Merge branch 'next' into feat/better-prometheus-metrics
This commit is contained in:
commit
5fca2d9482
|
|
@ -13,6 +13,7 @@ on:
|
||||||
- 'integration-tests/**'
|
- 'integration-tests/**'
|
||||||
- 'proto/**'
|
- 'proto/**'
|
||||||
- 'service/**'
|
- 'service/**'
|
||||||
|
- 'var/windows/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -31,6 +32,7 @@ on:
|
||||||
- 'integration-tests/**'
|
- 'integration-tests/**'
|
||||||
- 'proto/**'
|
- 'proto/**'
|
||||||
- 'service/**'
|
- 'service/**'
|
||||||
|
- 'var/windows/**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -112,6 +114,10 @@ jobs:
|
||||||
integration-tests
|
integration-tests
|
||||||
!integration-tests/node_modules
|
!integration-tests/node_modules
|
||||||
|
|
||||||
|
- name: Install msitools
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y msitools
|
||||||
|
|
||||||
- name: Install goreleaser
|
- name: Install goreleaser
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
service/OliveTin
|
service/OliveTin
|
||||||
service/OliveTin.armhf
|
service/OliveTin.armhf
|
||||||
service/OliveTin.exe
|
service/OliveTin.exe
|
||||||
|
service/resource_windows_*.syso
|
||||||
service/reports
|
service/reports
|
||||||
releases/
|
releases/
|
||||||
dist/
|
dist/
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ version: 2
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- make service-prep
|
- make service-prep
|
||||||
|
- make windows-resources VERSION={{ .Version }}
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- env:
|
- env:
|
||||||
|
|
@ -42,6 +43,8 @@ builds:
|
||||||
|
|
||||||
checksum:
|
checksum:
|
||||||
name_template: 'checksums.txt'
|
name_template: 'checksums.txt'
|
||||||
|
extra_files:
|
||||||
|
- glob: ./dist/OliveTin-windows-amd64.msi
|
||||||
snapshot:
|
snapshot:
|
||||||
version_template: "{{ .Branch }}-{{ .ShortCommit }}"
|
version_template: "{{ .Branch }}-{{ .ShortCommit }}"
|
||||||
changelog:
|
changelog:
|
||||||
|
|
@ -175,6 +178,8 @@ nfpms:
|
||||||
dst: /usr/share/man/man1/OliveTin.1.gz
|
dst: /usr/share/man/man1/OliveTin.1.gz
|
||||||
|
|
||||||
release:
|
release:
|
||||||
|
extra_files:
|
||||||
|
- glob: ./dist/OliveTin-windows-amd64.msi
|
||||||
footer: |
|
footer: |
|
||||||
## Container images (from GitHub)
|
## Container images (from GitHub)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,6 @@ plugins:
|
||||||
- '@semantic-release/git'
|
- '@semantic-release/git'
|
||||||
- - "@semantic-release/exec"
|
- - "@semantic-release/exec"
|
||||||
- publishCmd: |
|
- publishCmd: |
|
||||||
goreleaser release --clean --timeout 60m
|
VERSION=${nextRelease.version} GORELEASER_TIMEOUT=60m ./var/windows/goreleaser-release-with-msi.sh
|
||||||
|
|
||||||
tagFormat: '${version}'
|
tagFormat: '${version}'
|
||||||
|
|
|
||||||
9
Makefile
9
Makefile
|
|
@ -8,6 +8,12 @@ service:
|
||||||
service-prep:
|
service-prep:
|
||||||
$(MAKE) -wC service prep
|
$(MAKE) -wC service prep
|
||||||
|
|
||||||
|
windows-resources:
|
||||||
|
VERSION="$(VERSION)" ./var/windows/generate-resources.sh
|
||||||
|
|
||||||
|
windows-msi:
|
||||||
|
VERSION="$(VERSION)" ./var/windows/build-msi.sh
|
||||||
|
|
||||||
service-unittests:
|
service-unittests:
|
||||||
$(MAKE) -wC service unittests
|
$(MAKE) -wC service unittests
|
||||||
|
|
||||||
|
|
@ -59,10 +65,11 @@ clean:
|
||||||
$(call delete-files,OliveTin)
|
$(call delete-files,OliveTin)
|
||||||
$(call delete-files,OliveTin.armhf)
|
$(call delete-files,OliveTin.armhf)
|
||||||
$(call delete-files,OliveTin.exe)
|
$(call delete-files,OliveTin.exe)
|
||||||
|
rm -f service/resource_windows_*.syso
|
||||||
$(call delete-files,reports)
|
$(call delete-files,reports)
|
||||||
$(call delete-files,gen)
|
$(call delete-files,gen)
|
||||||
|
|
||||||
config-tool:
|
config-tool:
|
||||||
cd service && go run cmd/config-tool/main.go
|
cd service && go run cmd/config-tool/main.go
|
||||||
|
|
||||||
.PHONY: proto service
|
.PHONY: proto service windows-resources windows-msi
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// @generated by protoc-gen-es v2.12.0
|
// @generated by protoc-gen-es v2.12.1
|
||||||
// @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
|
// @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
|
|
@ -751,6 +751,11 @@ export declare type LogEntry = Message<"olivetin.api.v1.LogEntry"> & {
|
||||||
* @generated from field: string justification = 23;
|
* @generated from field: string justification = 23;
|
||||||
*/
|
*/
|
||||||
justification: string;
|
justification: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: repeated olivetin.api.v1.StartActionArgument arguments = 24;
|
||||||
|
*/
|
||||||
|
arguments: StartActionArgument[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -52,6 +52,10 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
choiceDisplayLabel,
|
||||||
|
syncStateFromModelValue
|
||||||
|
} from './choiceComboboxHelpers.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
|
|
@ -117,23 +121,23 @@ const filteredChoices = computed(() => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.modelValue, () => {
|
watch([() => props.modelValue, () => props.choices], () => {
|
||||||
if (!isOpen.value) {
|
if (!isOpen.value) {
|
||||||
query.value = selectedLabel()
|
syncFromModelValue()
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
function choiceLabel(choice) {
|
function choiceLabel(choice) {
|
||||||
return choice.title || choice.value
|
return choiceDisplayLabel(choice)
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedLabel() {
|
function syncFromModelValue() {
|
||||||
const match = props.choices.find(choice => choice.value === props.modelValue)
|
const next = syncStateFromModelValue(props.choices, props.modelValue)
|
||||||
if (!match) {
|
query.value = next.query
|
||||||
return props.modelValue || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return choiceLabel(match)
|
if (next.modelValue !== props.modelValue) {
|
||||||
|
emitValue(next.modelValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openList() {
|
function openList() {
|
||||||
|
|
@ -144,7 +148,7 @@ function openList() {
|
||||||
|
|
||||||
function closeList() {
|
function closeList() {
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
query.value = selectedLabel()
|
syncFromModelValue()
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitValue(value) {
|
function emitValue(value) {
|
||||||
|
|
@ -153,11 +157,15 @@ function emitValue(value) {
|
||||||
|
|
||||||
function selectChoice(choice) {
|
function selectChoice(choice) {
|
||||||
emitValue(choice.value)
|
emitValue(choice.value)
|
||||||
closeList()
|
query.value = choiceLabel(choice)
|
||||||
|
isOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
query.value = isOpen.value ? query.value : selectedLabel()
|
if (!isOpen.value) {
|
||||||
|
syncFromModelValue()
|
||||||
|
}
|
||||||
|
|
||||||
openList()
|
openList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
export function findSelectedChoice (choices, modelValue) {
|
||||||
|
if (!modelValue) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return choices.find(choice => choice.value === modelValue) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function choiceDisplayLabel (choice) {
|
||||||
|
return choice.title || choice.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayLabelForModelValue (choices, modelValue) {
|
||||||
|
const match = findSelectedChoice(choices, modelValue)
|
||||||
|
return match ? choiceDisplayLabel(match) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizedModelValue (choices, modelValue) {
|
||||||
|
if (!modelValue) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return findSelectedChoice(choices, modelValue) ? modelValue : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncStateFromModelValue (choices, modelValue) {
|
||||||
|
const normalizedValue = normalizedModelValue(choices, modelValue)
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: displayLabelForModelValue(choices, normalizedValue),
|
||||||
|
modelValue: normalizedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {
|
||||||
|
displayLabelForModelValue,
|
||||||
|
normalizedModelValue,
|
||||||
|
syncStateFromModelValue
|
||||||
|
} from './choiceComboboxHelpers.js'
|
||||||
|
|
||||||
|
const choices = [
|
||||||
|
{ title: 'Production', value: 'prod' },
|
||||||
|
{ title: 'Staging', value: 'stage' }
|
||||||
|
]
|
||||||
|
|
||||||
|
test('displayLabelForModelValue returns the choice label for valid values', () => {
|
||||||
|
assert.equal(displayLabelForModelValue(choices, 'prod'), 'Production')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('displayLabelForModelValue clears invalid enum values instead of echoing them', () => {
|
||||||
|
assert.equal(displayLabelForModelValue(choices, 'missing'), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('normalizedModelValue keeps valid values and clears invalid ones', () => {
|
||||||
|
assert.equal(normalizedModelValue(choices, 'stage'), 'stage')
|
||||||
|
assert.equal(normalizedModelValue(choices, 'missing'), '')
|
||||||
|
assert.equal(normalizedModelValue(choices, ''), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('syncStateFromModelValue clears invalid selections for closed-state sync', () => {
|
||||||
|
assert.deepEqual(syncStateFromModelValue(choices, 'missing'), {
|
||||||
|
query: '',
|
||||||
|
modelValue: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('syncStateFromModelValue preserves valid selections for closed-state sync', () => {
|
||||||
|
assert.deepEqual(syncStateFromModelValue(choices, 'stage'), {
|
||||||
|
query: 'Staging',
|
||||||
|
modelValue: 'stage'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { needsArgumentForm } from './needsArgumentForm.js'
|
||||||
|
|
||||||
|
const nonStorableArgumentTypes = new Set([
|
||||||
|
'password',
|
||||||
|
'very_dangerous_raw_string'
|
||||||
|
])
|
||||||
|
|
||||||
|
function isNonStorableArgumentType (type) {
|
||||||
|
return nonStorableArgumentTypes.has(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logEntryArgumentsToStartActionArgs (logEntry) {
|
||||||
|
return (logEntry?.arguments ?? []).map((arg) => ({
|
||||||
|
name: arg.name,
|
||||||
|
value: arg.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rerunNeedsArgumentForm (action, logEntry) {
|
||||||
|
if (action?.justification && !logEntry?.justification) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsArgumentForm(action)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasMissingRerunArguments(action, logEntry?.arguments ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMissingRerunArguments (action, storedArgs) {
|
||||||
|
const stored = new Map(storedArgs.map((arg) => [arg.name, arg.value]))
|
||||||
|
|
||||||
|
for (const arg of action?.arguments ?? []) {
|
||||||
|
if (isNonStorableArgumentType(arg.type)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.required && !stored.has(arg.name)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArgumentFormQuery (logEntry) {
|
||||||
|
const query = {}
|
||||||
|
|
||||||
|
for (const arg of logEntry?.arguments ?? []) {
|
||||||
|
query[arg.name] = arg.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRerunStartActionArgs (bindingId, logEntry, action) {
|
||||||
|
const startActionArgs = {
|
||||||
|
bindingId,
|
||||||
|
arguments: logEntryArgumentsToStartActionArgs(logEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action?.justification && logEntry?.justification) {
|
||||||
|
startActionArgs.justification = logEntry.justification
|
||||||
|
}
|
||||||
|
|
||||||
|
return startActionArgs
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {
|
||||||
|
buildArgumentFormQuery,
|
||||||
|
buildRerunStartActionArgs,
|
||||||
|
hasMissingRerunArguments,
|
||||||
|
logEntryArgumentsToStartActionArgs,
|
||||||
|
rerunNeedsArgumentForm
|
||||||
|
} from '../utils/rerunArguments.js'
|
||||||
|
|
||||||
|
test('logEntryArgumentsToStartActionArgs maps proto arguments for StartAction', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
logEntryArgumentsToStartActionArgs({
|
||||||
|
arguments: [
|
||||||
|
{ name: 'host', value: 'example.com' },
|
||||||
|
{ name: 'port', value: '443' }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{ name: 'host', value: 'example.com' },
|
||||||
|
{ name: 'port', value: '443' }
|
||||||
|
]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hasMissingRerunArguments requires password fields to be re-entered', () => {
|
||||||
|
const action = {
|
||||||
|
arguments: [
|
||||||
|
{ name: 'user', type: 'ascii_identifier', required: true },
|
||||||
|
{ name: 'pass', type: 'password', required: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
hasMissingRerunArguments(action, [{ name: 'user', value: 'alice' }]),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hasMissingRerunArguments requires very_dangerous_raw_string fields to be re-entered', () => {
|
||||||
|
const action = {
|
||||||
|
arguments: [
|
||||||
|
{ name: 'host', type: 'ascii_identifier', required: true },
|
||||||
|
{ name: 'payload', type: 'very_dangerous_raw_string', required: false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
hasMissingRerunArguments(action, [{ name: 'host', value: 'db-1' }]),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hasMissingRerunArguments detects missing required stored arguments', () => {
|
||||||
|
const action = {
|
||||||
|
arguments: [{ name: 'host', type: 'ascii_identifier', required: true }]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(hasMissingRerunArguments(action, []), true)
|
||||||
|
assert.equal(
|
||||||
|
hasMissingRerunArguments(action, [{ name: 'host', value: 'db-1' }]),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rerunNeedsArgumentForm can start directly when stored args are complete', () => {
|
||||||
|
const action = {
|
||||||
|
arguments: [{ name: 'host', type: 'ascii_identifier', required: true }]
|
||||||
|
}
|
||||||
|
const logEntry = {
|
||||||
|
arguments: [{ name: 'host', value: 'db-1' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(rerunNeedsArgumentForm(action, logEntry), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rerunNeedsArgumentForm opens the form when justification is missing', () => {
|
||||||
|
const action = { justification: true, arguments: [] }
|
||||||
|
|
||||||
|
assert.equal(rerunNeedsArgumentForm(action, {}), true)
|
||||||
|
assert.equal(
|
||||||
|
rerunNeedsArgumentForm(action, { justification: 'approved change' }),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildRerunStartActionArgs includes stored justification', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildRerunStartActionArgs('binding-1', {
|
||||||
|
arguments: [{ name: 'host', value: 'db-1' }],
|
||||||
|
justification: 'maintenance window'
|
||||||
|
}, {
|
||||||
|
justification: true,
|
||||||
|
arguments: [{ name: 'host', type: 'ascii_identifier' }]
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
bindingId: 'binding-1',
|
||||||
|
arguments: [{ name: 'host', value: 'db-1' }],
|
||||||
|
justification: 'maintenance window'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildArgumentFormQuery prefills non-password stored arguments', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildArgumentFormQuery({
|
||||||
|
arguments: [
|
||||||
|
{ name: 'host', value: 'db-1' },
|
||||||
|
{ name: 'port', value: '5432' }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
host: 'db-1',
|
||||||
|
port: '5432'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -5,6 +5,8 @@ import {
|
||||||
getRootAndWait,
|
getRootAndWait,
|
||||||
getActionButtons,
|
getActionButtons,
|
||||||
takeScreenshotOnFailure,
|
takeScreenshotOnFailure,
|
||||||
|
waitForArgumentFormPage,
|
||||||
|
waitForArgumentFormReady,
|
||||||
} from '../../lib/elements.js'
|
} from '../../lib/elements.js'
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -46,11 +48,13 @@ describe('config: multipleDropdowns', function () {
|
||||||
|
|
||||||
await button.click()
|
await button.click()
|
||||||
|
|
||||||
// Wait for navigation to argument form page
|
await waitForArgumentFormPage(8000)
|
||||||
await webdriver.wait(new Condition('wait for argument form page', async () => {
|
await waitForArgumentFormReady(10000)
|
||||||
const url = await webdriver.getCurrentUrl()
|
|
||||||
return url.includes('/actionBinding/') && url.includes('/argumentForm')
|
await webdriver.wait(new Condition('wait for choice comboboxes', async () => {
|
||||||
}), 8000)
|
const boxes = await webdriver.findElements(By.css('main .choice-combobox'))
|
||||||
|
return boxes.length >= 2
|
||||||
|
}), 10000)
|
||||||
|
|
||||||
const comboboxes = await webdriver.findElements(By.css('main .choice-combobox'))
|
const comboboxes = await webdriver.findElements(By.css('main .choice-combobox'))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,7 @@ message LogEntry {
|
||||||
bool queued = 21;
|
bool queued = 21;
|
||||||
string queued_for_group = 22;
|
string queued_for_group = 22;
|
||||||
string justification = 23;
|
string justification = 23;
|
||||||
|
repeated StartActionArgument arguments = 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetLogsResponse {
|
message GetLogsResponse {
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,11 @@ compile-x64-lin:
|
||||||
go build -o OliveTin
|
go build -o OliveTin
|
||||||
go env -u GOOS
|
go env -u GOOS
|
||||||
|
|
||||||
compile-x64-win:
|
compile-x64-win: windows-resources
|
||||||
go env -w GOOS=windows GOARCH=amd64
|
GOOS=windows GOARCH=amd64 go build -o OliveTin.exe
|
||||||
go build -o OliveTin.exe
|
|
||||||
go env -u GOOS GOARCH
|
windows-resources:
|
||||||
|
$(MAKE) -wC .. windows-resources
|
||||||
|
|
||||||
compile: compile-armhf compile-x64-lin compile-x64-win
|
compile: compile-armhf compile-x64-lin compile-x64-win
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1424,6 +1424,7 @@ type LogEntry struct {
|
||||||
Queued bool `protobuf:"varint,21,opt,name=queued,proto3" json:"queued,omitempty"`
|
Queued bool `protobuf:"varint,21,opt,name=queued,proto3" json:"queued,omitempty"`
|
||||||
QueuedForGroup string `protobuf:"bytes,22,opt,name=queued_for_group,json=queuedForGroup,proto3" json:"queued_for_group,omitempty"`
|
QueuedForGroup string `protobuf:"bytes,22,opt,name=queued_for_group,json=queuedForGroup,proto3" json:"queued_for_group,omitempty"`
|
||||||
Justification string `protobuf:"bytes,23,opt,name=justification,proto3" json:"justification,omitempty"`
|
Justification string `protobuf:"bytes,23,opt,name=justification,proto3" json:"justification,omitempty"`
|
||||||
|
Arguments []*StartActionArgument `protobuf:"bytes,24,rep,name=arguments,proto3" json:"arguments,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
@ -1605,6 +1606,13 @@ func (x *LogEntry) GetJustification() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *LogEntry) GetArguments() []*StartActionArgument {
|
||||||
|
if x != nil {
|
||||||
|
return x.Arguments
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type GetLogsResponse struct {
|
type GetLogsResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"`
|
Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"`
|
||||||
|
|
@ -4691,7 +4699,7 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
||||||
"\vdate_filter\x18\x02 \x01(\tR\n" +
|
"\vdate_filter\x18\x02 \x01(\tR\n" +
|
||||||
"dateFilter\x12\x1b\n" +
|
"dateFilter\x12\x1b\n" +
|
||||||
"\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x16\n" +
|
"\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x16\n" +
|
||||||
"\x06filter\x18\x04 \x01(\tR\x06filter\"\xf1\x05\n" +
|
"\x06filter\x18\x04 \x01(\tR\x06filter\"\xb5\x06\n" +
|
||||||
"\bLogEntry\x12)\n" +
|
"\bLogEntry\x12)\n" +
|
||||||
"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" +
|
"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" +
|
||||||
"\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" +
|
"\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" +
|
||||||
|
|
@ -4717,7 +4725,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
||||||
"binding_id\x18\x14 \x01(\tR\tbindingId\x12\x16\n" +
|
"binding_id\x18\x14 \x01(\tR\tbindingId\x12\x16\n" +
|
||||||
"\x06queued\x18\x15 \x01(\bR\x06queued\x12(\n" +
|
"\x06queued\x18\x15 \x01(\bR\x06queued\x12(\n" +
|
||||||
"\x10queued_for_group\x18\x16 \x01(\tR\x0equeuedForGroup\x12$\n" +
|
"\x10queued_for_group\x18\x16 \x01(\tR\x0equeuedForGroup\x12$\n" +
|
||||||
"\rjustification\x18\x17 \x01(\tR\rjustification\"\xca\x01\n" +
|
"\rjustification\x18\x17 \x01(\tR\rjustification\x12B\n" +
|
||||||
|
"\targuments\x18\x18 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\"\xca\x01\n" +
|
||||||
"\x0fGetLogsResponse\x12-\n" +
|
"\x0fGetLogsResponse\x12-\n" +
|
||||||
"\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" +
|
"\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" +
|
||||||
"\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" +
|
"\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" +
|
||||||
|
|
@ -5055,88 +5064,89 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
|
||||||
12, // 13: olivetin.api.v1.StartActionAndWaitRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument
|
12, // 13: olivetin.api.v1.StartActionAndWaitRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument
|
||||||
21, // 14: olivetin.api.v1.StartActionAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
|
21, // 14: olivetin.api.v1.StartActionAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
|
||||||
21, // 15: olivetin.api.v1.StartActionByGetAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
|
21, // 15: olivetin.api.v1.StartActionByGetAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
|
||||||
21, // 16: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
|
12, // 16: olivetin.api.v1.LogEntry.arguments:type_name -> olivetin.api.v1.StartActionArgument
|
||||||
21, // 17: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
|
21, // 17: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
|
||||||
21, // 18: olivetin.api.v1.ExecutionQueueAction.entries:type_name -> olivetin.api.v1.LogEntry
|
21, // 18: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
|
||||||
26, // 19: olivetin.api.v1.ExecutionQueueGroup.actions:type_name -> olivetin.api.v1.ExecutionQueueAction
|
21, // 19: olivetin.api.v1.ExecutionQueueAction.entries:type_name -> olivetin.api.v1.LogEntry
|
||||||
27, // 20: olivetin.api.v1.GetExecutionQueueResponse.groups:type_name -> olivetin.api.v1.ExecutionQueueGroup
|
26, // 20: olivetin.api.v1.ExecutionQueueGroup.actions:type_name -> olivetin.api.v1.ExecutionQueueAction
|
||||||
21, // 21: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
|
27, // 21: olivetin.api.v1.GetExecutionQueueResponse.groups:type_name -> olivetin.api.v1.ExecutionQueueGroup
|
||||||
34, // 22: olivetin.api.v1.ExecutionStatusResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
|
21, // 22: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
|
||||||
80, // 23: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
|
34, // 23: olivetin.api.v1.ExecutionStatusResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
|
||||||
81, // 24: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
|
80, // 24: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
|
||||||
50, // 25: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
|
81, // 25: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
|
||||||
51, // 26: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
|
50, // 26: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
|
||||||
53, // 27: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
|
51, // 27: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
|
||||||
54, // 28: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted
|
53, // 28: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
|
||||||
49, // 29: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk
|
54, // 29: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted
|
||||||
52, // 30: olivetin.api.v1.EventStreamResponse.heartbeat:type_name -> olivetin.api.v1.EventHeartbeat
|
49, // 30: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk
|
||||||
21, // 31: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
|
52, // 31: olivetin.api.v1.EventStreamResponse.heartbeat:type_name -> olivetin.api.v1.EventHeartbeat
|
||||||
21, // 32: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
|
21, // 32: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
|
||||||
68, // 33: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
|
21, // 33: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
|
||||||
67, // 34: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
|
68, // 34: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
|
||||||
7, // 35: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
|
67, // 35: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
|
||||||
0, // 36: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
|
7, // 36: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
|
||||||
34, // 37: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
|
0, // 37: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
|
||||||
73, // 38: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
|
34, // 38: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
|
||||||
5, // 39: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
|
73, // 39: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
|
||||||
42, // 40: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
|
5, // 40: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
|
||||||
8, // 41: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
|
42, // 41: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
|
||||||
11, // 42: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
|
8, // 42: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
|
||||||
14, // 43: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
|
11, // 43: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
|
||||||
16, // 44: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
|
14, // 44: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
|
||||||
18, // 45: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
|
16, // 45: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
|
||||||
75, // 46: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
|
18, // 46: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
|
||||||
55, // 47: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
|
75, // 47: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
|
||||||
33, // 48: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
|
55, // 48: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
|
||||||
20, // 49: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
|
33, // 49: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
|
||||||
23, // 50: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
|
20, // 50: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
|
||||||
25, // 51: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
|
23, // 51: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
|
||||||
29, // 52: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
|
25, // 52: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
|
||||||
36, // 53: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
|
29, // 53: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
|
||||||
38, // 54: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest
|
36, // 54: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
|
||||||
40, // 55: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
|
38, // 55: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest
|
||||||
43, // 56: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
|
40, // 56: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
|
||||||
45, // 57: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
|
43, // 57: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
|
||||||
57, // 58: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
|
45, // 58: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
|
||||||
59, // 59: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
|
57, // 59: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
|
||||||
61, // 60: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
|
59, // 60: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
|
||||||
47, // 61: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
|
61, // 61: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
|
||||||
63, // 62: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
|
47, // 62: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
|
||||||
65, // 63: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
|
63, // 63: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
|
||||||
69, // 64: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
|
65, // 64: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
|
||||||
71, // 65: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
|
69, // 65: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
|
||||||
74, // 66: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
|
71, // 66: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
|
||||||
6, // 67: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
|
74, // 67: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
|
||||||
13, // 68: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
|
6, // 68: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
|
||||||
15, // 69: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
|
13, // 69: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
|
||||||
17, // 70: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
|
15, // 70: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
|
||||||
19, // 71: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
|
17, // 71: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
|
||||||
13, // 72: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
|
19, // 72: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
|
||||||
56, // 73: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
|
13, // 73: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
|
||||||
35, // 74: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
|
56, // 74: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
|
||||||
22, // 75: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
|
35, // 75: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
|
||||||
24, // 76: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
|
22, // 76: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
|
||||||
28, // 77: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
|
24, // 77: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
|
||||||
30, // 78: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
|
28, // 78: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
|
||||||
37, // 79: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
|
30, // 79: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
|
||||||
39, // 80: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse
|
37, // 80: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
|
||||||
41, // 81: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
|
39, // 81: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse
|
||||||
44, // 82: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
|
41, // 82: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
|
||||||
46, // 83: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
|
44, // 83: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
|
||||||
58, // 84: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
|
46, // 84: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
|
||||||
60, // 85: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
|
58, // 85: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
|
||||||
62, // 86: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
|
60, // 86: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
|
||||||
48, // 87: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
|
62, // 87: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
|
||||||
64, // 88: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
|
48, // 88: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
|
||||||
66, // 89: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
|
64, // 89: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
|
||||||
70, // 90: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
|
66, // 90: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
|
||||||
72, // 91: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
|
70, // 91: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
|
||||||
5, // 92: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
|
72, // 92: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
|
||||||
67, // [67:93] is the sub-list for method output_type
|
5, // 93: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
|
||||||
41, // [41:67] is the sub-list for method input_type
|
68, // [68:94] is the sub-list for method output_type
|
||||||
41, // [41:41] is the sub-list for extension type_name
|
42, // [42:68] is the sub-list for method input_type
|
||||||
41, // [41:41] is the sub-list for extension extendee
|
42, // [42:42] is the sub-list for extension type_name
|
||||||
0, // [0:41] is the sub-list for field type_name
|
42, // [42:42] is the sub-list for extension extendee
|
||||||
|
0, // [0:42] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_olivetin_api_v1_olivetin_proto_init() }
|
func init() { file_olivetin_api_v1_olivetin_proto_init() }
|
||||||
|
|
|
||||||
|
|
@ -388,6 +388,7 @@ func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry
|
||||||
BindingId: logEntry.GetBindingId(),
|
BindingId: logEntry.GetBindingId(),
|
||||||
DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
|
DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
|
||||||
Justification: logEntry.Justification,
|
Justification: logEntry.Justification,
|
||||||
|
Arguments: logEntryArgumentsToProto(logEntry.Arguments),
|
||||||
}
|
}
|
||||||
|
|
||||||
if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil {
|
if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil {
|
||||||
|
|
@ -1548,14 +1549,15 @@ func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if execReqLogEntry.Binding.Action.Justification {
|
if err := validateRestartLogEntry(execReqLogEntry); err != nil {
|
||||||
return nil, restartRequiresJustificationError()
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
|
authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
|
||||||
execReq := executor.ExecutionRequest{
|
execReq := executor.ExecutionRequest{
|
||||||
Binding: execReqLogEntry.Binding,
|
Binding: execReqLogEntry.Binding,
|
||||||
Arguments: make(map[string]string),
|
Arguments: copyStringMap(execReqLogEntry.Arguments),
|
||||||
|
Justification: execReqLogEntry.Justification,
|
||||||
AuthenticatedUser: authenticatedUser,
|
AuthenticatedUser: authenticatedUser,
|
||||||
Cfg: api.cfg,
|
Cfg: api.cfg,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
|
|
||||||
|
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||||
|
"github.com/OliveTin/OliveTin/internal/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
func logEntryArgumentsToProto(args map[string]string) []*apiv1.StartActionArgument {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(args))
|
||||||
|
for name := range args {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
out := make([]*apiv1.StartActionArgument, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
out = append(out, &apiv1.StartActionArgument{
|
||||||
|
Name: name,
|
||||||
|
Value: args[name],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStringMap(source map[string]string) map[string]string {
|
||||||
|
copied := make(map[string]string, len(source))
|
||||||
|
for key, value := range source {
|
||||||
|
copied[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return copied
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartArgumentsIncompleteError() error {
|
||||||
|
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("stored arguments are incomplete for restart; use StartAction with the required arguments instead"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRestartLogEntry(entry *executor.InternalLogEntry) error {
|
||||||
|
if entry.Binding.Action.Justification && strings.TrimSpace(entry.Justification) == "" {
|
||||||
|
return restartRequiresJustificationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
if executor.RestartArgumentsIncomplete(entry.Binding.Action, entry.Binding.Entity, entry.Arguments) {
|
||||||
|
return restartArgumentsIncompleteError()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,376 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||||
|
config "github.com/OliveTin/OliveTin/internal/config"
|
||||||
|
"github.com/OliveTin/OliveTin/internal/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
func argumentAction(title, shell string, args []config.ActionArgument) *config.Action {
|
||||||
|
return &config.Action{
|
||||||
|
Title: title,
|
||||||
|
Shell: shell,
|
||||||
|
MaxConcurrent: 1,
|
||||||
|
Arguments: args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForLogArguments(t *testing.T, ex *executor.Executor, trackingID string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
entry, ok := ex.GetLog(trackingID)
|
||||||
|
if ok && entry.Arguments != nil {
|
||||||
|
return entry.Arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("timed out waiting for arguments on log %s", trackingID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutionStatusIncludesStoredArguments(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Actions = []*config.Action{
|
||||||
|
argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
|
||||||
|
{Name: "host", Type: "ascii_identifier"},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := executor.DefaultExecutor(cfg)
|
||||||
|
ex.RebuildActionMap()
|
||||||
|
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
|
||||||
|
require.NotNil(t, binding)
|
||||||
|
|
||||||
|
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
|
||||||
|
BindingId: binding.ID,
|
||||||
|
Arguments: []*apiv1.StartActionArgument{
|
||||||
|
{Name: "host", Value: "example.com"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
statusResp, err := client.ExecutionStatus(context.Background(), connect.NewRequest(&apiv1.ExecutionStatusRequest{
|
||||||
|
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, statusResp.Msg.LogEntry)
|
||||||
|
require.Len(t, statusResp.Msg.LogEntry.Arguments, 1)
|
||||||
|
assert.Equal(t, "host", statusResp.Msg.LogEntry.Arguments[0].Name)
|
||||||
|
assert.Equal(t, "example.com", statusResp.Msg.LogEntry.Arguments[0].Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutionStatusOmitsPasswordArguments(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Actions = []*config.Action{
|
||||||
|
{
|
||||||
|
Title: "Connect",
|
||||||
|
Exec: []string{"echo", "{{ user }}"},
|
||||||
|
MaxConcurrent: 1,
|
||||||
|
Arguments: []config.ActionArgument{
|
||||||
|
{Name: "user", Type: "ascii_identifier"},
|
||||||
|
{Name: "pass", Type: "password"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := executor.DefaultExecutor(cfg)
|
||||||
|
ex.RebuildActionMap()
|
||||||
|
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
|
||||||
|
require.NotNil(t, binding)
|
||||||
|
|
||||||
|
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
|
||||||
|
BindingId: binding.ID,
|
||||||
|
Arguments: []*apiv1.StartActionArgument{
|
||||||
|
{Name: "user", Value: "alice"},
|
||||||
|
{Name: "pass", Value: "secret"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
statusResp, err := client.ExecutionStatus(context.Background(), connect.NewRequest(&apiv1.ExecutionStatusRequest{
|
||||||
|
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, statusResp.Msg.LogEntry)
|
||||||
|
|
||||||
|
for _, arg := range statusResp.Msg.LogEntry.Arguments {
|
||||||
|
assert.NotEqual(t, "pass", arg.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Len(t, statusResp.Msg.LogEntry.Arguments, 1)
|
||||||
|
assert.Equal(t, "user", statusResp.Msg.LogEntry.Arguments[0].Name)
|
||||||
|
assert.Equal(t, "alice", statusResp.Msg.LogEntry.Arguments[0].Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartActionReusesStoredArguments(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Actions = []*config.Action{
|
||||||
|
argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
|
||||||
|
{Name: "host", Type: "ascii_identifier"},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := executor.DefaultExecutor(cfg)
|
||||||
|
ex.RebuildActionMap()
|
||||||
|
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
|
||||||
|
require.NotNil(t, binding)
|
||||||
|
|
||||||
|
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
|
||||||
|
BindingId: binding.ID,
|
||||||
|
Arguments: []*apiv1.StartActionArgument{
|
||||||
|
{Name: "host", Value: "server-a"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
originalArgs := waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
|
||||||
|
assert.Equal(t, "server-a", originalArgs["host"])
|
||||||
|
|
||||||
|
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
|
||||||
|
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, restartResp.Msg.ExecutionTrackingId)
|
||||||
|
assert.NotEqual(t, startResp.Msg.ExecutionTrackingId, restartResp.Msg.ExecutionTrackingId)
|
||||||
|
|
||||||
|
restartedArgs := waitForLogArguments(t, ex, restartResp.Msg.ExecutionTrackingId)
|
||||||
|
assert.Equal(t, "server-a", restartedArgs["host"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartActionRejectsIncompleteStoredArguments(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Actions = []*config.Action{
|
||||||
|
{
|
||||||
|
Title: "Connect",
|
||||||
|
Exec: []string{"echo", "{{ user }}"},
|
||||||
|
MaxConcurrent: 1,
|
||||||
|
Arguments: []config.ActionArgument{
|
||||||
|
{Name: "user", Type: "ascii_identifier"},
|
||||||
|
{Name: "pass", Type: "password"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := executor.DefaultExecutor(cfg)
|
||||||
|
ex.RebuildActionMap()
|
||||||
|
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
|
||||||
|
require.NotNil(t, binding)
|
||||||
|
|
||||||
|
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
|
||||||
|
BindingId: binding.ID,
|
||||||
|
Arguments: []*apiv1.StartActionArgument{
|
||||||
|
{Name: "user", Value: "alice"},
|
||||||
|
{Name: "pass", Value: "secret"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
|
||||||
|
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
|
||||||
|
}))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "stored arguments are incomplete for restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartActionRejectsMissingRequiredStoredArguments(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Actions = []*config.Action{
|
||||||
|
argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
|
||||||
|
{Name: "host", Type: "ascii_identifier"},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := executor.DefaultExecutor(cfg)
|
||||||
|
ex.RebuildActionMap()
|
||||||
|
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
|
||||||
|
require.NotNil(t, binding)
|
||||||
|
|
||||||
|
trackingID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||||
|
ex.SetLog(trackingID, &executor.InternalLogEntry{
|
||||||
|
Binding: binding,
|
||||||
|
ExecutionFinished: true,
|
||||||
|
ExecutionTrackingID: trackingID,
|
||||||
|
Arguments: map[string]string{},
|
||||||
|
})
|
||||||
|
|
||||||
|
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
_, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
|
||||||
|
ExecutionTrackingId: trackingID,
|
||||||
|
}))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "stored arguments are incomplete for restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogEntryArgumentsToProto(t *testing.T) {
|
||||||
|
assert.Nil(t, logEntryArgumentsToProto(nil))
|
||||||
|
assert.Nil(t, logEntryArgumentsToProto(map[string]string{}))
|
||||||
|
|
||||||
|
out := logEntryArgumentsToProto(map[string]string{
|
||||||
|
"host": "example.com",
|
||||||
|
"port": "443",
|
||||||
|
})
|
||||||
|
require.Len(t, out, 2)
|
||||||
|
|
||||||
|
values := map[string]string{}
|
||||||
|
for _, arg := range out {
|
||||||
|
values[arg.Name] = arg.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "example.com", values["host"])
|
||||||
|
assert.Equal(t, "443", values["port"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyStringMap(t *testing.T) {
|
||||||
|
source := map[string]string{"host": "example.com"}
|
||||||
|
copied := copyStringMap(source)
|
||||||
|
|
||||||
|
assert.Equal(t, source, copied)
|
||||||
|
source["host"] = "changed"
|
||||||
|
assert.Equal(t, "example.com", copied["host"])
|
||||||
|
|
||||||
|
empty := copyStringMap(nil)
|
||||||
|
assert.NotNil(t, empty)
|
||||||
|
assert.Empty(t, empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartActionRequiresJustificationWhenMissingFromStoredLog(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Actions = []*config.Action{
|
||||||
|
{
|
||||||
|
Title: "Dangerous action",
|
||||||
|
Shell: "echo ok",
|
||||||
|
MaxConcurrent: 1,
|
||||||
|
Justification: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := executor.DefaultExecutor(cfg)
|
||||||
|
ex.RebuildActionMap()
|
||||||
|
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
|
||||||
|
require.NotNil(t, binding)
|
||||||
|
|
||||||
|
trackingID := "manual-log-without-justification"
|
||||||
|
ex.SetLog(trackingID, &executor.InternalLogEntry{
|
||||||
|
Binding: binding,
|
||||||
|
ExecutionFinished: true,
|
||||||
|
ExecutionTrackingID: trackingID,
|
||||||
|
})
|
||||||
|
|
||||||
|
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
_, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
|
||||||
|
ExecutionTrackingId: trackingID,
|
||||||
|
}))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "justification")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Actions = []*config.Action{
|
||||||
|
{
|
||||||
|
Title: "Dangerous action",
|
||||||
|
Shell: "echo ok",
|
||||||
|
MaxConcurrent: 1,
|
||||||
|
Justification: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := executor.DefaultExecutor(cfg)
|
||||||
|
ex.RebuildActionMap()
|
||||||
|
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
|
||||||
|
require.NotNil(t, binding)
|
||||||
|
|
||||||
|
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
|
||||||
|
BindingId: binding.ID,
|
||||||
|
Justification: "maintenance window",
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
originalLog, ok := ex.GetLog(startResp.Msg.ExecutionTrackingId)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "maintenance window", originalLog.Justification)
|
||||||
|
|
||||||
|
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
|
||||||
|
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
restartedLog, ok := ex.GetLog(restartResp.Msg.ExecutionTrackingId)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "maintenance window", restartedLog.Justification)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLogsIncludesStoredArguments(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Actions = []*config.Action{
|
||||||
|
argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
|
||||||
|
{Name: "host", Type: "ascii_identifier"},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := executor.DefaultExecutor(cfg)
|
||||||
|
ex.RebuildActionMap()
|
||||||
|
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
|
||||||
|
require.NotNil(t, binding)
|
||||||
|
|
||||||
|
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
|
||||||
|
BindingId: binding.ID,
|
||||||
|
Arguments: []*apiv1.StartActionArgument{
|
||||||
|
{Name: "host", Value: "db-1"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, startResp.Msg.ExecutionTrackingId)
|
||||||
|
|
||||||
|
logsResp, err := client.GetLogs(context.Background(), connect.NewRequest(&apiv1.GetLogsRequest{}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, logsResp.Msg.Logs)
|
||||||
|
|
||||||
|
var matched bool
|
||||||
|
for _, entry := range logsResp.Msg.Logs {
|
||||||
|
if entry.ExecutionTrackingId != startResp.Msg.ExecutionTrackingId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matched = true
|
||||||
|
require.Len(t, entry.Arguments, 1)
|
||||||
|
assert.Equal(t, "host", entry.Arguments[0].Name)
|
||||||
|
assert.Equal(t, "db-1", entry.Arguments[0].Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, matched, "expected log entry with stored arguments")
|
||||||
|
}
|
||||||
|
|
@ -89,29 +89,48 @@ var envConfigTests = []struct {
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setTestEnvInput(t *testing.T, input string) {
|
||||||
|
t.Helper()
|
||||||
|
if input != "" {
|
||||||
|
if err := os.Setenv("INPUT", input); err != nil {
|
||||||
|
t.Fatalf("Setenv INPUT: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Unsetenv("INPUT"); err != nil {
|
||||||
|
t.Fatalf("Unsetenv INPUT: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfigFromYAML(t *testing.T, yamlStr string, cfg *Config) bool {
|
||||||
|
t.Helper()
|
||||||
|
k := koanf.New(".")
|
||||||
|
if err := k.Load(rawbytes.Provider([]byte(yamlStr)), yaml.Parser()); err != nil {
|
||||||
|
t.Errorf("Error loading YAML: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return unmarshalRoot(k, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnvInConfig(t *testing.T) {
|
func TestEnvInConfig(t *testing.T) {
|
||||||
|
originalInput, hadInput := os.LookupEnv("INPUT")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if hadInput {
|
||||||
|
setTestEnvInput(t, originalInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTestEnvInput(t, "")
|
||||||
|
})
|
||||||
|
|
||||||
for _, tt := range envConfigTests {
|
for _, tt := range envConfigTests {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
setIfNotEmpty("INPUT", tt.input)
|
setTestEnvInput(t, tt.input)
|
||||||
k := koanf.New(".")
|
if !loadConfigFromYAML(t, tt.yaml, cfg) {
|
||||||
err := k.Load(rawbytes.Provider([]byte(tt.yaml)), yaml.Parser())
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error loading YAML: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !unmarshalRoot(k, cfg) {
|
|
||||||
t.Errorf("Error unmarshalling config for env=%q", tt.input)
|
t.Errorf("Error unmarshalling config for env=%q", tt.input)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
field := tt.selector(cfg)
|
field := tt.selector(cfg)
|
||||||
assert.Equal(t, tt.output, field,
|
assert.Equal(t, tt.output, field,
|
||||||
"Unmarshaled config field doesn't match expected value: env=%q", tt.input)
|
"Unmarshaled config field doesn't match expected value: env=%q", tt.input)
|
||||||
os.Unsetenv("INPUT")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setIfNotEmpty(key, val string) {
|
|
||||||
if val != "" {
|
|
||||||
os.Setenv(key, val)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,7 @@ type InternalLogEntry struct {
|
||||||
ActionTitle string
|
ActionTitle string
|
||||||
ActionIcon string
|
ActionIcon string
|
||||||
Justification string
|
Justification string
|
||||||
|
Arguments map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// .Binding can be nil, so we need to handle that.
|
// .Binding can be nil, so we need to handle that.
|
||||||
|
|
@ -856,6 +857,7 @@ func stepParseArgs(req *ExecutionRequest) bool {
|
||||||
return fail(req, err)
|
return fail(req, err)
|
||||||
}
|
}
|
||||||
mangleInvalidArgumentValues(req)
|
mangleInvalidArgumentValues(req)
|
||||||
|
copyStorableArgumentsToLogEntry(req)
|
||||||
|
|
||||||
if hasExec(req) {
|
if hasExec(req) {
|
||||||
return handleExecBranch(req)
|
return handleExecBranch(req)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
config "github.com/OliveTin/OliveTin/internal/config"
|
||||||
|
"github.com/OliveTin/OliveTin/internal/entities"
|
||||||
|
"github.com/OliveTin/OliveTin/internal/tpl"
|
||||||
|
)
|
||||||
|
|
||||||
|
func argumentTypeStorableInLog(argType string) bool {
|
||||||
|
switch argType {
|
||||||
|
case "password", "very_dangerous_raw_string":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func storableArgumentNames(action *config.Action) map[string]struct{} {
|
||||||
|
if action == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make(map[string]struct{}, len(action.Arguments))
|
||||||
|
for i := range action.Arguments {
|
||||||
|
arg := &action.Arguments[i]
|
||||||
|
if !argumentTypeStorableInLog(arg.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
names[arg.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func storableArgumentNamesFromRequest(req *ExecutionRequest) map[string]struct{} {
|
||||||
|
if req == nil || req.Binding == nil || req.Binding.Action == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return storableArgumentNames(req.Binding.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStorableArgumentName(name string, allowedNames map[string]struct{}) bool {
|
||||||
|
if strings.HasPrefix(name, config.ReservedArgumentNamePrefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := allowedNames[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectStorableArguments(args map[string]string, allowedNames map[string]struct{}) map[string]string {
|
||||||
|
result := make(map[string]string)
|
||||||
|
for name, value := range args {
|
||||||
|
if isStorableArgumentName(name, allowedNames) {
|
||||||
|
result[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterStorableArguments(args map[string]string, allowedNames map[string]struct{}) map[string]string {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := collectStorableArguments(args, allowedNames)
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func storableArgumentsFromRequest(req *ExecutionRequest) map[string]string {
|
||||||
|
allowedNames := storableArgumentNamesFromRequest(req)
|
||||||
|
if len(allowedNames) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return filterStorableArguments(req.Arguments, allowedNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStorableArgumentsToLogEntry(req *ExecutionRequest) {
|
||||||
|
args := storableArgumentsFromRequest(req)
|
||||||
|
if args == nil || req.logEntry == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.mutateLogEntry(func(entry *InternalLogEntry) {
|
||||||
|
entry.Arguments = args
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartArgumentMissingFromStored(arg *config.ActionArgument, entity *entities.Entity, storedArgs map[string]string) bool {
|
||||||
|
if !argumentTypeStorableInLog(arg.Type) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !restartArgumentRequired(arg, entity) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if storedArgs == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := storedArgs[arg.Name]
|
||||||
|
return !ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func RestartArgumentsIncomplete(action *config.Action, entity *entities.Entity, storedArgs map[string]string) bool {
|
||||||
|
if action == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range action.Arguments {
|
||||||
|
if restartArgumentMissingFromStored(&action.Arguments[i], entity, storedArgs) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartArgumentRequired(arg *config.ActionArgument, entity *entities.Entity) bool {
|
||||||
|
if argumentSkipsValidation(arg) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultValue := arg.Default
|
||||||
|
if defaultValue != "" {
|
||||||
|
defaultValue = tpl.ParseTemplateOfActionBeforeExec(defaultValue, entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue == ""
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
auth "github.com/OliveTin/OliveTin/internal/auth"
|
||||||
|
config "github.com/OliveTin/OliveTin/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestArgumentTypeStorableInLog(t *testing.T) {
|
||||||
|
assert.True(t, argumentTypeStorableInLog("ascii"))
|
||||||
|
assert.True(t, argumentTypeStorableInLog("shell_safe_identifier"))
|
||||||
|
assert.False(t, argumentTypeStorableInLog("password"))
|
||||||
|
assert.False(t, argumentTypeStorableInLog("very_dangerous_raw_string"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorableArgumentsFromRequestExcludesSensitiveAndSystemArgs(t *testing.T) {
|
||||||
|
req := newExecRequest()
|
||||||
|
req.Binding.Action.Arguments = []config.ActionArgument{
|
||||||
|
{Name: "host", Type: "ascii_identifier"},
|
||||||
|
{Name: "secret", Type: "password"},
|
||||||
|
{Name: "payload", Type: "very_dangerous_raw_string"},
|
||||||
|
}
|
||||||
|
req.Arguments = map[string]string{
|
||||||
|
"host": "example.com",
|
||||||
|
"secret": "hunter2",
|
||||||
|
"payload": "rm -rf /",
|
||||||
|
"ot_executionTrackingId": "track-123",
|
||||||
|
"ot_username": "alice",
|
||||||
|
"extra_undefined": "drop-me",
|
||||||
|
}
|
||||||
|
|
||||||
|
args := storableArgumentsFromRequest(req)
|
||||||
|
|
||||||
|
require.Len(t, args, 1)
|
||||||
|
assert.Equal(t, "example.com", args["host"])
|
||||||
|
assert.NotContains(t, args, "secret")
|
||||||
|
assert.NotContains(t, args, "payload")
|
||||||
|
assert.NotContains(t, args, "ot_executionTrackingId")
|
||||||
|
assert.NotContains(t, args, "ot_username")
|
||||||
|
assert.NotContains(t, args, "extra_undefined")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorableArgumentsFromRequestReturnsNilWhenEmpty(t *testing.T) {
|
||||||
|
req := newExecRequest()
|
||||||
|
req.Binding.Action.Arguments = []config.ActionArgument{
|
||||||
|
{Name: "secret", Type: "password"},
|
||||||
|
}
|
||||||
|
req.Arguments = map[string]string{
|
||||||
|
"secret": "hunter2",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Nil(t, storableArgumentsFromRequest(req))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorableArgumentsFromRequestStoresMangledCheckboxValue(t *testing.T) {
|
||||||
|
req := newExecRequest()
|
||||||
|
req.Binding.Action.Arguments = []config.ActionArgument{
|
||||||
|
{
|
||||||
|
Name: "mode",
|
||||||
|
Type: "checkbox",
|
||||||
|
Choices: []config.ActionArgumentChoice{
|
||||||
|
{Title: "Enabled", Value: "1"},
|
||||||
|
{Title: "Disabled", Value: "0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req.Arguments = map[string]string{
|
||||||
|
"mode": "Enabled",
|
||||||
|
}
|
||||||
|
|
||||||
|
mangleInvalidArgumentValues(req)
|
||||||
|
args := storableArgumentsFromRequest(req)
|
||||||
|
|
||||||
|
require.Len(t, args, 1)
|
||||||
|
assert.Equal(t, "1", args["mode"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyStorableArgumentsToLogEntry(t *testing.T) {
|
||||||
|
req := newExecRequest()
|
||||||
|
req.logEntry = &InternalLogEntry{}
|
||||||
|
req.Binding.Action.Arguments = []config.ActionArgument{
|
||||||
|
{Name: "target", Type: "ascii_identifier"},
|
||||||
|
}
|
||||||
|
req.Arguments = map[string]string{
|
||||||
|
"target": "server-a",
|
||||||
|
}
|
||||||
|
|
||||||
|
copyStorableArgumentsToLogEntry(req)
|
||||||
|
|
||||||
|
require.NotNil(t, req.logEntry.Arguments)
|
||||||
|
assert.Equal(t, "server-a", req.logEntry.Arguments["target"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecRequestStoresArgumentsOnLogEntry(t *testing.T) {
|
||||||
|
e, cfg := testingExecutor()
|
||||||
|
|
||||||
|
e.RebuildActionMap()
|
||||||
|
binding := e.FindBindingWithNoEntity(cfg.Actions[0])
|
||||||
|
require.NotNil(t, binding)
|
||||||
|
|
||||||
|
req := ExecutionRequest{
|
||||||
|
Binding: binding,
|
||||||
|
Cfg: cfg,
|
||||||
|
AuthenticatedUser: auth.UserGuest(cfg),
|
||||||
|
Arguments: map[string]string{
|
||||||
|
"person": "yourself",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
wg, trackingID := e.ExecRequest(&req)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
logEntry, ok := e.GetLog(trackingID)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.NotNil(t, logEntry.Arguments)
|
||||||
|
assert.Equal(t, "yourself", logEntry.Arguments["person"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartArgumentsIncompleteDetectsNonStorableArguments(t *testing.T) {
|
||||||
|
action := &config.Action{
|
||||||
|
Arguments: []config.ActionArgument{
|
||||||
|
{Name: "host", Type: "ascii_identifier"},
|
||||||
|
{Name: "pass", Type: "password"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, RestartArgumentsIncomplete(action, nil, map[string]string{
|
||||||
|
"host": "db-1",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartArgumentsIncompleteDetectsMissingRequiredStoredArguments(t *testing.T) {
|
||||||
|
action := &config.Action{
|
||||||
|
Arguments: []config.ActionArgument{
|
||||||
|
{Name: "host", Type: "ascii_identifier"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, RestartArgumentsIncomplete(action, nil, map[string]string{}))
|
||||||
|
assert.False(t, RestartArgumentsIncomplete(action, nil, map[string]string{
|
||||||
|
"host": "db-1",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartArgumentsIncompleteAllowsOptionalArgumentsWithDefaults(t *testing.T) {
|
||||||
|
action := &config.Action{
|
||||||
|
Arguments: []config.ActionArgument{
|
||||||
|
{Name: "host", Type: "ascii_identifier", Default: "example.com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.False(t, RestartArgumentsIncomplete(action, nil, map[string]string{}))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<assemblyIdentity
|
||||||
|
version="1.0.0.0"
|
||||||
|
processorArchitecture="*"
|
||||||
|
name="OliveTin.OliveTin"
|
||||||
|
type="win32" />
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity
|
||||||
|
type="win32"
|
||||||
|
name="Microsoft.Windows.Common-Controls"
|
||||||
|
version="6.0.0.0"
|
||||||
|
processorArchitecture="*"
|
||||||
|
publicKeyToken="6595b64144ccf1df"
|
||||||
|
language="*" />
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
|
||||||
|
<Product
|
||||||
|
Id="*"
|
||||||
|
Name="OliveTin $(var.Version)"
|
||||||
|
Language="1033"
|
||||||
|
Version="$(var.Version)"
|
||||||
|
Manufacturer="James Read"
|
||||||
|
UpgradeCode="8B5E3F2A-1C4D-4E6F-9A0B-2D3C4E5F6071">
|
||||||
|
<Package
|
||||||
|
InstallerVersion="500"
|
||||||
|
Compressed="yes"
|
||||||
|
InstallScope="perMachine"
|
||||||
|
Description="OliveTin web interface for running shell commands"
|
||||||
|
Comments="https://github.com/OliveTin/OliveTin" />
|
||||||
|
|
||||||
|
<MajorUpgrade
|
||||||
|
AllowSameVersionUpgrades="yes"
|
||||||
|
DowngradeErrorMessage="A newer version of OliveTin is already installed." />
|
||||||
|
<MediaTemplate />
|
||||||
|
|
||||||
|
<Feature Id="ProductFeature" Title="OliveTin" Level="1">
|
||||||
|
<ComponentGroupRef Id="CG.AppFiles" />
|
||||||
|
<ComponentRef Id="ConfigFile" />
|
||||||
|
</Feature>
|
||||||
|
|
||||||
|
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||||
|
<Directory Id="ProgramFiles64Folder">
|
||||||
|
<Directory Id="INSTALLDIR" Name="OliveTin" />
|
||||||
|
</Directory>
|
||||||
|
<Directory Id="CommonAppDataFolder">
|
||||||
|
<Directory Id="ConfigDir" Name="OliveTin" />
|
||||||
|
</Directory>
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<DirectoryRef Id="ConfigDir">
|
||||||
|
<Component Id="ConfigFile" Guid="A1B2C3D4-E5F6-7890-ABCD-EF1234567890" Win64="yes" Permanent="yes" NeverOverwrite="yes">
|
||||||
|
<File
|
||||||
|
Id="ConfigYaml"
|
||||||
|
Source="$(var.ConfigSource)"
|
||||||
|
Name="config.yaml"
|
||||||
|
KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</DirectoryRef>
|
||||||
|
</Product>
|
||||||
|
</Wix>
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
DIST_DIR="${DIST_DIR:-${REPO_ROOT}/dist}"
|
||||||
|
ARCH="${ARCH:-amd64}"
|
||||||
|
ZIP_NAME="OliveTin-windows-${ARCH}.zip"
|
||||||
|
ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
|
||||||
|
MSI_NAME="OliveTin-windows-${ARCH}.msi"
|
||||||
|
MSI_PATH="${DIST_DIR}/${MSI_NAME}"
|
||||||
|
|
||||||
|
if [[ ! -f "${ZIP_PATH}" ]]; then
|
||||||
|
echo "Windows archive not found: ${ZIP_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v wixl >/dev/null || ! command -v wixl-heat >/dev/null; then
|
||||||
|
echo "wixl and wixl-heat are required (install the wixl/msitools package)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
normalize_msi_version() {
|
||||||
|
local raw="${1#v}"
|
||||||
|
raw="${raw%%-*}"
|
||||||
|
if [[ ! "${raw}" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]]; then
|
||||||
|
echo "Invalid MSI version (expected major[.minor[.patch[.build]]]): ${1}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local -a parts=()
|
||||||
|
IFS='.' read -r -a parts <<<"${raw}"
|
||||||
|
local major="${parts[0]:-0}"
|
||||||
|
local minor="${parts[1]:-0}"
|
||||||
|
local patch="${parts[2]:-0}"
|
||||||
|
printf '%s.%s.%s' "${major}" "${minor}" "${patch}"
|
||||||
|
}
|
||||||
|
|
||||||
|
VERSION="${VERSION:-}"
|
||||||
|
if [[ -z "${VERSION}" ]]; then
|
||||||
|
VERSION="$(git -C "${REPO_ROOT}" describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${VERSION}" ]]; then
|
||||||
|
echo "Could not determine release version; set VERSION explicitly" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
MSI_VERSION="$(normalize_msi_version "${VERSION}")" || exit 1
|
||||||
|
|
||||||
|
STAGING="$(mktemp -d)"
|
||||||
|
APP_STAGING="$(mktemp -d)"
|
||||||
|
HEAT_WXS="$(mktemp)"
|
||||||
|
trap 'rm -rf "${STAGING}" "${APP_STAGING}" "${HEAT_WXS}"' EXIT
|
||||||
|
|
||||||
|
unzip -q "${ZIP_PATH}" -d "${STAGING}"
|
||||||
|
SOURCE_ROOT="${STAGING}/OliveTin-windows-${ARCH}"
|
||||||
|
|
||||||
|
if [[ ! -f "${SOURCE_ROOT}/OliveTin.exe" ]]; then
|
||||||
|
echo "OliveTin.exe not found in ${SOURCE_ROOT}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${SOURCE_ROOT}/config.yaml" ]]; then
|
||||||
|
echo "config.yaml not found in ${SOURCE_ROOT}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${APP_STAGING}/webui"
|
||||||
|
cp "${SOURCE_ROOT}/OliveTin.exe" "${APP_STAGING}/"
|
||||||
|
cp -a "${SOURCE_ROOT}/webui/." "${APP_STAGING}/webui/"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "${APP_STAGING}"
|
||||||
|
find . -type f | sed 's|^\./||'
|
||||||
|
) | wixl-heat \
|
||||||
|
-p "" \
|
||||||
|
--component-group CG.AppFiles \
|
||||||
|
--var var.SourceDir \
|
||||||
|
--directory-ref INSTALLDIR \
|
||||||
|
--win64 \
|
||||||
|
> "${HEAT_WXS}"
|
||||||
|
|
||||||
|
wixl \
|
||||||
|
-v \
|
||||||
|
-a x64 \
|
||||||
|
-D "Version=${MSI_VERSION}" \
|
||||||
|
-D "Win64=yes" \
|
||||||
|
-D "SourceDir=${APP_STAGING}" \
|
||||||
|
-D "ConfigSource=${SOURCE_ROOT}/config.yaml" \
|
||||||
|
-o "${MSI_PATH}" \
|
||||||
|
"${SCRIPT_DIR}/OliveTin.wxs" \
|
||||||
|
"${HEAT_WXS}"
|
||||||
|
|
||||||
|
echo "Built ${MSI_PATH}"
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
SERVICE_DIR="${REPO_ROOT}/service"
|
||||||
|
VERSIONINFO_JSON="${SCRIPT_DIR}/versioninfo.json"
|
||||||
|
ICON_PATH="${SCRIPT_DIR}/OliveTin.ico"
|
||||||
|
MANIFEST_PATH="${SCRIPT_DIR}/OliveTin.exe.manifest"
|
||||||
|
GOVERSIONINFO_VERSION="${GOVERSIONINFO_VERSION:-v1.5.0}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [version]
|
||||||
|
|
||||||
|
Generate Windows resource (.syso) files for embedding in OliveTin.exe.
|
||||||
|
|
||||||
|
version Release version (e.g. 3.0.0 or v3.0.0). Defaults to VERSION env,
|
||||||
|
then the latest git tag, then 0.0.0.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_windows_version() {
|
||||||
|
local raw="${1#v}"
|
||||||
|
raw="${raw%%-*}"
|
||||||
|
if [[ ! "${raw}" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]]; then
|
||||||
|
echo "0.0.0.0"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local -a parts=()
|
||||||
|
IFS='.' read -r -a parts <<<"${raw}"
|
||||||
|
local major="${parts[0]:-0}"
|
||||||
|
local minor="${parts[1]:-0}"
|
||||||
|
local patch="${parts[2]:-0}"
|
||||||
|
local build="${parts[3]:-0}"
|
||||||
|
printf '%s.%s.%s.%s' "${major}" "${minor}" "${patch}" "${build}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_version() {
|
||||||
|
if [[ $# -gt 0 && -n "${1:-}" ]]; then
|
||||||
|
echo "${1}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ -n "${VERSION:-}" ]]; then
|
||||||
|
echo "${VERSION}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if git -C "${REPO_ROOT}" describe --tags --abbrev=0 >/dev/null 2>&1; then
|
||||||
|
git -C "${REPO_ROOT}" describe --tags --abbrev=0
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "0.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${VERSIONINFO_JSON}" ]]; then
|
||||||
|
echo "versioninfo.json not found: ${VERSIONINFO_JSON}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${ICON_PATH}" ]]; then
|
||||||
|
echo "icon not found: ${ICON_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
WINDOWS_VERSION="$(normalize_windows_version "$(resolve_version "${1:-}")")"
|
||||||
|
echo "Generating Windows resources for version ${WINDOWS_VERSION}"
|
||||||
|
|
||||||
|
TOOL_BIN="$(mktemp -d)/bin"
|
||||||
|
export GOBIN="${TOOL_BIN}"
|
||||||
|
go install "github.com/josephspurrier/goversioninfo/cmd/goversioninfo@${GOVERSIONINFO_VERSION}"
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${WORK_DIR}" "${TOOL_BIN%/*}"' EXIT
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "${WORK_DIR}"
|
||||||
|
"${TOOL_BIN}/goversioninfo" \
|
||||||
|
-64 \
|
||||||
|
-platform-specific \
|
||||||
|
-icon="${ICON_PATH}" \
|
||||||
|
-manifest="${MANIFEST_PATH}" \
|
||||||
|
-file-version="${WINDOWS_VERSION}" \
|
||||||
|
-product-version="${WINDOWS_VERSION}" \
|
||||||
|
"${VERSIONINFO_JSON}"
|
||||||
|
)
|
||||||
|
|
||||||
|
rm -f "${SERVICE_DIR}"/resource_windows_*.syso
|
||||||
|
mv "${WORK_DIR}"/resource_windows_*.syso "${SERVICE_DIR}/"
|
||||||
|
echo "Wrote Windows resource files to ${SERVICE_DIR}"
|
||||||
|
|
@ -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 "$@"
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
DIST_DIR="${DIST_DIR:-${REPO_ROOT}/dist}"
|
||||||
|
ARCH="${ARCH:-amd64}"
|
||||||
|
MSI_NAME="OliveTin-windows-${ARCH}.msi"
|
||||||
|
MSI_PATH="${DIST_DIR}/${MSI_NAME}"
|
||||||
|
TAG="${1:-}"
|
||||||
|
|
||||||
|
if [[ -z "${TAG}" ]]; then
|
||||||
|
echo "Usage: $(basename "$0") <release-tag>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${MSI_PATH}" ]]; then
|
||||||
|
echo "MSI not found: ${MSI_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v gh >/dev/null; then
|
||||||
|
echo "gh is required to upload the MSI to GitHub releases" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
checksums_path="${DIST_DIR}/checksums.txt"
|
||||||
|
new_checksum="$(cd "${DIST_DIR}" && sha256sum "${MSI_NAME}")"
|
||||||
|
if [[ -f "${checksums_path}" ]] && grep -qF " ${MSI_NAME}" "${checksums_path}"; then
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
grep -vF " ${MSI_NAME}" "${checksums_path}" > "${tmp}" || true
|
||||||
|
printf '%s\n' "${new_checksum}" >> "${tmp}"
|
||||||
|
mv "${tmp}" "${checksums_path}"
|
||||||
|
elif [[ -f "${checksums_path}" ]]; then
|
||||||
|
printf '%s\n' "${new_checksum}" >> "${checksums_path}"
|
||||||
|
else
|
||||||
|
printf '%s\n' "${new_checksum}" > "${checksums_path}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
gh release upload "${TAG}" "${MSI_PATH}" --clobber
|
||||||
|
if [[ -f "${checksums_path}" ]]; then
|
||||||
|
gh release upload "${TAG}" "${checksums_path}" --clobber
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Uploaded ${MSI_NAME} to release ${TAG}"
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue