mirror of
https://github.com/OliveTin/OliveTin
synced 2025-12-12 00:55:34 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a6d9e4f68 | ||
|
|
83f45d71bf | ||
|
|
79a71099f9 | ||
|
|
e6a02ac614 | ||
|
|
e0167c9e42 | ||
|
|
7abffedb14 | ||
|
|
d32db6483e | ||
|
|
44b518a5b2 | ||
|
|
a4e50bfb54 | ||
|
|
a8f5e25454 | ||
|
|
c3d5da1981 | ||
|
|
7a1c4d3efa | ||
|
|
c89979ddb2 | ||
|
|
430aab638b | ||
|
|
961ddac193 | ||
|
|
03ac3b5fa7 | ||
|
|
d21f06e555 | ||
|
|
f25b456c3d | ||
|
|
e1db1e7be5 | ||
|
|
19c3b67cdd | ||
|
|
b9d859ada2 | ||
|
|
61fc771ac3 | ||
|
|
e0fd10a6ec | ||
|
|
2a5732cc27 | ||
|
|
57390be16f | ||
|
|
8a6d61c260 | ||
|
|
f337e05eaf |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,3 +16,4 @@ integration-tests/screenshots/
|
||||
webui/
|
||||
server.log
|
||||
OliveTin
|
||||
integration-tests/configs/authRequireGuestsToLogin/sessions.yaml
|
||||
|
||||
@@ -26,7 +26,7 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
|
||||
### Test Notes and Gotchas
|
||||
- The top-level Makefile does not expose `unittests`; use `cd service && make unittests`.
|
||||
- Connect RPC API must be mounted correctly; in tests, create the handler via `GetNewHandler(ex)` and serve under `/api/`.
|
||||
- Frontend “ready” state: the app sets `document.body` attribute `initial-marshal-complete="true"` when loaded. Integration helpers wait for this before selecting elements.
|
||||
- Frontend “ready” state: the app sets `document.body` attribute `loaded-dashboard="<name>"` when loading a dashboard. Integration helpers that test dashboard functionality wait for this before selecting elements. Certain conditions enforcing login will mean that this attribute is not set until a user is logged in.
|
||||
- Modern UI uses Vue components:
|
||||
- Action buttons are rendered as `.action-button button`.
|
||||
- Logs and Diagnostics are Vue router links available via `/logs` and `/diagnostics`.
|
||||
@@ -64,6 +64,6 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
|
||||
### Troubleshooting
|
||||
- API tests failing with content-type errors: ensure Connect handler is served under `/api/` and the client targets that base URL.
|
||||
- Executor panics: check for nil `Binding/Action` and add guards in step functions.
|
||||
- Integration timeouts: wait for `initial-marshal-complete` and use selectors matching the Vue UI.
|
||||
- Integration timeouts: wait for `loaded-dashboard` and use selectors matching the Vue UI.
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import { Mutex } from './Mutex.js'
|
||||
* occour in sequential order.
|
||||
*/
|
||||
export class OutputTerminal {
|
||||
constructor () {
|
||||
constructor (executionTrackingId) {
|
||||
this.executionTrackingId = executionTrackingId
|
||||
this.writeMutex = new Mutex()
|
||||
this.terminal = new Terminal({
|
||||
convertEol: true
|
||||
|
||||
@@ -48,6 +48,11 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
|
||||
* @generated from field: int32 order = 7;
|
||||
*/
|
||||
order: number;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 timeout = 8;
|
||||
*/
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -581,6 +586,63 @@ export declare type GetLogsResponse = Message<"olivetin.api.v1.GetLogsResponse">
|
||||
*/
|
||||
export declare const GetLogsResponseSchema: GenMessage<GetLogsResponse>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.GetActionLogsRequest
|
||||
*/
|
||||
export declare type GetActionLogsRequest = Message<"olivetin.api.v1.GetActionLogsRequest"> & {
|
||||
/**
|
||||
* @generated from field: string action_id = 1;
|
||||
*/
|
||||
actionId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 start_offset = 2;
|
||||
*/
|
||||
startOffset: bigint;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message olivetin.api.v1.GetActionLogsRequest.
|
||||
* Use `create(GetActionLogsRequestSchema)` to create a new message.
|
||||
*/
|
||||
export declare const GetActionLogsRequestSchema: GenMessage<GetActionLogsRequest>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.GetActionLogsResponse
|
||||
*/
|
||||
export declare type GetActionLogsResponse = Message<"olivetin.api.v1.GetActionLogsResponse"> & {
|
||||
/**
|
||||
* @generated from field: repeated olivetin.api.v1.LogEntry logs = 1;
|
||||
*/
|
||||
logs: LogEntry[];
|
||||
|
||||
/**
|
||||
* @generated from field: int64 count_remaining = 2;
|
||||
*/
|
||||
countRemaining: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 page_size = 3;
|
||||
*/
|
||||
pageSize: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 total_count = 4;
|
||||
*/
|
||||
totalCount: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 start_offset = 5;
|
||||
*/
|
||||
startOffset: bigint;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message olivetin.api.v1.GetActionLogsResponse.
|
||||
* Use `create(GetActionLogsResponseSchema)` to create a new message.
|
||||
*/
|
||||
export declare const GetActionLogsResponseSchema: GenMessage<GetActionLogsResponse>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.ValidateArgumentTypeRequest
|
||||
*/
|
||||
@@ -1316,6 +1378,11 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
|
||||
* @generated from field: bool show_log_list = 22;
|
||||
*/
|
||||
showLogList: boolean;
|
||||
|
||||
/**
|
||||
* @generated from field: bool login_required = 23;
|
||||
*/
|
||||
loginRequired: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1570,6 +1637,14 @@ export declare const OliveTinApiService: GenService<{
|
||||
input: typeof GetLogsRequestSchema;
|
||||
output: typeof GetLogsResponseSchema;
|
||||
},
|
||||
/**
|
||||
* @generated from rpc olivetin.api.v1.OliveTinApiService.GetActionLogs
|
||||
*/
|
||||
getActionLogs: {
|
||||
methodKind: "unary";
|
||||
input: typeof GetActionLogsRequestSchema;
|
||||
output: typeof GetActionLogsResponseSchema;
|
||||
},
|
||||
/**
|
||||
* @generated from rpc olivetin.api.v1.OliveTinApiService.ValidateArgumentType
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -59,6 +59,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Sidebar from 'picocrank/vue/components/Sidebar.vue';
|
||||
import Header from 'picocrank/vue/components/Header.vue';
|
||||
import { HugeiconsIcon } from '@hugeicons/vue'
|
||||
@@ -67,6 +68,8 @@ import { UserCircle02Icon } from '@hugeicons/core-free-icons'
|
||||
import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
|
||||
import logoUrl from '../../OliveTinLogo.png';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const sidebar = ref(null);
|
||||
const username = ref('guest');
|
||||
const isLoggedIn = ref(false);
|
||||
@@ -107,7 +110,14 @@ async function requestInit() {
|
||||
try {
|
||||
const initResponse = await window.client.init({})
|
||||
|
||||
// Store init response first so the login view can read options (e.g., authLocalLogin)
|
||||
window.initResponse = initResponse
|
||||
|
||||
// Check if login is required and redirect if so (after storing initResponse)
|
||||
if (initResponse.loginRequired) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
window.initError = false
|
||||
window.initErrorMessage = ''
|
||||
window.initCompleted = true
|
||||
|
||||
@@ -70,6 +70,19 @@ const routes = [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/action/:actionId',
|
||||
name: 'ActionDetails',
|
||||
component: () => import('./views/ActionDetailsView.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
title: 'Action Details',
|
||||
breadcrumb: [
|
||||
{ name: "Actions", href: "/" },
|
||||
{ name: "Action Details" },
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/diagnostics',
|
||||
name: 'Diagnostics',
|
||||
|
||||
389
frontend/resources/vue/views/ActionDetailsView.vue
Normal file
389
frontend/resources/vue/views/ActionDetailsView.vue
Normal file
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<Section :title="'Action Details: ' + actionTitle" :padding="false">
|
||||
<template #toolbar>
|
||||
<button v-if="action" @click="startAction" title="Start this action" class="button neutral">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8 6v12l8-6z" />
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class = "flex-row padding" v-if="action">
|
||||
<div class = "fg1">
|
||||
<dl>
|
||||
<dt>Title</dt>
|
||||
<dd>{{ action.title }}</dd>
|
||||
<dt>Timeout</dt>
|
||||
<dd>{{ action.timeout }} seconds</dd>
|
||||
</dl>
|
||||
<p v-if="action" class = "fg1">
|
||||
Execution history for this action. You can filter by execution tracking ID.
|
||||
</p>
|
||||
</div>
|
||||
<div style = "align-self: start; text-align: right;">
|
||||
<span class="icon" v-html="action.icon"></span>
|
||||
|
||||
<div class="filter-container">
|
||||
<label class="input-with-icons">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor"
|
||||
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
|
||||
</svg>
|
||||
<input placeholder="Filter current page" v-model="searchText" />
|
||||
<button title="Clear search filter" :disabled="!searchText" @click="clearSearch">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor"
|
||||
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="filteredLogs.length > 0">
|
||||
<table class="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Execution ID</th>
|
||||
<th>Metadata</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="log in filteredLogs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle">
|
||||
<td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
|
||||
<td>
|
||||
<router-link :to="`/logs/${log.executionTrackingId}`">
|
||||
{{ log.executionTrackingId }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td class="tags">
|
||||
<span class="annotation">
|
||||
<span class="annotation-key">User:</span>
|
||||
<span class="annotation-val">{{ log.user }}</span>
|
||||
</span>
|
||||
<span v-if="log.tags && log.tags.length > 0" class="tag-list">
|
||||
<span v-for="tag in log.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="exit-code">
|
||||
<span :class="getStatusClass(log) + ' annotation'">
|
||||
{{ getStatusText(log) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pagination :pageSize="pageSize" :total="totalCount" :currentPage="currentPage" @page-change="handlePageChange" class="padding"
|
||||
@page-size-change="handlePageSizeChange" itemTitle="execution logs" />
|
||||
</div>
|
||||
|
||||
<div v-show="logs.length === 0 && !loading" class="empty-state">
|
||||
<p>This action has no execution history.</p>
|
||||
<router-link to="/">Return to index</router-link>
|
||||
</div>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Pagination from '../components/Pagination.vue'
|
||||
import Section from 'picocrank/vue/components/Section.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const logs = ref([])
|
||||
const action = ref(null)
|
||||
const actionTitle = ref('Action Details')
|
||||
const searchText = ref('')
|
||||
const pageSize = ref(10)
|
||||
const currentPage = ref(1)
|
||||
const loading = ref(false)
|
||||
const totalCount = ref(0)
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
if (!searchText.value) {
|
||||
return logs.value
|
||||
}
|
||||
const searchLower = searchText.value.toLowerCase()
|
||||
return logs.value.filter(log =>
|
||||
log.executionTrackingId.toLowerCase().includes(searchLower) ||
|
||||
log.actionTitle.toLowerCase().includes(searchLower)
|
||||
)
|
||||
})
|
||||
|
||||
async function fetchActionLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const actionId = route.params.actionId
|
||||
const startOffset = (currentPage.value - 1) * pageSize.value
|
||||
|
||||
const args = {
|
||||
"actionId": actionId,
|
||||
"startOffset": BigInt(startOffset),
|
||||
"pageSize": BigInt(Number(pageSize.value)),
|
||||
}
|
||||
|
||||
const response = await window.client.getActionLogs(args)
|
||||
|
||||
logs.value = response.logs
|
||||
const serverPageSize = Number(response.pageSize)
|
||||
if (Number.isFinite(serverPageSize) && serverPageSize > 0) {
|
||||
pageSize.value = serverPageSize
|
||||
}
|
||||
totalCount.value = Number(response.totalCount) || 0
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch action logs:', err)
|
||||
window.showBigError('fetch-action-logs', 'getting action logs', err, false)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAction() {
|
||||
try {
|
||||
const actionId = route.params.actionId
|
||||
const args = {
|
||||
"bindingId": actionId
|
||||
}
|
||||
const response = await window.client.getActionBinding(args)
|
||||
action.value = response.action
|
||||
actionTitle.value = action.value?.title || 'Unknown Action'
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch action:', err)
|
||||
window.showBigError('fetch-action', 'getting action details', err, false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
action.value = null
|
||||
actionTitle.value = 'Action Details'
|
||||
logs.value = []
|
||||
totalCount.value = 0
|
||||
currentPage.value = 1
|
||||
searchText.value = ''
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchText.value = ''
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
if (!timestamp) return 'Unknown'
|
||||
try {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString()
|
||||
} catch (err) {
|
||||
return timestamp
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusClass(log) {
|
||||
if (log.timedOut) return 'status-timeout'
|
||||
if (log.blocked) return 'status-blocked'
|
||||
if (log.exitCode !== 0) return 'status-error'
|
||||
return 'status-success'
|
||||
}
|
||||
|
||||
function getStatusText(log) {
|
||||
if (log.timedOut) return 'Timed out'
|
||||
if (log.blocked) return 'Blocked'
|
||||
if (log.exitCode !== 0) return `Exit code ${log.exitCode}`
|
||||
return 'Completed'
|
||||
}
|
||||
|
||||
function handlePageChange(page) {
|
||||
currentPage.value = page
|
||||
fetchActionLogs()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(newPageSize) {
|
||||
pageSize.value = newPageSize
|
||||
currentPage.value = 1
|
||||
fetchActionLogs()
|
||||
}
|
||||
|
||||
async function startAction() {
|
||||
if (!action.value || !action.value.bindingId) {
|
||||
console.error('Cannot start action: no binding ID')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const args = {
|
||||
"bindingId": action.value.bindingId,
|
||||
"arguments": []
|
||||
}
|
||||
|
||||
const response = await window.client.startAction(args)
|
||||
router.push(`/logs/${response.executionTrackingId}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to start action:', err)
|
||||
window.showBigError('start-action', 'starting action', err, false)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAction()
|
||||
fetchActionLogs()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.params.actionId,
|
||||
() => {
|
||||
resetState()
|
||||
fetchAction()
|
||||
fetchActionLogs()
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.logs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.logs-table th {
|
||||
background-color: var(--section-background);
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logs-table td {
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.log-row:hover {
|
||||
background-color: var(--hover-background);
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.input-with-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--section-background);
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.input-with-icons input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.input-with-icons button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-with-icons button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.annotation {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.annotation-key {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.annotation-val {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background-color: var(--accent-color);
|
||||
color: var(--accent-text);
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.exit-code .status-success {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.exit-code .status-error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.exit-code .status-timeout {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.exit-code .status-blocked {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.padding {
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<template>
|
||||
<Section :title="'Execution Results: ' + title" id = "execution-results-popup">
|
||||
<Section :title="'Execution Results: ' + title" id = "execution-results-popup">
|
||||
<template #toolbar>
|
||||
<button @click="toggleSize" title="Toggle dialog size">
|
||||
<router-link v-if="actionId" :to="`/action/${actionId}`" title="View all executions for this action" class="button neutral">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.31-8.86c-1.77-.45-2.34-.94-2.34-1.67 0-.84.79-1.43 2.1-1.43 1.38 0 1.9.66 1.94 1.64h1.71c-.05-1.34-.87-2.57-2.49-2.97V5H10.9v1.69c-1.51.32-2.72 1.3-2.72 2.81 0 1.79 1.49 2.69 3.66 3.21 1.95.46 2.34 1.22 2.34 1.8 0 .53-.39 1.39-2.1 1.39-1.6 0-2.05-.56-2.13-1.45H8.04c.08 1.5 1.18 2.37 2.82 2.69V19h2.34v-1.63c1.65-.35 2.48-1.24 2.48-2.77-.01-1.88-1.51-2.87-3.7-3.23z"/>
|
||||
</svg>
|
||||
Action Details
|
||||
</router-link>
|
||||
<button @click="toggleSize" title="Toggle dialog size" class = "neutral">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor"
|
||||
d="M3 3h6v2H6.462l4.843 4.843l-1.415 1.414L5 6.367V9H3zm0 18h6v-2H6.376l4.929-4.928l-1.415-1.414L5 17.548V15H3zm12 0h6v-6h-2v2.524l-4.867-4.866l-1.414 1.414L17.647 19H15zm6-18h-6v2h2.562l-4.843 4.843l1.414 1.414L19 6.39V9h2z" />
|
||||
@@ -22,6 +28,13 @@
|
||||
<span class="icon" role="img" v-html="icon" style = "align-self: start"></span>
|
||||
</div>
|
||||
|
||||
<div v-if="notFound" class="error-message padded-content">
|
||||
<h3>Execution Not Found</h3>
|
||||
<p>{{ errorMessage }}</p>
|
||||
<p>The execution with ID <code>{{ executionTrackingId }}</code> could not be found.</p>
|
||||
<router-link to="/logs">View all logs</router-link> or <router-link to="/">return to home</router-link>.
|
||||
</div>
|
||||
|
||||
<div ref="xtermOutput"></div>
|
||||
|
||||
<br />
|
||||
@@ -81,12 +94,15 @@ const duration = ref('')
|
||||
const logEntry = ref(null)
|
||||
const canRerun = ref(false)
|
||||
const canKill = ref(false)
|
||||
const actionId = ref('')
|
||||
const notFound = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
let executionTicker = null
|
||||
let terminal = null
|
||||
|
||||
function initializeTerminal() {
|
||||
terminal = new OutputTerminal(executionTrackingId.value, this)
|
||||
terminal = new OutputTerminal(executionTrackingId.value)
|
||||
terminal.open(xtermOutput.value)
|
||||
terminal.resize(80, 24)
|
||||
|
||||
@@ -94,7 +110,19 @@ function initializeTerminal() {
|
||||
}
|
||||
|
||||
function toggleSize() {
|
||||
terminal.fit()
|
||||
if (!xtermOutput.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (xtermOutput.value.requestFullscreen) {
|
||||
xtermOutput.value.requestFullscreen()
|
||||
} else if (xtermOutput.value.webkitRequestFullscreen) {
|
||||
xtermOutput.value.webkitRequestFullscreen()
|
||||
} else if (xtermOutput.value.mozRequestFullScreen) {
|
||||
xtermOutput.value.mozRequestFullScreen()
|
||||
} else if (xtermOutput.value.msRequestFullscreen) {
|
||||
xtermOutput.value.msRequestFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
@@ -112,6 +140,8 @@ async function reset() {
|
||||
canRerun.value = false
|
||||
canKill.value = false
|
||||
logEntry.value = null
|
||||
notFound.value = false
|
||||
errorMessage.value = ''
|
||||
|
||||
if (terminal) {
|
||||
await terminal.reset()
|
||||
@@ -139,10 +169,23 @@ function show(actionButton) {
|
||||
}
|
||||
|
||||
async function rerunAction() {
|
||||
let startActionArgs = {}
|
||||
const res = await window.client.startAction(startActionArgs)
|
||||
|
||||
if (!logEntry.value || !logEntry.value.actionId) {
|
||||
console.error('Cannot rerun: no action ID available')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const startActionArgs = {
|
||||
"bindingId": logEntry.value.actionId,
|
||||
"arguments": []
|
||||
}
|
||||
|
||||
const res = await window.client.startAction(startActionArgs)
|
||||
router.push(`/logs/${res.executionTrackingId}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to rerun action:', err)
|
||||
window.showBigError('rerun-action', 'rerunning action', err, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function killAction() {
|
||||
@@ -177,6 +220,8 @@ async function fetchExecutionResult(executionTrackingIdParam) {
|
||||
console.log("fetchExecutionResult", executionTrackingIdParam)
|
||||
|
||||
executionTrackingId.value = executionTrackingIdParam
|
||||
notFound.value = false
|
||||
errorMessage.value = ''
|
||||
|
||||
const executionStatusArgs = {
|
||||
executionTrackingId: executionTrackingId.value
|
||||
@@ -187,7 +232,13 @@ async function fetchExecutionResult(executionTrackingIdParam) {
|
||||
|
||||
await renderExecutionResult(logEntryResult)
|
||||
} catch (err) {
|
||||
renderError(err)
|
||||
// Check if it's a "not found" error (404 or similar)
|
||||
if (err.status === 404 || err.code === 'NotFound' || err.message?.includes('not found')) {
|
||||
notFound.value = true
|
||||
errorMessage.value = err.message || 'The execution could not be found in the system.'
|
||||
} else {
|
||||
renderError(err)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -204,7 +255,7 @@ function updateDuration(logEntryParam) {
|
||||
} else {
|
||||
let delta = ''
|
||||
try {
|
||||
delta = (new Date(logEntry.value.datetimeStarted) - new Date(logEntry.value.datetimeStarted)) / 1000
|
||||
delta = (new Date(logEntry.value.datetimeFinished) - new Date(logEntry.value.datetimeStarted)) / 1000
|
||||
delta = new Intl.RelativeTimeFormat().format(delta, 'seconds').replace('in ', '').replace('ago', '')
|
||||
} catch (e) {
|
||||
console.warn('Failed to calculate delta', e)
|
||||
@@ -236,6 +287,7 @@ async function renderExecutionResult(res) {
|
||||
icon.value = res.logEntry.actionIcon
|
||||
title.value = res.logEntry.actionTitle
|
||||
titleTooltip.value = 'Action ID: ' + res.logEntry.actionId + '\nExecution ID: ' + res.logEntry.executionTrackingId
|
||||
actionId.value = res.logEntry.actionId
|
||||
|
||||
updateDuration(res.logEntry)
|
||||
|
||||
@@ -308,3 +360,43 @@ defineExpose({
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action-history-link {
|
||||
color: var(--link-color, #007bff);
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c2c7;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.error-message h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.error-message p {
|
||||
margin: 0.5rem 0;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.error-message code {
|
||||
background-color: #f8d7da;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.125rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.error-message a {
|
||||
color: #721c24;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
<dd v-if="userProvider !== 'system'">{{ userProvider }}</dd>
|
||||
<dt v-if="usergroup">Group</dt>
|
||||
<dd v-if="usergroup">{{ usergroup }}</dd>
|
||||
<dt v-if="acls && acls.length > 0">Matched ACLs</dt>
|
||||
<dd v-if="acls && acls.length > 0">
|
||||
<span class="acl-tag" v-for="(acl, idx) in acls" :key="`acl-${idx}`">{{ acl }}</span>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<div class="user-actions">
|
||||
@@ -38,6 +42,7 @@ const username = ref('guest')
|
||||
const userProvider = ref('system')
|
||||
const usergroup = ref('')
|
||||
const loggingOut = ref(false)
|
||||
const acls = ref([])
|
||||
|
||||
function updateUserInfo() {
|
||||
if (window.initResponse) {
|
||||
@@ -48,6 +53,20 @@ function updateUserInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWhoAmI() {
|
||||
try {
|
||||
const res = await window.client.whoAmI({})
|
||||
acls.value = res.acls || []
|
||||
// Update usergroup from authoritative WhoAmI response
|
||||
if (res.usergroup) {
|
||||
usergroup.value = res.usergroup
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch WhoAmI for ACLs', e)
|
||||
acls.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
loggingOut.value = true
|
||||
|
||||
@@ -70,8 +89,12 @@ async function handleLogout() {
|
||||
console.error('Failed to reinitialize after logout:', initErr)
|
||||
}
|
||||
|
||||
// Redirect to home page
|
||||
router.push('/')
|
||||
// Redirect based on init response: if login is required, go to login page
|
||||
if (window.initResponse && window.initResponse.loginRequired) {
|
||||
router.push('/login')
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err)
|
||||
} finally {
|
||||
@@ -83,14 +106,9 @@ let watchInterval = null
|
||||
|
||||
onMounted(() => {
|
||||
updateUserInfo()
|
||||
fetchWhoAmI()
|
||||
|
||||
// Watch for changes to init response
|
||||
watchInterval = setInterval(() => {
|
||||
if (window.initResponse) {
|
||||
updateUserInfo()
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (watchInterval) {
|
||||
@@ -124,6 +142,16 @@ section {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.acl-tag {
|
||||
display: inline-block;
|
||||
background: var(--section-background);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
margin: 0 0.25rem 0.25rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -14,9 +14,32 @@ authRequireGuestsToLogin: true
|
||||
authLocalUsers:
|
||||
enabled: true
|
||||
users:
|
||||
- username: "testuser"
|
||||
usergroup: "admin"
|
||||
password: "testpass123"
|
||||
- username: "alice"
|
||||
usergroup: "admins"
|
||||
password: "$argon2id$v=19$m=65536,t=4,p=6$ORxyZZGW6E3FWZnbQmHJ9Q$BzIOWeXry/BZ6+JV1T4UASBnebVLB9QJ4f5TmUPXsg4" # notsecret: password
|
||||
|
||||
- username: "bob"
|
||||
usergroup: "users"
|
||||
password: "$argon2id$v=19$m=65536,t=4,p=6$ORxyZZGW6E3FWZnbQmHJ9Q$BzIOWeXry/BZ6+JV1T4UASBnebVLB9QJ4f5TmUPXsg4" # notsecret: password
|
||||
|
||||
accessControlLists:
|
||||
- name: "admin"
|
||||
matchUsergroups: ["admins"]
|
||||
addToEveryAction: true
|
||||
permissions:
|
||||
view: true
|
||||
exec: true
|
||||
logs: true
|
||||
kill: true
|
||||
|
||||
- name: "users"
|
||||
matchUsergroups: ["users"]
|
||||
addToEveryAction: true
|
||||
permissions:
|
||||
view: true
|
||||
exec: false
|
||||
logs: false
|
||||
kill: false
|
||||
|
||||
# Simple actions for testing
|
||||
actions:
|
||||
|
||||
@@ -21,39 +21,19 @@ describe('config: authRequireGuestsToLogin', function () {
|
||||
takeScreenshotOnFailure(this.currentTest, webdriver);
|
||||
});
|
||||
|
||||
it('Server starts successfully with authRequireGuestsToLogin enabled', async function () {
|
||||
await webdriver.get(runner.baseUrl())
|
||||
await webdriver.wait(until.titleContains('OliveTin'), 10000)
|
||||
const title = await webdriver.getTitle()
|
||||
expect(title).to.contain('OliveTin')
|
||||
console.log('✓ Server started successfully with authRequireGuestsToLogin enabled')
|
||||
})
|
||||
it('Guest is redirected to login', async function () {
|
||||
// Don't use getRootAndWait here because we want to test the redirect, and getRootAndWait waits for the dashboard to load
|
||||
|
||||
it('Guest user is blocked from accessing the web UI', async function () {
|
||||
await webdriver.get(runner.baseUrl())
|
||||
|
||||
// Wait for the page to finish loading
|
||||
await webdriver.wait(until.elementLocated(By.css('body')), 10000)
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
// The page should redirect or show an error because guest is not allowed
|
||||
// We can't directly test the API from Selenium, but we can verify the page behavior
|
||||
const currentUrl = await webdriver.getCurrentUrl()
|
||||
console.log('Current URL:', currentUrl)
|
||||
|
||||
// At minimum, we verify the server responds
|
||||
const pageText = await webdriver.findElement(By.tagName('body')).getText()
|
||||
console.log('✓ Page loaded, guest behavior verified')
|
||||
})
|
||||
|
||||
it('Authenticated user can login and access the dashboard', async function () {
|
||||
await webdriver.get(runner.baseUrl())
|
||||
await webdriver.wait(until.urlContains('/login'), 10000)
|
||||
|
||||
// Check if there's a login link or login page
|
||||
// This is a simplified test since we can't easily test the full auth flow from Selenium
|
||||
const bodyText = await webdriver.findElement(By.tagName('body')).getText()
|
||||
console.log('Page content preview:', bodyText.substring(0, 200))
|
||||
console.log('✓ Authenticated user flow verified')
|
||||
// Verify login UI elements are present
|
||||
const loginElements = await webdriver.findElements(By.css('form.local-login-form, .login-oauth2, .login-disabled'))
|
||||
expect(loginElements.length).to.be.greaterThan(0)
|
||||
|
||||
console.log('✓ Login page loaded correctly')
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ message Action {
|
||||
repeated ActionArgument arguments = 5;
|
||||
string popup_on_start = 6;
|
||||
int32 order = 7;
|
||||
int32 timeout = 8;
|
||||
}
|
||||
|
||||
message ActionArgument {
|
||||
@@ -141,6 +142,19 @@ message GetLogsResponse {
|
||||
int64 start_offset = 5;
|
||||
}
|
||||
|
||||
message GetActionLogsRequest {
|
||||
string action_id = 1;
|
||||
int64 start_offset = 2;
|
||||
}
|
||||
|
||||
message GetActionLogsResponse {
|
||||
repeated LogEntry logs = 1;
|
||||
int64 count_remaining = 2;
|
||||
int64 page_size = 3;
|
||||
int64 total_count = 4;
|
||||
int64 start_offset = 5;
|
||||
}
|
||||
|
||||
message ValidateArgumentTypeRequest {
|
||||
string value = 1;
|
||||
string type = 2;
|
||||
@@ -305,6 +319,7 @@ message InitResponse {
|
||||
string banner_css = 20;
|
||||
bool show_diagnostics = 21;
|
||||
bool show_log_list = 22;
|
||||
bool login_required = 23;
|
||||
}
|
||||
|
||||
message AdditionalLink {
|
||||
@@ -366,6 +381,8 @@ service OliveTinApiService {
|
||||
rpc ExecutionStatus(ExecutionStatusRequest) returns (ExecutionStatusResponse) {}
|
||||
|
||||
rpc GetLogs(GetLogsRequest) returns (GetLogsResponse) {}
|
||||
|
||||
rpc GetActionLogs(GetActionLogsRequest) returns (GetActionLogsResponse) {}
|
||||
|
||||
rpc ValidateArgumentType(ValidateArgumentTypeRequest) returns (ValidateArgumentTypeResponse) {}
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ const (
|
||||
// OliveTinApiServiceGetLogsProcedure is the fully-qualified name of the OliveTinApiService's
|
||||
// GetLogs RPC.
|
||||
OliveTinApiServiceGetLogsProcedure = "/olivetin.api.v1.OliveTinApiService/GetLogs"
|
||||
// OliveTinApiServiceGetActionLogsProcedure is the fully-qualified name of the OliveTinApiService's
|
||||
// GetActionLogs RPC.
|
||||
OliveTinApiServiceGetActionLogsProcedure = "/olivetin.api.v1.OliveTinApiService/GetActionLogs"
|
||||
// OliveTinApiServiceValidateArgumentTypeProcedure is the fully-qualified name of the
|
||||
// OliveTinApiService's ValidateArgumentType RPC.
|
||||
OliveTinApiServiceValidateArgumentTypeProcedure = "/olivetin.api.v1.OliveTinApiService/ValidateArgumentType"
|
||||
@@ -117,6 +120,7 @@ type OliveTinApiServiceClient interface {
|
||||
KillAction(context.Context, *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error)
|
||||
ExecutionStatus(context.Context, *connect.Request[v1.ExecutionStatusRequest]) (*connect.Response[v1.ExecutionStatusResponse], error)
|
||||
GetLogs(context.Context, *connect.Request[v1.GetLogsRequest]) (*connect.Response[v1.GetLogsResponse], error)
|
||||
GetActionLogs(context.Context, *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], error)
|
||||
ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error)
|
||||
WhoAmI(context.Context, *connect.Request[v1.WhoAmIRequest]) (*connect.Response[v1.WhoAmIResponse], error)
|
||||
SosReport(context.Context, *connect.Request[v1.SosReportRequest]) (*connect.Response[v1.SosReportResponse], error)
|
||||
@@ -199,6 +203,12 @@ func NewOliveTinApiServiceClient(httpClient connect.HTTPClient, baseURL string,
|
||||
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetLogs")),
|
||||
connect.WithClientOptions(opts...),
|
||||
),
|
||||
getActionLogs: connect.NewClient[v1.GetActionLogsRequest, v1.GetActionLogsResponse](
|
||||
httpClient,
|
||||
baseURL+OliveTinApiServiceGetActionLogsProcedure,
|
||||
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetActionLogs")),
|
||||
connect.WithClientOptions(opts...),
|
||||
),
|
||||
validateArgumentType: connect.NewClient[v1.ValidateArgumentTypeRequest, v1.ValidateArgumentTypeResponse](
|
||||
httpClient,
|
||||
baseURL+OliveTinApiServiceValidateArgumentTypeProcedure,
|
||||
@@ -303,6 +313,7 @@ type oliveTinApiServiceClient struct {
|
||||
killAction *connect.Client[v1.KillActionRequest, v1.KillActionResponse]
|
||||
executionStatus *connect.Client[v1.ExecutionStatusRequest, v1.ExecutionStatusResponse]
|
||||
getLogs *connect.Client[v1.GetLogsRequest, v1.GetLogsResponse]
|
||||
getActionLogs *connect.Client[v1.GetActionLogsRequest, v1.GetActionLogsResponse]
|
||||
validateArgumentType *connect.Client[v1.ValidateArgumentTypeRequest, v1.ValidateArgumentTypeResponse]
|
||||
whoAmI *connect.Client[v1.WhoAmIRequest, v1.WhoAmIResponse]
|
||||
sosReport *connect.Client[v1.SosReportRequest, v1.SosReportResponse]
|
||||
@@ -365,6 +376,11 @@ func (c *oliveTinApiServiceClient) GetLogs(ctx context.Context, req *connect.Req
|
||||
return c.getLogs.CallUnary(ctx, req)
|
||||
}
|
||||
|
||||
// GetActionLogs calls olivetin.api.v1.OliveTinApiService.GetActionLogs.
|
||||
func (c *oliveTinApiServiceClient) GetActionLogs(ctx context.Context, req *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], error) {
|
||||
return c.getActionLogs.CallUnary(ctx, req)
|
||||
}
|
||||
|
||||
// ValidateArgumentType calls olivetin.api.v1.OliveTinApiService.ValidateArgumentType.
|
||||
func (c *oliveTinApiServiceClient) ValidateArgumentType(ctx context.Context, req *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error) {
|
||||
return c.validateArgumentType.CallUnary(ctx, req)
|
||||
@@ -451,6 +467,7 @@ type OliveTinApiServiceHandler interface {
|
||||
KillAction(context.Context, *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error)
|
||||
ExecutionStatus(context.Context, *connect.Request[v1.ExecutionStatusRequest]) (*connect.Response[v1.ExecutionStatusResponse], error)
|
||||
GetLogs(context.Context, *connect.Request[v1.GetLogsRequest]) (*connect.Response[v1.GetLogsResponse], error)
|
||||
GetActionLogs(context.Context, *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], error)
|
||||
ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error)
|
||||
WhoAmI(context.Context, *connect.Request[v1.WhoAmIRequest]) (*connect.Response[v1.WhoAmIResponse], error)
|
||||
SosReport(context.Context, *connect.Request[v1.SosReportRequest]) (*connect.Response[v1.SosReportResponse], error)
|
||||
@@ -529,6 +546,12 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect
|
||||
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetLogs")),
|
||||
connect.WithHandlerOptions(opts...),
|
||||
)
|
||||
oliveTinApiServiceGetActionLogsHandler := connect.NewUnaryHandler(
|
||||
OliveTinApiServiceGetActionLogsProcedure,
|
||||
svc.GetActionLogs,
|
||||
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetActionLogs")),
|
||||
connect.WithHandlerOptions(opts...),
|
||||
)
|
||||
oliveTinApiServiceValidateArgumentTypeHandler := connect.NewUnaryHandler(
|
||||
OliveTinApiServiceValidateArgumentTypeProcedure,
|
||||
svc.ValidateArgumentType,
|
||||
@@ -639,6 +662,8 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect
|
||||
oliveTinApiServiceExecutionStatusHandler.ServeHTTP(w, r)
|
||||
case OliveTinApiServiceGetLogsProcedure:
|
||||
oliveTinApiServiceGetLogsHandler.ServeHTTP(w, r)
|
||||
case OliveTinApiServiceGetActionLogsProcedure:
|
||||
oliveTinApiServiceGetActionLogsHandler.ServeHTTP(w, r)
|
||||
case OliveTinApiServiceValidateArgumentTypeProcedure:
|
||||
oliveTinApiServiceValidateArgumentTypeHandler.ServeHTTP(w, r)
|
||||
case OliveTinApiServiceWhoAmIProcedure:
|
||||
@@ -714,6 +739,10 @@ func (UnimplementedOliveTinApiServiceHandler) GetLogs(context.Context, *connect.
|
||||
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetLogs is not implemented"))
|
||||
}
|
||||
|
||||
func (UnimplementedOliveTinApiServiceHandler) GetActionLogs(context.Context, *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], error) {
|
||||
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetActionLogs is not implemented"))
|
||||
}
|
||||
|
||||
func (UnimplementedOliveTinApiServiceHandler) ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error) {
|
||||
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.ValidateArgumentType is not implemented"))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,9 @@ func (u *AuthenticatedUser) parseUsergroupLine(sep string) []string {
|
||||
} else {
|
||||
ret = strings.Fields(u.UsergroupLine)
|
||||
}
|
||||
|
||||
log.Debugf("parseUsergroupLine: %v, %v, sep:%v", u.UsergroupLine, ret, sep)
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -196,61 +199,66 @@ func getHeaderKeyOrEmpty(headers http.Header, key string) string {
|
||||
|
||||
// UserFromContext tries to find a user from a Connect RPC context
|
||||
func UserFromContext[T any](ctx context.Context, req *connect.Request[T], cfg *config.Config) *AuthenticatedUser {
|
||||
var ret *AuthenticatedUser
|
||||
|
||||
if req != nil {
|
||||
ret = &AuthenticatedUser{}
|
||||
// Only trust headers if explicitly configured
|
||||
if cfg.AuthHttpHeaderUsername != "" {
|
||||
ret.Username = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUsername)
|
||||
}
|
||||
|
||||
if cfg.AuthHttpHeaderUserGroup != "" {
|
||||
ret.UsergroupLine = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUserGroup)
|
||||
}
|
||||
// Optional provider header; otherwise infer below
|
||||
prov := getHeaderKeyOrEmpty(req.Header(), "provider")
|
||||
if prov != "" {
|
||||
ret.Provider = prov
|
||||
}
|
||||
|
||||
// If no username from headers, fall back to local session cookie
|
||||
if ret.Username == "" {
|
||||
// Build a minimal http.Request to parse cookies from headers
|
||||
dummy := &http.Request{Header: req.Header()}
|
||||
if c, err := dummy.Cookie("olivetin-sid-local"); err == nil && c != nil && c.Value != "" {
|
||||
if sess := auth.GetUserSession("local", c.Value); sess != nil {
|
||||
if u := cfg.FindUserByUsername(sess.Username); u != nil {
|
||||
ret.Username = u.Username
|
||||
ret.UsergroupLine = u.Usergroup
|
||||
ret.Provider = "local"
|
||||
ret.SID = c.Value
|
||||
} else {
|
||||
log.WithFields(log.Fields{"username": sess.Username}).Warn("UserFromContext: local session user not in config")
|
||||
}
|
||||
} else {
|
||||
log.WithFields(log.Fields{"sid": c.Value, "provider": "local"}).Warn("UserFromContext: stale local session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ret.Username != "" {
|
||||
buildUserAcls(cfg, ret)
|
||||
}
|
||||
user := userFromHeaders(req, cfg)
|
||||
if user.Username == "" {
|
||||
user = userFromLocalSession(req, cfg, user)
|
||||
}
|
||||
|
||||
if ret == nil || ret.Username == "" {
|
||||
ret = UserGuest(cfg)
|
||||
if user.Username == "" {
|
||||
user = *UserGuest(cfg)
|
||||
} else {
|
||||
buildUserAcls(cfg, &user)
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"username": ret.Username,
|
||||
"usergroupLine": ret.UsergroupLine,
|
||||
"provider": ret.Provider,
|
||||
"acls": ret.Acls,
|
||||
"username": user.Username,
|
||||
"usergroupLine": user.UsergroupLine,
|
||||
"provider": user.Provider,
|
||||
"acls": user.Acls,
|
||||
}).Debugf("UserFromContext")
|
||||
return &user
|
||||
}
|
||||
|
||||
return ret
|
||||
//gocyclo:ignore
|
||||
func userFromHeaders[T any](req *connect.Request[T], cfg *config.Config) AuthenticatedUser {
|
||||
var u AuthenticatedUser
|
||||
if req == nil {
|
||||
return u
|
||||
}
|
||||
if cfg.AuthHttpHeaderUsername != "" {
|
||||
u.Username = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUsername)
|
||||
}
|
||||
if cfg.AuthHttpHeaderUserGroup != "" {
|
||||
u.UsergroupLine = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUserGroup)
|
||||
}
|
||||
if prov := getHeaderKeyOrEmpty(req.Header(), "provider"); prov != "" {
|
||||
u.Provider = prov
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
//gocyclo:ignore
|
||||
func userFromLocalSession[T any](req *connect.Request[T], cfg *config.Config, u AuthenticatedUser) AuthenticatedUser {
|
||||
if req == nil || u.Username != "" {
|
||||
return u
|
||||
}
|
||||
dummy := &http.Request{Header: req.Header()}
|
||||
c, err := dummy.Cookie("olivetin-sid-local")
|
||||
if err != nil || c == nil || c.Value == "" {
|
||||
return u
|
||||
}
|
||||
sess := auth.GetUserSession("local", c.Value)
|
||||
if sess == nil {
|
||||
log.WithFields(log.Fields{"sid": c.Value, "provider": "local"}).Warn("UserFromContext: stale local session")
|
||||
return u
|
||||
}
|
||||
if cfgUser := cfg.FindUserByUsername(sess.Username); cfgUser != nil {
|
||||
u.Username = cfgUser.Username
|
||||
u.UsergroupLine = cfgUser.Usergroup
|
||||
u.Provider = "local"
|
||||
u.SID = c.Value
|
||||
return u
|
||||
}
|
||||
log.WithFields(log.Fields{"username": sess.Username}).Warn("UserFromContext: local session user not in config")
|
||||
return u
|
||||
}
|
||||
|
||||
func UserGuest(cfg *config.Config) *AuthenticatedUser {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
acl "github.com/OliveTin/OliveTin/internal/acl"
|
||||
auth "github.com/OliveTin/OliveTin/internal/auth"
|
||||
@@ -28,10 +29,26 @@ type oliveTinAPI struct {
|
||||
executor *executor.Executor
|
||||
cfg *config.Config
|
||||
|
||||
connectedClients []*connectedClients
|
||||
// streamingClients is a set of currently connected clients.
|
||||
// The empty struct value models set semantics (keys only) and keeps add/remove O(1).
|
||||
// We use a map for efficient membership and deletion; ordering is not required.
|
||||
streamingClients map[*streamingClient]struct{}
|
||||
streamingClientsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
type connectedClients struct {
|
||||
// This is used to avoid race conditions when iterating over the connectedClients map.
|
||||
// and holds the lock for as minimal time as possible to avoid blocking the API for too long.
|
||||
func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
|
||||
api.streamingClientsMutex.RLock()
|
||||
defer api.streamingClientsMutex.RUnlock()
|
||||
clients := make([]*streamingClient, 0, len(api.streamingClients))
|
||||
for client := range api.streamingClients {
|
||||
clients = append(clients, client)
|
||||
}
|
||||
return clients
|
||||
}
|
||||
|
||||
type streamingClient struct {
|
||||
channel chan *apiv1.EventStreamResponse
|
||||
AuthenticatedUser *acl.AuthenticatedUser
|
||||
}
|
||||
@@ -365,6 +382,10 @@ func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[a
|
||||
|
||||
binding := api.executor.FindBindingByID(req.Msg.BindingId)
|
||||
|
||||
if binding == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&apiv1.GetActionBindingResponse{
|
||||
Action: buildAction(binding, &DashboardRenderRequest{
|
||||
cfg: api.cfg,
|
||||
@@ -432,27 +453,98 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetL
|
||||
}
|
||||
|
||||
ret := &apiv1.GetLogsResponse{}
|
||||
logEntries, paging := api.executor.GetLogTrackingIdsACL(api.cfg, user, req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
|
||||
for _, le := range logEntries {
|
||||
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
|
||||
}
|
||||
ret.CountRemaining = paging.CountRemaining
|
||||
ret.PageSize = paging.PageSize
|
||||
ret.TotalCount = paging.TotalCount
|
||||
ret.StartOffset = paging.StartOffset
|
||||
return connect.NewResponse(ret), nil
|
||||
}
|
||||
|
||||
logEntries, pagingResult := api.executor.GetLogTrackingIds(req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
|
||||
func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv1.GetActionLogsRequest]) (*connect.Response[apiv1.GetActionLogsResponse], error) {
|
||||
user := acl.UserFromContext(ctx, req, api.cfg)
|
||||
|
||||
for _, logEntry := range logEntries {
|
||||
action := logEntry.Binding.Action
|
||||
|
||||
if action == nil || acl.IsAllowedLogs(api.cfg, user, action) {
|
||||
pbLogEntry := api.internalLogEntryToPb(logEntry, user)
|
||||
|
||||
ret.Logs = append(ret.Logs, pbLogEntry)
|
||||
}
|
||||
if err := api.checkDashboardAccess(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret.CountRemaining = pagingResult.CountRemaining
|
||||
ret.PageSize = pagingResult.PageSize
|
||||
ret.TotalCount = pagingResult.TotalCount
|
||||
ret.StartOffset = pagingResult.StartOffset
|
||||
|
||||
ret := &apiv1.GetActionLogsResponse{}
|
||||
filtered := api.filterLogsByACL(api.executor.GetLogsByActionId(req.Msg.ActionId), user)
|
||||
page := paginate(int64(len(filtered)), api.cfg.LogHistoryPageSize, req.Msg.StartOffset)
|
||||
if page.empty {
|
||||
ret.CountRemaining = 0
|
||||
ret.PageSize = page.size
|
||||
ret.TotalCount = page.total
|
||||
ret.StartOffset = page.start
|
||||
return connect.NewResponse(ret), nil
|
||||
}
|
||||
// Newest-first slicing: compute reversed indices
|
||||
startIdx := page.total - page.end
|
||||
endIdx := page.total - page.start
|
||||
if startIdx < 0 { startIdx = 0 }
|
||||
if endIdx > int64(len(filtered)) { endIdx = int64(len(filtered)) }
|
||||
for _, le := range filtered[startIdx:endIdx] {
|
||||
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
|
||||
}
|
||||
// Entries older than the returned newest page
|
||||
ret.CountRemaining = page.start
|
||||
ret.PageSize = page.size
|
||||
ret.TotalCount = page.total
|
||||
ret.StartOffset = page.start
|
||||
return connect.NewResponse(ret), nil
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) pbLogsFiltered(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*apiv1.LogEntry {
|
||||
out := make([]*apiv1.LogEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e == nil || e.Binding == nil || e.Binding.Action == nil {
|
||||
continue
|
||||
}
|
||||
if acl.IsAllowedLogs(api.cfg, user, e.Binding.Action) {
|
||||
out = append(out, api.internalLogEntryToPb(e, user))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*executor.InternalLogEntry {
|
||||
filtered := make([]*executor.InternalLogEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e == nil || e.Binding == nil || e.Binding.Action == nil {
|
||||
continue
|
||||
}
|
||||
if acl.IsAllowedLogs(api.cfg, user, e.Binding.Action) {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
type pageInfo struct {
|
||||
total int64
|
||||
size int64
|
||||
start int64
|
||||
end int64
|
||||
empty bool
|
||||
}
|
||||
|
||||
func paginate(total int64, size int64, start int64) pageInfo {
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start >= total {
|
||||
return pageInfo{total: total, size: size, start: start, end: start, empty: true}
|
||||
}
|
||||
end := start + size
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return pageInfo{total: total, size: size, start: start, end: end, empty: false}
|
||||
}
|
||||
|
||||
/*
|
||||
This function is ONLY a helper for the UI - the arguments are validated properly
|
||||
on the StartAction -> Executor chain. This is here basically to provide helpful
|
||||
@@ -565,20 +657,25 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
|
||||
return err
|
||||
}
|
||||
|
||||
client := &connectedClients{
|
||||
client := &streamingClient{
|
||||
channel: make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
|
||||
AuthenticatedUser: user,
|
||||
}
|
||||
|
||||
log.Infof("EventStream: client connected: %v", client.AuthenticatedUser.Username)
|
||||
|
||||
api.connectedClients = append(api.connectedClients, client)
|
||||
api.streamingClientsMutex.Lock()
|
||||
api.streamingClients[client] = struct{}{}
|
||||
api.streamingClientsMutex.Unlock()
|
||||
|
||||
// loop over client channel and send events to connectedClient
|
||||
for msg := range client.channel {
|
||||
log.Debugf("Sending event to client: %v", msg)
|
||||
if err := srv.Send(msg); err != nil {
|
||||
log.Errorf("Error sending event to client: %v", err)
|
||||
// Remove disconnected client from the list
|
||||
api.removeClient(client)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,8 +684,17 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) {
|
||||
api.streamingClientsMutex.Lock()
|
||||
delete(api.streamingClients, clientToRemove)
|
||||
api.streamingClientsMutex.Unlock()
|
||||
close(clientToRemove.channel)
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) OnActionMapRebuilt() {
|
||||
for _, client := range api.connectedClients {
|
||||
toRemove := []*streamingClient{}
|
||||
|
||||
for _, client := range api.copyOfStreamingClients() {
|
||||
select {
|
||||
case client.channel <- &apiv1.EventStreamResponse{
|
||||
Event: &apiv1.EventStreamResponse_ConfigChanged{
|
||||
@@ -596,13 +702,20 @@ func (api *oliveTinAPI) OnActionMapRebuilt() {
|
||||
},
|
||||
}:
|
||||
default:
|
||||
log.Warnf("EventStream: client channel is full, dropping message")
|
||||
log.Warnf("EventStream: client channel is full, removing client")
|
||||
toRemove = append(toRemove, client)
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range toRemove {
|
||||
api.removeClient(client)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
|
||||
for _, client := range api.connectedClients {
|
||||
toRemove := []*streamingClient{}
|
||||
|
||||
for _, client := range api.copyOfStreamingClients() {
|
||||
select {
|
||||
case client.channel <- &apiv1.EventStreamResponse{
|
||||
Event: &apiv1.EventStreamResponse_ExecutionStarted{
|
||||
@@ -612,13 +725,20 @@ func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
|
||||
},
|
||||
}:
|
||||
default:
|
||||
log.Warnf("EventStream: client channel is full, dropping message")
|
||||
log.Warnf("EventStream: client channel is full, removing client")
|
||||
toRemove = append(toRemove, client)
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range toRemove {
|
||||
api.removeClient(client)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
|
||||
for _, client := range api.connectedClients {
|
||||
toRemove := []*streamingClient{}
|
||||
|
||||
for _, client := range api.copyOfStreamingClients() {
|
||||
select {
|
||||
case client.channel <- &apiv1.EventStreamResponse{
|
||||
Event: &apiv1.EventStreamResponse_ExecutionFinished{
|
||||
@@ -628,9 +748,14 @@ func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
|
||||
},
|
||||
}:
|
||||
default:
|
||||
log.Warnf("EventStream: client channel is full, dropping message")
|
||||
log.Warnf("EventStream: client channel is full, removing client")
|
||||
toRemove = append(toRemove, client)
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range toRemove {
|
||||
api.removeClient(client)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) {
|
||||
@@ -645,9 +770,7 @@ func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[api
|
||||
func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
|
||||
user := acl.UserFromContext(ctx, req, api.cfg)
|
||||
|
||||
if err := api.checkDashboardAccess(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin
|
||||
|
||||
res := &apiv1.InitResponse{
|
||||
ShowFooter: api.cfg.ShowFooter,
|
||||
@@ -672,6 +795,7 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
|
||||
BannerCss: api.cfg.BannerCSS,
|
||||
ShowDiagnostics: user.EffectivePolicy.ShowDiagnostics,
|
||||
ShowLogList: user.EffectivePolicy.ShowLogList,
|
||||
LoginRequired: loginRequired,
|
||||
}
|
||||
|
||||
return connect.NewResponse(res), nil
|
||||
@@ -734,7 +858,9 @@ func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLin
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
|
||||
for _, client := range api.connectedClients {
|
||||
toRemove := []*streamingClient{}
|
||||
|
||||
for _, client := range api.copyOfStreamingClients() {
|
||||
select {
|
||||
case client.channel <- &apiv1.EventStreamResponse{
|
||||
Event: &apiv1.EventStreamResponse_OutputChunk{
|
||||
@@ -745,9 +871,14 @@ func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string
|
||||
},
|
||||
}:
|
||||
default:
|
||||
log.Warnf("EventStream: client channel is full, dropping message")
|
||||
log.Warnf("EventStream: client channel is full, removing client")
|
||||
toRemove = append(toRemove, client)
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range toRemove {
|
||||
api.removeClient(client)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
|
||||
@@ -864,6 +995,7 @@ func newServer(ex *executor.Executor) *oliveTinAPI {
|
||||
server := oliveTinAPI{}
|
||||
server.cfg = ex.Cfg
|
||||
server.executor = ex
|
||||
server.streamingClients = make(map[*streamingClient]struct{})
|
||||
|
||||
ex.AddListener(&server)
|
||||
return &server
|
||||
|
||||
@@ -46,6 +46,7 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
|
||||
CanExec: acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action),
|
||||
PopupOnStart: action.PopupOnStart,
|
||||
Order: int32(actionBinding.ConfigOrder),
|
||||
Timeout: int32(action.Timeout),
|
||||
}
|
||||
|
||||
for _, cfgArg := range action.Arguments {
|
||||
|
||||
@@ -88,34 +88,23 @@ func LoadUserSessions(cfg *config.Config) {
|
||||
data, err := os.ReadFile(cfg.GetDir() + "/sessions.yaml")
|
||||
if err != nil {
|
||||
logrus.WithError(err).Warn("Failed to read sessions.yaml file")
|
||||
// Initialize empty session storage if file doesn't exist
|
||||
if sessionStorage == nil {
|
||||
sessionStorage = &SessionStorage{
|
||||
Providers: make(map[string]*SessionProvider),
|
||||
}
|
||||
}
|
||||
ensureEmptySessionStorage()
|
||||
return
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(data, &sessionStorage)
|
||||
if err != nil {
|
||||
if err := yaml.Unmarshal(data, &sessionStorage); err != nil {
|
||||
logrus.WithError(err).Error("Failed to unmarshal sessions.yaml")
|
||||
// Initialize empty session storage if unmarshal fails
|
||||
if sessionStorage == nil {
|
||||
sessionStorage = &SessionStorage{
|
||||
Providers: make(map[string]*SessionProvider),
|
||||
}
|
||||
}
|
||||
ensureEmptySessionStorage()
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure sessionStorage and Providers are properly initialized
|
||||
if sessionStorage == nil {
|
||||
sessionStorage = &SessionStorage{
|
||||
Providers: make(map[string]*SessionProvider),
|
||||
}
|
||||
}
|
||||
ensureEmptySessionStorage()
|
||||
}
|
||||
|
||||
func ensureEmptySessionStorage() {
|
||||
if sessionStorage == nil {
|
||||
sessionStorage = &SessionStorage{Providers: make(map[string]*SessionProvider)}
|
||||
}
|
||||
if sessionStorage.Providers == nil {
|
||||
sessionStorage.Providers = make(map[string]*SessionProvider)
|
||||
}
|
||||
|
||||
@@ -48,51 +48,77 @@ func AppendSourceWithIncludes(cfg *Config, k *koanf.Koanf, configPath string) {
|
||||
func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
|
||||
log.Infof("Appending cfg source: %s", configPath)
|
||||
|
||||
// Unmarshal config - koanf will handle mapstructure tags automatically
|
||||
err := k.Unmarshal(".", cfg)
|
||||
if err != nil {
|
||||
log.Errorf("Error unmarshalling config: %v", err)
|
||||
if !unmarshalRoot(k, cfg) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for complex nested structures that might not unmarshal correctly
|
||||
// Only attempt manual unmarshaling if the automatic approach didn't populate the fields
|
||||
if len(cfg.Actions) == 0 && k.Exists("actions") {
|
||||
var actions []*Action
|
||||
if err := k.Unmarshal("actions", &actions); err == nil {
|
||||
cfg.Actions = actions
|
||||
log.Debugf("Manually loaded %d actions", len(actions))
|
||||
}
|
||||
}
|
||||
loadCollectionsFallbacks(k, cfg)
|
||||
|
||||
if len(cfg.Dashboards) == 0 && k.Exists("dashboards") {
|
||||
var dashboards []*DashboardComponent
|
||||
if err := k.Unmarshal("dashboards", &dashboards); err == nil {
|
||||
cfg.Dashboards = dashboards
|
||||
log.Debugf("Manually loaded %d dashboards", len(dashboards))
|
||||
}
|
||||
}
|
||||
|
||||
if len(cfg.Entities) == 0 && k.Exists("entities") {
|
||||
var entities []*EntityFile
|
||||
if err := k.Unmarshal("entities", &entities); err == nil {
|
||||
cfg.Entities = entities
|
||||
log.Debugf("Manually loaded %d entities", len(entities))
|
||||
}
|
||||
}
|
||||
|
||||
if len(cfg.AuthLocalUsers.Users) == 0 && k.Exists("authLocalUsers") {
|
||||
var authLocalUsers AuthLocalUsersConfig
|
||||
if err := k.Unmarshal("authLocalUsers", &authLocalUsers); err == nil {
|
||||
cfg.AuthLocalUsers = authLocalUsers
|
||||
log.Debugf("Manually loaded local auth config")
|
||||
}
|
||||
}
|
||||
|
||||
// Map structure tags should handle these automatically, but we keep fallbacks
|
||||
// for fields that might not unmarshal correctly
|
||||
applyConfigOverrides(k, cfg)
|
||||
|
||||
afterLoadFinalize(cfg, configPath)
|
||||
}
|
||||
|
||||
func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool {
|
||||
if err := k.Unmarshal(".", cfg); err != nil {
|
||||
log.Errorf("Error unmarshalling config: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func loadCollectionsFallbacks(k *koanf.Koanf, cfg *Config) {
|
||||
maybeUnmarshalActions(k, cfg)
|
||||
maybeUnmarshalDashboards(k, cfg)
|
||||
maybeUnmarshalEntities(k, cfg)
|
||||
maybeUnmarshalAuthLocalUsers(k, cfg)
|
||||
}
|
||||
|
||||
func maybeUnmarshalActions(k *koanf.Koanf, cfg *Config) {
|
||||
if len(cfg.Actions) != 0 || !k.Exists("actions") {
|
||||
return
|
||||
}
|
||||
var actions []*Action
|
||||
if err := k.Unmarshal("actions", &actions); err == nil {
|
||||
cfg.Actions = actions
|
||||
log.Debugf("Manually loaded %d actions", len(actions))
|
||||
}
|
||||
}
|
||||
|
||||
func maybeUnmarshalDashboards(k *koanf.Koanf, cfg *Config) {
|
||||
if len(cfg.Dashboards) != 0 || !k.Exists("dashboards") {
|
||||
return
|
||||
}
|
||||
var dashboards []*DashboardComponent
|
||||
if err := k.Unmarshal("dashboards", &dashboards); err == nil {
|
||||
cfg.Dashboards = dashboards
|
||||
log.Debugf("Manually loaded %d dashboards", len(dashboards))
|
||||
}
|
||||
}
|
||||
|
||||
func maybeUnmarshalEntities(k *koanf.Koanf, cfg *Config) {
|
||||
if len(cfg.Entities) != 0 || !k.Exists("entities") {
|
||||
return
|
||||
}
|
||||
var entities []*EntityFile
|
||||
if err := k.Unmarshal("entities", &entities); err == nil {
|
||||
cfg.Entities = entities
|
||||
log.Debugf("Manually loaded %d entities", len(entities))
|
||||
}
|
||||
}
|
||||
|
||||
func maybeUnmarshalAuthLocalUsers(k *koanf.Koanf, cfg *Config) {
|
||||
if len(cfg.AuthLocalUsers.Users) != 0 || !k.Exists("authLocalUsers") {
|
||||
return
|
||||
}
|
||||
var authLocalUsers AuthLocalUsersConfig
|
||||
if err := k.Unmarshal("authLocalUsers", &authLocalUsers); err == nil {
|
||||
cfg.AuthLocalUsers = authLocalUsers
|
||||
log.Debugf("Manually loaded local auth config")
|
||||
}
|
||||
}
|
||||
|
||||
func afterLoadFinalize(cfg *Config, configPath string) {
|
||||
metricConfigReloadedCount.Inc()
|
||||
metricConfigActionCount.Set(float64(len(cfg.Actions)))
|
||||
|
||||
@@ -135,128 +161,112 @@ func LoadIncludedConfigs(cfg *Config, k *koanf.Koanf, baseConfigPath string) {
|
||||
return
|
||||
}
|
||||
|
||||
configDir := filepath.Dir(baseConfigPath)
|
||||
includePath := filepath.Join(configDir, cfg.Include)
|
||||
|
||||
includePath := filepath.Join(filepath.Dir(baseConfigPath), cfg.Include)
|
||||
log.Infof("Loading included configs from: %s", includePath)
|
||||
|
||||
// Check if the include directory exists
|
||||
dirInfo, err := os.Stat(includePath)
|
||||
if err != nil {
|
||||
log.Warnf("Include directory not found: %s", includePath)
|
||||
yamlFiles, ok := listYamlFiles(includePath)
|
||||
if !ok || len(yamlFiles) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if !dirInfo.IsDir() {
|
||||
log.Warnf("Include path is not a directory: %s", includePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Read all .yml files from the directory
|
||||
entries, err := os.ReadDir(includePath)
|
||||
if err != nil {
|
||||
log.Errorf("Error reading include directory: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter and sort .yml files
|
||||
var yamlFiles []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".yml") || strings.HasSuffix(entry.Name(), ".yaml")) {
|
||||
yamlFiles = append(yamlFiles, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
if len(yamlFiles) == 0 {
|
||||
log.Infof("No YAML files found in include directory: %s", includePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort files to ensure deterministic load order
|
||||
sort.Strings(yamlFiles)
|
||||
|
||||
// Load each file and merge into config
|
||||
for _, filename := range yamlFiles {
|
||||
filePath := filepath.Join(includePath, filename)
|
||||
log.Infof("Loading included config file: %s", filePath)
|
||||
|
||||
includeK := koanf.New(".")
|
||||
f := file.Provider(filePath)
|
||||
|
||||
if err := includeK.Load(f, yaml.Parser()); err != nil {
|
||||
log.Errorf("Error loading included config file %s: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Unmarshal into a temporary config to process properly
|
||||
tempCfg := &Config{}
|
||||
if err := includeK.Unmarshal(".", tempCfg); err != nil {
|
||||
log.Errorf("Error unmarshalling included config file %s: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply the same manual loading workarounds as in AppendSource
|
||||
if len(tempCfg.Actions) == 0 && includeK.Exists("actions") {
|
||||
var actions []*Action
|
||||
if err := includeK.Unmarshal("actions", &actions); err == nil {
|
||||
tempCfg.Actions = actions
|
||||
log.Debugf("Manually loaded %d actions from %s", len(actions), filename)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge the temp config into the main config
|
||||
// Later files override earlier ones
|
||||
mergeConfig(cfg, tempCfg)
|
||||
|
||||
log.Infof("Successfully loaded and merged %s", filename)
|
||||
loadAndMergeIncludedFile(cfg, includePath, filename)
|
||||
}
|
||||
|
||||
log.Infof("Finished loading %d included config file(s)", len(yamlFiles))
|
||||
|
||||
// Sanitize the merged config
|
||||
cfg.Sanitize()
|
||||
}
|
||||
|
||||
func listYamlFiles(includePath string) ([]string, bool) {
|
||||
dirInfo, err := os.Stat(includePath)
|
||||
if err != nil {
|
||||
log.Warnf("Include directory not found: %s", includePath)
|
||||
return nil, false
|
||||
}
|
||||
if !dirInfo.IsDir() {
|
||||
log.Warnf("Include path is not a directory: %s", includePath)
|
||||
return nil, false
|
||||
}
|
||||
entries, err := os.ReadDir(includePath)
|
||||
if err != nil {
|
||||
log.Errorf("Error reading include directory: %v", err)
|
||||
return nil, false
|
||||
}
|
||||
var yamlFiles []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
|
||||
yamlFiles = append(yamlFiles, name)
|
||||
}
|
||||
}
|
||||
if len(yamlFiles) == 0 {
|
||||
log.Infof("No YAML files found in include directory: %s", includePath)
|
||||
}
|
||||
return yamlFiles, true
|
||||
}
|
||||
|
||||
func loadAndMergeIncludedFile(cfg *Config, includePath, filename string) {
|
||||
filePath := filepath.Join(includePath, filename)
|
||||
log.Infof("Loading included config file: %s", filePath)
|
||||
|
||||
includeK := koanf.New(".")
|
||||
if err := includeK.Load(file.Provider(filePath), yaml.Parser()); err != nil {
|
||||
log.Errorf("Error loading included config file %s: %v", filePath, err)
|
||||
return
|
||||
}
|
||||
|
||||
tempCfg := &Config{}
|
||||
if err := includeK.Unmarshal(".", tempCfg); err != nil {
|
||||
log.Errorf("Error unmarshalling included config file %s: %v", filePath, err)
|
||||
return
|
||||
}
|
||||
|
||||
loadCollectionsFallbacks(includeK, tempCfg)
|
||||
|
||||
mergeConfig(cfg, tempCfg)
|
||||
log.Infof("Successfully loaded and merged %s", filename)
|
||||
}
|
||||
|
||||
func mergeConfig(base *Config, overlay *Config) {
|
||||
// Merge Actions - overlay appends to base
|
||||
mergeSlices(base, overlay)
|
||||
overrideSimple(base, overlay)
|
||||
overrideNested(base, overlay)
|
||||
overrideStrings(base, overlay)
|
||||
}
|
||||
|
||||
func mergeSlices(base *Config, overlay *Config) {
|
||||
if len(overlay.Actions) > 0 {
|
||||
base.Actions = append(base.Actions, overlay.Actions...)
|
||||
}
|
||||
|
||||
// Merge Dashboards - overlay appends to base
|
||||
if len(overlay.Dashboards) > 0 {
|
||||
base.Dashboards = append(base.Dashboards, overlay.Dashboards...)
|
||||
log.Debugf("Merged %d dashboards from include", len(overlay.Dashboards))
|
||||
}
|
||||
|
||||
// Merge Entities - overlay appends to base
|
||||
if len(overlay.Entities) > 0 {
|
||||
base.Entities = append(base.Entities, overlay.Entities...)
|
||||
log.Debugf("Merged %d entities from include", len(overlay.Entities))
|
||||
}
|
||||
|
||||
// Merge AccessControlLists - overlay appends to base
|
||||
if len(overlay.AccessControlLists) > 0 {
|
||||
base.AccessControlLists = append(base.AccessControlLists, overlay.AccessControlLists...)
|
||||
log.Debugf("Merged %d access control lists from include", len(overlay.AccessControlLists))
|
||||
}
|
||||
|
||||
// Merge AuthLocalUsers.Users - overlay appends to base
|
||||
if len(overlay.AuthLocalUsers.Users) > 0 {
|
||||
base.AuthLocalUsers.Users = append(base.AuthLocalUsers.Users, overlay.AuthLocalUsers.Users...)
|
||||
log.Debugf("Merged %d local users from include", len(overlay.AuthLocalUsers.Users))
|
||||
}
|
||||
|
||||
// Merge slices by appending
|
||||
if len(overlay.StyleMods) > 0 {
|
||||
base.StyleMods = append(base.StyleMods, overlay.StyleMods...)
|
||||
}
|
||||
|
||||
if len(overlay.AdditionalNavigationLinks) > 0 {
|
||||
base.AdditionalNavigationLinks = append(base.AdditionalNavigationLinks, overlay.AdditionalNavigationLinks...)
|
||||
}
|
||||
}
|
||||
|
||||
// Override simple fields (later files win)
|
||||
func overrideSimple(base *Config, overlay *Config) {
|
||||
if overlay.LogLevel != "" {
|
||||
base.LogLevel = overlay.LogLevel
|
||||
}
|
||||
@@ -278,28 +288,30 @@ func mergeConfig(base *Config, overlay *Config) {
|
||||
if overlay.AuthRequireGuestsToLogin != base.AuthRequireGuestsToLogin {
|
||||
base.AuthRequireGuestsToLogin = overlay.AuthRequireGuestsToLogin
|
||||
}
|
||||
|
||||
// Override nested structs
|
||||
if overlay.DefaultPolicy.ShowDiagnostics != base.DefaultPolicy.ShowDiagnostics {
|
||||
base.DefaultPolicy.ShowDiagnostics = overlay.DefaultPolicy.ShowDiagnostics
|
||||
}
|
||||
if overlay.DefaultPolicy.ShowLogList != base.DefaultPolicy.ShowLogList {
|
||||
base.DefaultPolicy.ShowLogList = overlay.DefaultPolicy.ShowLogList
|
||||
}
|
||||
|
||||
if overlay.Prometheus.Enabled != base.Prometheus.Enabled {
|
||||
base.Prometheus.Enabled = overlay.Prometheus.Enabled
|
||||
}
|
||||
if overlay.Prometheus.DefaultGoMetrics != base.Prometheus.DefaultGoMetrics {
|
||||
base.Prometheus.DefaultGoMetrics = overlay.Prometheus.DefaultGoMetrics
|
||||
}
|
||||
|
||||
// Override AuthLocalUsers.Enabled if set
|
||||
if overlay.AuthLocalUsers.Enabled {
|
||||
base.AuthLocalUsers.Enabled = overlay.AuthLocalUsers.Enabled
|
||||
}
|
||||
}
|
||||
|
||||
// Override string fields if non-empty
|
||||
func overrideNested(base *Config, overlay *Config) {
|
||||
// Only apply overrides when overlay explicitly enables the option.
|
||||
// This mirrors the presence-check pattern used elsewhere to avoid
|
||||
// unintentionally disabling an already-enabled base setting with a default false.
|
||||
if overlay.DefaultPolicy.ShowDiagnostics {
|
||||
base.DefaultPolicy.ShowDiagnostics = true
|
||||
}
|
||||
if overlay.DefaultPolicy.ShowLogList {
|
||||
base.DefaultPolicy.ShowLogList = true
|
||||
}
|
||||
if overlay.Prometheus.Enabled {
|
||||
base.Prometheus.Enabled = true
|
||||
}
|
||||
if overlay.Prometheus.DefaultGoMetrics {
|
||||
base.Prometheus.DefaultGoMetrics = true
|
||||
}
|
||||
}
|
||||
|
||||
func overrideStrings(base *Config, overlay *Config) {
|
||||
overrideString(&base.BannerMessage, overlay.BannerMessage)
|
||||
overrideString(&base.BannerCSS, overlay.BannerCSS)
|
||||
overrideString(&base.LogLevel, overlay.LogLevel)
|
||||
|
||||
@@ -90,55 +90,63 @@ var envConfigTests = []struct {
|
||||
}
|
||||
|
||||
func TestEnvInConfig(t *testing.T) {
|
||||
for _, tt := range envConfigTests {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if tt.input != "" {
|
||||
os.Setenv("INPUT", tt.input)
|
||||
}
|
||||
|
||||
// Process the YAML content to replace environment variables
|
||||
processedYaml := envRegex.ReplaceAllStringFunc(tt.yaml, func(match string) string {
|
||||
submatches := envRegex.FindStringSubmatch(match)
|
||||
key := submatches[1]
|
||||
val, _ := os.LookupEnv(key)
|
||||
return val
|
||||
})
|
||||
|
||||
k := koanf.New(".")
|
||||
err := k.Load(rawbytes.Provider([]byte(processedYaml)), yaml.Parser())
|
||||
if err != nil {
|
||||
t.Errorf("Error loading YAML: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Try default unmarshaling
|
||||
err = k.Unmarshal(".", cfg)
|
||||
if err != nil {
|
||||
t.Errorf("Error unmarshalling config: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Manual field assignment for testing (since default unmarshaling has issues with field mapping)
|
||||
if k.Exists("PageTitle") {
|
||||
cfg.PageTitle = k.String("PageTitle")
|
||||
}
|
||||
if k.Exists("CheckForUpdates") {
|
||||
cfg.CheckForUpdates = k.Bool("CheckForUpdates")
|
||||
}
|
||||
if k.Exists("LogHistoryPageSize") {
|
||||
cfg.LogHistoryPageSize = k.Int64("LogHistoryPageSize")
|
||||
}
|
||||
if k.Exists("actions") {
|
||||
var actions []*Action
|
||||
if err := k.Unmarshal("actions", &actions); err == nil {
|
||||
cfg.Actions = actions
|
||||
}
|
||||
}
|
||||
|
||||
field := tt.selector(cfg)
|
||||
assert.Equal(t, tt.output, field, "Unmarshaled config field doesn't match expected value: env=\"%s\"", tt.input)
|
||||
|
||||
os.Unsetenv("INPUT")
|
||||
}
|
||||
for _, tt := range envConfigTests {
|
||||
cfg := DefaultConfig()
|
||||
setIfNotEmpty("INPUT", tt.input)
|
||||
processed := processYamlWithEnv(tt.yaml)
|
||||
k, err := loadKoanf(processed)
|
||||
if err != nil {
|
||||
t.Errorf("Error loading YAML: %v", err)
|
||||
continue
|
||||
}
|
||||
if err := k.Unmarshal(".", cfg); err != nil {
|
||||
t.Errorf("Error unmarshalling config: %v", err)
|
||||
continue
|
||||
}
|
||||
manualAssigns(k, cfg)
|
||||
field := tt.selector(cfg)
|
||||
assert.Equal(t, tt.output, field, "Unmarshaled config field doesn't match expected value: env=\"%s\"", tt.input)
|
||||
os.Unsetenv("INPUT")
|
||||
}
|
||||
}
|
||||
|
||||
func setIfNotEmpty(key, val string) {
|
||||
if val != "" {
|
||||
os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
|
||||
func processYamlWithEnv(content string) string {
|
||||
return envRegex.ReplaceAllStringFunc(content, func(match string) string {
|
||||
submatches := envRegex.FindStringSubmatch(match)
|
||||
key := submatches[1]
|
||||
val, _ := os.LookupEnv(key)
|
||||
return val
|
||||
})
|
||||
}
|
||||
|
||||
func loadKoanf(processed string) (*koanf.Koanf, error) {
|
||||
k := koanf.New(".")
|
||||
if err := k.Load(rawbytes.Provider([]byte(processed)), yaml.Parser()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func manualAssigns(k *koanf.Koanf, cfg *Config) {
|
||||
if k.Exists("PageTitle") {
|
||||
cfg.PageTitle = k.String("PageTitle")
|
||||
}
|
||||
if k.Exists("CheckForUpdates") {
|
||||
cfg.CheckForUpdates = k.Bool("CheckForUpdates")
|
||||
}
|
||||
if k.Exists("LogHistoryPageSize") {
|
||||
cfg.LogHistoryPageSize = k.Int64("LogHistoryPageSize")
|
||||
}
|
||||
if k.Exists("actions") {
|
||||
var actions []*Action
|
||||
if err := k.Unmarshal("actions", &actions); err == nil {
|
||||
cfg.Actions = actions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,36 +30,42 @@ func AddListener(l func()) {
|
||||
}
|
||||
|
||||
func SetupEntityFileWatchers(cfg *config.Config) {
|
||||
configDir := cfg.GetDir()
|
||||
baseDir := resolveEntitiesBaseDir(cfg.GetDir())
|
||||
for i := range cfg.Entities { // #337 - iterate by key, not by value
|
||||
ef := cfg.Entities[i]
|
||||
watchAndLoadEntity(baseDir, ef)
|
||||
}
|
||||
}
|
||||
|
||||
// Only use var directory if not in integration test mode
|
||||
absConfigDir, _ := filepath.Abs(configDir)
|
||||
if !strings.Contains(absConfigDir, "integration-tests") {
|
||||
configDirVar := filepath.Join(configDir, "var") // for development purposes
|
||||
//gocyclo:ignore
|
||||
func resolveEntitiesBaseDir(configDir string) string {
|
||||
absConfigDir, err := filepath.Abs(configDir)
|
||||
|
||||
if _, err := os.Stat(configDirVar); err == nil {
|
||||
configDir = configDirVar
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorf("Error getting absolute path for %s: %v", configDir, err)
|
||||
return configDir
|
||||
}
|
||||
|
||||
for entityIndex := range cfg.Entities { // #337 - iterate by key, not by value
|
||||
ef := cfg.Entities[entityIndex]
|
||||
p := ef.File
|
||||
|
||||
if !filepath.IsAbs(p) {
|
||||
p = filepath.Join(configDir, p)
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"entityFile": p,
|
||||
}).Debugf("Adding config dir to entity file path")
|
||||
}
|
||||
|
||||
go filehelper.WatchFileWrite(p, func(filename string) {
|
||||
loadEntityFile(p, ef.Name)
|
||||
})
|
||||
|
||||
loadEntityFile(p, ef.Name)
|
||||
if strings.Contains(absConfigDir, "integration-tests") {
|
||||
return configDir
|
||||
}
|
||||
|
||||
devVar := filepath.Join(configDir, "var")
|
||||
|
||||
if _, err := os.Stat(devVar); err == nil {
|
||||
return devVar
|
||||
}
|
||||
return absConfigDir
|
||||
}
|
||||
|
||||
func watchAndLoadEntity(baseDir string, ef *config.EntityFile) {
|
||||
p := ef.File
|
||||
if !filepath.IsAbs(p) {
|
||||
p = filepath.Join(baseDir, p)
|
||||
log.WithFields(log.Fields{"entityFile": p}).Debugf("Adding config dir to entity file path")
|
||||
}
|
||||
go filehelper.WatchFileWrite(p, func(filename string) { loadEntityFile(p, ef.Name) })
|
||||
loadEntityFile(p, ef.Name)
|
||||
}
|
||||
|
||||
func loadEntityFile(filename string, entityname string) {
|
||||
|
||||
@@ -46,42 +46,42 @@ func parseActionExec(values map[string]string, action *config.Action, entity *en
|
||||
if action == nil {
|
||||
return nil, fmt.Errorf("action is nil")
|
||||
}
|
||||
if err := validateArguments(values, action); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed := make([]string, len(action.Exec))
|
||||
for i, a := range action.Exec {
|
||||
out, err := parseSingleExec(a, values, entity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed[i] = out
|
||||
}
|
||||
logParsedExec(action, parsed, values)
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseSingleExec(a string, values map[string]string, entity *entities.Entity) (string, error) {
|
||||
arg, err := parseCommandForReplacements(a, values, entity)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return entities.ParseTemplateWithArgs(arg, entity, values), nil
|
||||
}
|
||||
|
||||
func validateArguments(values map[string]string, action *config.Action) error {
|
||||
for _, arg := range action.Arguments {
|
||||
argName := arg.Name
|
||||
argValue := values[argName]
|
||||
|
||||
err := typecheckActionArgument(&arg, argValue, action)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if err := typecheckActionArgument(&arg, values[arg.Name], action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"name": argName,
|
||||
"value": argValue,
|
||||
}).Debugf("Arg assigned")
|
||||
log.WithFields(log.Fields{"name": arg.Name, "value": values[arg.Name]}).Debugf("Arg assigned")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
parsedArgs := make([]string, len(action.Exec))
|
||||
for i, arg := range action.Exec {
|
||||
parsedArg, err := parseCommandForReplacements(arg, values, entity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parsedArg = entities.ParseTemplateWithArgs(parsedArg, entity, values)
|
||||
parsedArgs[i] = parsedArg
|
||||
}
|
||||
|
||||
redactedArgs := redactExecArgs(parsedArgs, action.Arguments, values)
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"actionTitle": action.Title,
|
||||
"cmd": redactedArgs,
|
||||
}).Infof("Action parse args - After (Exec)")
|
||||
|
||||
return parsedArgs, nil
|
||||
func logParsedExec(action *config.Action, parsed []string, values map[string]string) {
|
||||
redacted := redactExecArgs(parsed, action.Arguments, values)
|
||||
log.WithFields(log.Fields{"actionTitle": action.Title, "cmd": redacted}).Infof("Action parse args - After (Exec)")
|
||||
}
|
||||
|
||||
func parseActionArguments(values map[string]string, action *config.Action, entity *entities.Entity) (string, error) {
|
||||
@@ -298,17 +298,12 @@ func checkShellArgumentSafety(action *config.Action) error {
|
||||
if action.Shell == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
unsafeTypes := []string{"url", "email", "raw_string_multiline", "very_dangerous_raw_string"}
|
||||
|
||||
unsafe := map[string]struct{}{"url": {}, "email": {}, "raw_string_multiline": {}, "very_dangerous_raw_string": {}}
|
||||
for _, arg := range action.Arguments {
|
||||
for _, unsafeType := range unsafeTypes {
|
||||
if arg.Type == unsafeType {
|
||||
return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type)
|
||||
}
|
||||
if _, bad := unsafe[arg.Type]; bad {
|
||||
return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +224,48 @@ func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*Int
|
||||
return trackingIds, pagingResult
|
||||
}
|
||||
|
||||
// GetLogTrackingIdsACL returns logs filtered by ACL visibility for the user and
|
||||
// paginated correctly based on the filtered set.
|
||||
func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *acl.AuthenticatedUser, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
|
||||
// Build filtered list in reverse-chronological order (matching GetLogTrackingIds)
|
||||
filtered := make([]*InternalLogEntry, 0)
|
||||
|
||||
e.logmutex.RLock()
|
||||
for i := len(e.logsTrackingIdsByDate) - 1; i >= 0; i-- {
|
||||
entry := e.logs[e.logsTrackingIdsByDate[i]]
|
||||
if entry == nil || entry.Binding == nil || entry.Binding.Action == nil {
|
||||
continue
|
||||
}
|
||||
if acl.IsAllowedLogs(cfg, user, entry.Binding.Action) {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
}
|
||||
e.logmutex.RUnlock()
|
||||
|
||||
total := int64(len(filtered))
|
||||
paging := &PagingResult{PageSize: pageCount, TotalCount: total, StartOffset: startOffset}
|
||||
|
||||
if total == 0 {
|
||||
paging.CountRemaining = 0
|
||||
return []*InternalLogEntry{}, paging
|
||||
}
|
||||
|
||||
// Compute start/end indices using the same semantics as GetLogTrackingIds,
|
||||
// but over the filtered slice
|
||||
startIndex := getPagingStartIndex(startOffset, total)
|
||||
pageCount = min(total, pageCount)
|
||||
endIndex := max(0, (startIndex-pageCount)+1)
|
||||
|
||||
// Slice is inclusive of both ends in original logic, so iterate and collect
|
||||
out := make([]*InternalLogEntry, 0, pageCount)
|
||||
for i := endIndex; i <= startIndex && i < int64(len(filtered)); i++ {
|
||||
out = append(out, filtered[i])
|
||||
}
|
||||
|
||||
paging.CountRemaining = endIndex
|
||||
return out, paging
|
||||
}
|
||||
|
||||
func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
|
||||
e.logmutex.RLock()
|
||||
|
||||
@@ -427,51 +469,75 @@ func stepACLCheck(req *ExecutionRequest) bool {
|
||||
}
|
||||
|
||||
func stepParseArgs(req *ExecutionRequest) bool {
|
||||
var err error
|
||||
ensureArgumentMap(req)
|
||||
injectSystemArgs(req)
|
||||
|
||||
if req.Arguments == nil {
|
||||
req.Arguments = make(map[string]string)
|
||||
if !hasBindingAndAction(req) {
|
||||
return fail(req, fmt.Errorf("cannot parse arguments: Binding or Action is nil"))
|
||||
}
|
||||
|
||||
req.Arguments["ot_executionTrackingId"] = req.TrackingID
|
||||
req.Arguments["ot_username"] = req.AuthenticatedUser.Username
|
||||
|
||||
mangleInvalidArgumentValues(req)
|
||||
|
||||
if req.Binding == nil || req.Binding.Action == nil {
|
||||
err = fmt.Errorf("cannot parse arguments: Binding or Action is nil")
|
||||
req.logEntry.Output = err.Error()
|
||||
log.Warn(err.Error())
|
||||
return false
|
||||
}
|
||||
|
||||
if len(req.Binding.Action.Exec) > 0 {
|
||||
req.useDirectExec = true
|
||||
req.execArgs, err = parseActionExec(req.Arguments, req.Binding.Action, req.Binding.Entity)
|
||||
if hasExec(req) {
|
||||
return handleExecBranch(req)
|
||||
} else {
|
||||
req.useDirectExec = false
|
||||
|
||||
err = checkShellArgumentSafety(req.Binding.Action)
|
||||
if err != nil {
|
||||
req.logEntry.Output = err.Error()
|
||||
log.Warn(err.Error())
|
||||
return false
|
||||
}
|
||||
|
||||
req.finalParsedCommand, err = parseActionArguments(req.Arguments, req.Binding.Action, req.Binding.Entity)
|
||||
return handleShellBranch(req)
|
||||
}
|
||||
}
|
||||
|
||||
func handleExecBranch(req *ExecutionRequest) bool {
|
||||
args, err := parseActionExec(req.Arguments, req.Binding.Action, req.Binding.Entity)
|
||||
|
||||
if err != nil {
|
||||
req.logEntry.Output = err.Error()
|
||||
|
||||
log.Warn(err.Error())
|
||||
|
||||
return false
|
||||
return fail(req, err)
|
||||
}
|
||||
|
||||
req.useDirectExec = true
|
||||
req.execArgs = args
|
||||
return true
|
||||
}
|
||||
|
||||
func handleShellBranch(req *ExecutionRequest) bool {
|
||||
if err := checkShellArgumentSafety(req.Binding.Action); err != nil {
|
||||
return fail(req, err)
|
||||
}
|
||||
|
||||
cmd, err := parseActionArguments(req.Arguments, req.Binding.Action, req.Binding.Entity)
|
||||
|
||||
if err != nil {
|
||||
return fail(req, err)
|
||||
}
|
||||
|
||||
req.useDirectExec = false
|
||||
req.finalParsedCommand = cmd
|
||||
return true
|
||||
}
|
||||
|
||||
func ensureArgumentMap(req *ExecutionRequest) {
|
||||
if req.Arguments == nil {
|
||||
req.Arguments = make(map[string]string)
|
||||
}
|
||||
}
|
||||
|
||||
func injectSystemArgs(req *ExecutionRequest) {
|
||||
req.Arguments["ot_executionTrackingId"] = req.TrackingID
|
||||
req.Arguments["ot_username"] = req.AuthenticatedUser.Username
|
||||
}
|
||||
|
||||
func hasBindingAndAction(req *ExecutionRequest) bool {
|
||||
return !(req.Binding == nil || req.Binding.Action == nil)
|
||||
}
|
||||
|
||||
func hasExec(req *ExecutionRequest) bool {
|
||||
return len(req.Binding.Action.Exec) > 0
|
||||
}
|
||||
|
||||
func fail(req *ExecutionRequest, err error) bool {
|
||||
req.logEntry.Output = err.Error()
|
||||
log.Warn(err.Error())
|
||||
return false
|
||||
}
|
||||
|
||||
func stepRequestAction(req *ExecutionRequest) bool {
|
||||
metricActionsRequested.Inc()
|
||||
|
||||
@@ -482,6 +548,7 @@ func stepRequestAction(req *ExecutionRequest) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
req.logEntry.Binding = req.Binding
|
||||
req.logEntry.ActionConfigTitle = req.Binding.Action.Title
|
||||
req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity)
|
||||
req.logEntry.ActionIcon = req.Binding.Action.Icon
|
||||
@@ -585,34 +652,17 @@ func buildEnv(args map[string]string) []string {
|
||||
func stepExec(req *ExecutionRequest) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
streamer := &OutputStreamer{Req: req}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if req.useDirectExec {
|
||||
cmd = wrapCommandDirect(ctx, req.execArgs)
|
||||
} else {
|
||||
cmd = wrapCommandInShell(ctx, req.finalParsedCommand)
|
||||
}
|
||||
|
||||
cmd := buildCommand(ctx, req)
|
||||
if cmd == nil {
|
||||
req.logEntry.Output = "Cannot execute: no command arguments provided"
|
||||
log.Warn("Cannot execute: no command arguments provided")
|
||||
return false
|
||||
}
|
||||
|
||||
cmd.Stdout = streamer
|
||||
cmd.Stderr = streamer
|
||||
cmd.Env = buildEnv(req.Arguments)
|
||||
|
||||
req.logEntry.ExecutionStarted = true
|
||||
|
||||
prepareCommand(cmd, streamer, req)
|
||||
runerr := cmd.Start()
|
||||
|
||||
req.logEntry.Process = cmd.Process
|
||||
|
||||
waiterr := cmd.Wait()
|
||||
|
||||
req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
|
||||
req.logEntry.Output = streamer.String()
|
||||
|
||||
@@ -642,6 +692,20 @@ func stepExec(req *ExecutionRequest) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func buildCommand(ctx context.Context, req *ExecutionRequest) *exec.Cmd {
|
||||
if req.useDirectExec {
|
||||
return wrapCommandDirect(ctx, req.execArgs)
|
||||
}
|
||||
return wrapCommandInShell(ctx, req.finalParsedCommand)
|
||||
}
|
||||
|
||||
func prepareCommand(cmd *exec.Cmd, streamer *OutputStreamer, req *ExecutionRequest) {
|
||||
cmd.Stdout = streamer
|
||||
cmd.Stderr = streamer
|
||||
cmd.Env = buildEnv(req.Arguments)
|
||||
req.logEntry.ExecutionStarted = true
|
||||
}
|
||||
|
||||
func stepExecAfter(req *ExecutionRequest) bool {
|
||||
if req.Binding.Action.ShellAfterCompleted == "" {
|
||||
return true
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package httpservers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
// apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||
|
||||
config "github.com/OliveTin/OliveTin/internal/config"
|
||||
)
|
||||
|
||||
func parseHttpHeaderForAuth(cfg *config.Config, req *http.Request) (string, string) {
|
||||
username, ok := req.Header[cfg.AuthHttpHeaderUsername]
|
||||
|
||||
if !ok {
|
||||
log.Warnf("Config has AuthHttpHeaderUsername set to %v, but it was not found", cfg.AuthHttpHeaderUsername)
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if cfg.AuthHttpHeaderUserGroup != "" {
|
||||
usergroup, ok := req.Header[cfg.AuthHttpHeaderUserGroup]
|
||||
|
||||
if ok {
|
||||
log.Debugf("HTTP Header Auth found a username and usergroup")
|
||||
|
||||
return username[0], usergroup[0]
|
||||
} else {
|
||||
log.Warnf("Config has AuthHttpHeaderUserGroup set to %v, but it was not found", cfg.AuthHttpHeaderUserGroup)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("HTTP Header Auth found a username, but usergroup is not being used")
|
||||
|
||||
return username[0], ""
|
||||
}
|
||||
|
||||
//gocyclo:ignore
|
||||
func parseJwtHeader(cfg *config.Config, req *http.Request) (string, string) {
|
||||
// JWTs in the Authorization header are usually prefixed with "Bearer " which is not part of the JWT token.
|
||||
return parseJwt(cfg, strings.TrimPrefix(req.Header.Get(cfg.AuthJwtHeader), "Bearer "))
|
||||
}
|
||||
@@ -51,10 +51,16 @@ func init() {
|
||||
}
|
||||
|
||||
func initLog() {
|
||||
log.SetFormatter(&log.TextFormatter{
|
||||
ForceQuote: true,
|
||||
DisableTimestamp: true,
|
||||
})
|
||||
logFormat := os.Getenv("OLIVETIN_LOG_FORMAT")
|
||||
|
||||
if logFormat == "json" {
|
||||
log.SetFormatter(&log.JSONFormatter{})
|
||||
} else {
|
||||
log.SetFormatter(&log.TextFormatter{
|
||||
ForceQuote: true,
|
||||
DisableTimestamp: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Use debug this early on to catch details about startup errors. The
|
||||
// default config will raise the log level later, if not set.
|
||||
|
||||
Reference in New Issue
Block a user