feature: The mega dashboards & entities commit.

This commit is contained in:
jamesread 2024-02-22 23:16:43 +00:00
parent fddf83f27d
commit 381bf59fbd
39 changed files with 928 additions and 356 deletions

View File

@ -68,8 +68,9 @@ webui-codestyle:
cd webui.dev && ./node_modules/.bin/stylelint style.css cd webui.dev && ./node_modules/.bin/stylelint style.css
webui-dist: webui-dist:
rm -rf webui webui.dev/dist
cd webui.dev && npm install cd webui.dev && npm install
cd webui.dev && parcel build --dist-dir ../webui/ cd webui.dev && parcel build && mv dist ../webui
clean: clean:
rm -rf dist OliveTin OliveTin.armhf OliveTin.exe reports gen rm -rf dist OliveTin OliveTin.armhf OliveTin.exe reports gen

View File

@ -39,24 +39,23 @@ message GetDashboardComponentsResponse {
string title = 1; string title = 1;
repeated Action actions = 2; repeated Action actions = 2;
repeated Entity entities = 3; repeated Entity entities = 3;
repeated DashboardItem dashboards = 4; repeated DashboardComponent dashboards = 4;
} }
message GetDashboardComponentsRequest {} message GetDashboardComponentsRequest {}
message DashboardItem { message DashboardComponent {
string title = 1; string title = 1;
string type = 2; string type = 2;
repeated DashboardItem contents = 3; repeated DashboardComponent contents = 3;
string link = 4;
} }
message StartActionRequest { message StartActionRequest {
string action_name = 1; string action_id = 1;
repeated StartActionArgument arguments = 2; repeated StartActionArgument arguments = 2;
string uuid = 3; string unique_tracking_id = 3;
} }
message StartActionArgument { message StartActionArgument {
@ -65,30 +64,30 @@ message StartActionArgument {
} }
message StartActionResponse { message StartActionResponse {
string execution_uuid = 2; string execution_tracking_id = 2;
} }
message StartActionAndWaitRequest { message StartActionAndWaitRequest {
string action_name = 1; string action_id = 1;
} }
message StartActionAndWaitResponse { message StartActionAndWaitResponse {
LogEntry log_entry = 1; LogEntry log_entry = 1;
} }
message StartActionByAliasRequest { message StartActionByGetRequest {
string action_alias = 1; string action_id = 1;
} }
message StartActionByAliasResponse { message StartActionByGetResponse {
string execution_uuid = 2; string execution_tracking_id = 2;
} }
message StartActionByAliasAndWaitRequest { message StartActionByGetAndWaitRequest {
string action_alias = 1; string action_id = 1;
} }
message StartActionByAliasAndWaitResponse { message StartActionByGetAndWaitResponse {
LogEntry log_entry = 1; LogEntry log_entry = 1;
} }
@ -105,9 +104,9 @@ message LogEntry {
string user_class = 8; string user_class = 8;
string action_icon = 9; string action_icon = 9;
repeated string tags = 10; repeated string tags = 10;
string execution_uuid = 11; string execution_tracking_id = 11;
string datetime_finished = 12; string datetime_finished = 12;
string uuid = 13; string action_id = 13;
bool execution_started = 14; bool execution_started = 14;
bool execution_finished = 15; bool execution_finished = 15;
bool blocked = 16; bool blocked = 16;
@ -128,7 +127,7 @@ message ValidateArgumentTypeResponse {
} }
message WatchExecutionRequest { message WatchExecutionRequest {
string execution_uuid = 1; string execution_tracking_id = 1;
} }
message WatchExecutionUpdate { message WatchExecutionUpdate {
@ -136,7 +135,7 @@ message WatchExecutionUpdate {
} }
message ExecutionStatusRequest { message ExecutionStatusRequest {
string execution_uuid = 1; string execution_tracking_id = 1;
} }
message ExecutionStatusResponse { message ExecutionStatusResponse {
@ -155,6 +154,24 @@ message SosReportResponse {
string alert = 1; string alert = 1;
} }
message DumpVarsRequest {}
message DumpVarsResponse {
string alert = 1;
map<string, string> contents = 2;
}
message ActionEntityPair {
string action_title = 1;
string entity_prefix = 2;
}
message DumpPublicIdActionMapRequest {}
message DumpPublicIdActionMapResponse {
string alert = 1;
map<string, ActionEntityPair> contents = 2;
}
message GetReadyzRequest {} message GetReadyzRequest {}
message GetReadyzResponse { message GetReadyzResponse {
@ -182,15 +199,15 @@ service OliveTinApiService {
}; };
} }
rpc StartActionByAlias(StartActionByAliasRequest) returns (StartActionByAliasResponse) { rpc StartActionByGet(StartActionByGetRequest) returns (StartActionByGetResponse) {
option (google.api.http) = { option (google.api.http) = {
get: "/api/StartActionByAlias/{action_alias}" get: "/api/StartActionByGet/{action_id}"
}; };
} }
rpc StartActionByAliasAndWait(StartActionByAliasAndWaitRequest) returns (StartActionByAliasAndWaitResponse) { rpc StartActionByGetAndWait(StartActionByGetAndWaitRequest) returns (StartActionByGetAndWaitResponse) {
option (google.api.http) = { option (google.api.http) = {
get: "/api/StartActionByAliasAndWait/{action_alias}" get: "/api/StartActionByGetAndWait/{action_id}"
}; };
} }
@ -226,6 +243,18 @@ service OliveTinApiService {
}; };
} }
rpc DumpVars(DumpVarsRequest) returns (DumpVarsResponse) {
option (google.api.http) = {
get: "/api/DumpVars"
};
}
rpc DumpPublicIdActionMap(DumpPublicIdActionMapRequest) returns (DumpPublicIdActionMapResponse) {
option (google.api.http) = {
get: "/api/DumpPublicIdActionMap"
};
}
rpc GetReadyz(GetReadyzRequest) returns (GetReadyzResponse) { rpc GetReadyz(GetReadyzRequest) returns (GetReadyzResponse) {
option (google.api.http) = { option (google.api.http) = {
get: "/api/readyz" get: "/api/readyz"

View File

@ -5,6 +5,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/OliveTin/OliveTin/internal/entityfiles"
"github.com/OliveTin/OliveTin/internal/executor" "github.com/OliveTin/OliveTin/internal/executor"
grpcapi "github.com/OliveTin/OliveTin/internal/grpcapi" grpcapi "github.com/OliveTin/OliveTin/internal/grpcapi"
"github.com/OliveTin/OliveTin/internal/installationinfo" "github.com/OliveTin/OliveTin/internal/installationinfo"
@ -153,6 +154,8 @@ func main() {
go oncron.Schedule(cfg, executor) go oncron.Schedule(cfg, executor)
go onfileindir.WatchFilesInDirectory(cfg, executor) go onfileindir.WatchFilesInDirectory(cfg, executor)
go entityfiles.SetupEntityFileWatchers(cfg)
go updatecheck.StartUpdateChecker(version, commit, cfg, configDir) go updatecheck.StartUpdateChecker(version, commit, cfg, configDir)
go grpcapi.Start(cfg, executor) go grpcapi.Start(cfg, executor)

View File

@ -10,23 +10,41 @@ logLevel: "INFO"
# Actions (buttons) to show up on the WebUI: # Actions (buttons) to show up on the WebUI:
actions: actions:
# This is the most simple action, it just runs the command and flashes the
# button to indicate status.
#
# If you are running OliveTin in a container remember to pass through the
# docker socket! https://docs.olivetin.app/action-container-control.html
- title: Restart media container - title: Restart media container
shell: docker restart mediacontainer shell: docker restart mediacontainer
icon: box icon: box
entity: container
# This uses `popupOnStart: execution-dialog-stdout-only` to simply show just
# the command output.
- title: Check disk space - title: Check disk space
icon: disk icon: disk
shell: df -h /media shell: df -h /media
popupOnStart: execution-dialog-stdout-only popupOnStart: execution-dialog-stdout-only
# This uses `popupOnStart: execution-dialog` to show a dialog with more
# information about the command that was run.
- title: check dmesg logs - title: check dmesg logs
shell: dmesg | tail shell: dmesg | tail
icon: logs icon: logs
popupOnStart: execution-dialog popupOnStart: execution-dialog
# This uses `popupOnStart: execution-button` to display a mini button that
# links to the logs.
- title: date
shell: date
timeout: 6
icon: clock
popupOnStart: execution-button
# This will run a simple script that you create. # You are not limited to operating system commands, and of course you can run
# your own scripts. Here `maxConcurrent` stops the script running multiple
# times in parallel. There is also a timeout that will kill the command if it
# runs for too long.
- title: Run backup script - title: Run backup script
shell: /opt/backupScript.sh shell: /opt/backupScript.sh
shellAfterCompleted: "apprise -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ stdout }} '" shellAfterCompleted: "apprise -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ stdout }} '"
@ -35,14 +53,10 @@ actions:
icon: backup icon: backup
popupOnStart: execution-dialog popupOnStart: execution-dialog
- title: date # When you want to prompt users for input, that is when you should use
shell: date # `arguments` - this presents a popup dialog and asks for argument values.
timeout: 6 #
icon: clock # Docs: https://docs.olivetin.app/action-ping.html
popupOnStart: execution-button
# This will send 1 ping (-c 1)
# Docs: https://docs.olivetin.app/action-ping.html
- title: Ping host - title: Ping host
shell: ping {{ host }} -c {{ count }} shell: ping {{ host }} -c {{ count }}
icon: ping icon: ping
@ -60,31 +74,12 @@ actions:
default: 1 default: 1
description: How many times to do you want to ping? description: How many times to do you want to ping?
# OliveTin can run long-running jobs like Ansible playbooks. # OliveTin can control containers - docker is just a command line app.
# #
# For such jobs, you will need to install ansible-playbook on the host where # However, if you are running in a container you will need to do some setup,
# you are running OliveTin, or in the container. # see the docs below.
# #
# You probably want a much longer timeout as well (so that ansible completes). # Docs: https://docs.olivetin.app/action-container-control.html
- title: "Run Ansible Playbook"
icon: "&#x1F1E6"
shell: ansible-playbook -i /etc/hosts /root/myRepo/myPlaybook.yaml
timeout: 120
# Restart httpd on host "server1"
# Docs: https://docs.olivetin.app/action-ping.html
- title: restart httpd on server1
titleAlias: restart_httpd
icon: restart
timeout: 1
shell: ssh root@server1 'service httpd restart'
# OliveTin can control containers - docker is just a command line app.
#
# However, if you are running in a container you will need to do some setup,
# see the docs below.
#
# Docs: https://docs.olivetin.app/action-container-control.html
- title: Restart Docker Container - title: Restart Docker Container
icon: restart icon: restart
shell: docker restart {{ container }} shell: docker restart {{ container }}
@ -96,6 +91,10 @@ actions:
- value: traefik - value: traefik
- value: grafana - value: grafana
# There is a special `confirmation` argument to help against accidental clicks
# on "dangerous" actions.
#
# Docs: https://docs.olivetin.app/confirmation.html
- title: Delete old backups - title: Delete old backups
icon: ashtonished icon: ashtonished
shell: rm -rf /opt/oldBackups/ shell: rm -rf /opt/oldBackups/
@ -103,35 +102,154 @@ actions:
- type: confirmation - type: confirmation
title: Are you sure?! title: Are you sure?!
- title: Server1 Power Off
shell: echo "Power Off Server 1"
- title: Server2 Wake On LAN # Sometimes you want to run actions on other servers - don't overcomplicate
shell: echo "Sending Wake on LAN to Server 2" # it, just use SSH!
#
# Docs: https://docs.olivetin.app/action-ssh.html
# Docs: https://docs.olivetin.app/action-service.html
- title: restart httpd on server1
id: restart_httpd
icon: restart
timeout: 1
shell: ssh root@server1 'service httpd restart'
- title: Server2 Power Off # There are several built-in shortcuts for the `icon` option, but you
shell: echo "Power Off Server 2" # can also just specify any HTML, this includes any unicode character,
# or a <img = "..." /> link to a custom icon.
#
# Docs: https://docs.olivetin.app/icons.html
#
# Lots of people use OliveTin to easily execute ansible-playbooks. You
# probably want a much longer timeout as well (so that ansible completes).
#
# Docs: https://docs.olivetin.app/ansible-playbook.html
- title: "Run Ansible Playbook"
icon: '&#x1F1E6;'
shell: ansible-playbook -i /etc/hosts /root/myRepo/myPlaybook.yaml
timeout: 120
# The following actions are "dummy" actions, used in a Dashboard. As long as
# you have these referenced in a dashboard, they will not who up in the
# `actions` view.
- title: Ping hypervisor1
shell: echo "hypervisor1 online"
- title: Ping hypervisor2
shell: echo "hypervisor2 online"
- title: "{{ server.name }} Wake on Lan"
shell: echo "Sending Wake on LAN to {{ server.hostname }}"
entity: server
- title: "{{ server.name }} Power Off"
shell: "echo 'Power Off Server: {{ server.hostname }}'"
entity: server
- title: Ping All Servers - title: Ping All Servers
shell: echo "Ping all servers" shell: "echo 'Ping all servers'"
icon: ping icon: ping
- title: Start {{ container.Names }}
icon: box
shell: docker start {{ container.Names }}
entity: container
- title: Stop {{ container.Names }}
icon: box
shell: docker stop {{ container.Names }}
entity: container
# Lastly, you can hide actions from the web UI, this is useful for creating
# background helpers that execute only on startup or a cron, for updating
# entity files.
# - title: Update container entity file
# shell: 'docker ps -a --format json > /etc/OliveTin/containers.json'
# hidden: true
# execOnStartup: true
# execOnCron: '*/1 * * * *'
# An entity is something that exists - a "thing", like a VM, or a Container
# is an entity. OliveTin allows you to then dynamically generate actions based
# around these entities.
#
# This is really useful if you want to generate wake on lan or poweroff actions
# for `server` entities, for example.
#
# A very popular use case that entities were designed for was for `container`
# entities - in a similar way you could generate `start`, `stop`, and `restart`
# container actions.
#
# Entities are just loaded fome files on disk, OliveTin will also watch these
# files for updates while OliveTin is running, and update entities.
#
# Entities can have properties defined in those files, and those can be used
# in your configuration as variables. For example; `container.status`,
# or `vm.hostname`.
#
# Docs: http://docs.olivetin.app/entities.html
entities:
# YAML files are the default expected format, so you can use .yml or .yaml,
# or even .txt, as long as the file contains valid a valid yaml LIST, then it
# will load properly.
#
# Docs: https://docs.olivetin.app/entities.html
- file: /etc/OliveTin/servers.yaml
name: server
- file: /etc/OliveTin/containers.json
name: container
# Dashboards are a way of taking actions from the default "actions" view, and
# organizing them into groups - either into folders, or fieldsets.
#
# The only way to properly use entities, are to use them with a `fieldset` on
# a dashboard.
dashboards: dashboards:
# Top level items are dsahboards.
- title: My Servers - title: My Servers
contents: contents:
- title: Server Power Controls # The contents of a dashboard will try to look for an action with a
# matching title IF the `contents: ` property is empty.
- title: Ping All Servers
# If you create an item with some "contents:", OliveTin will show that as
# directory.
- title: Hypervisors
contents:
- title: Ping hypervisor1
- title: Ping hypervisor2
# If you specify `type: fieldset` and some `contents`, it will show your
# actions grouped together without a folder.
- type: fieldset
entity: server
title: 'Server: {{ server.hostname }}'
contents:
# By default OliveTin will look for an action with a matching title
# and put it on the dashboard.
#
# Fieldsets also support `type: display`, which can display arbitary
# text. This is useful for displaying things like a container's state.
- type: display
title: |
Hostname: <strong>{{ server.name }}</strong>
IP Address: <strong>{{ server.ip }}</strong>
# These are the actions (defined above) that we want on the dashboard.
- title: '{{ server.name }} Wake on Lan'
- title: '{{ server.name }} Power Off'
# This is the second dashboard.
- title: My Containers
contents:
- title: Container {{ container.Names }}
entity: container
type: fieldset type: fieldset
contents: contents:
- title: Server 1 - type: display
contents: title: |
- link: Server1 Power Off {{ container.Names }} <br /><br /><strong>{{ container.State }}</strong>
- title: 'Start {{ container.Names }}'
- title: Server 2 - title: 'Stop {{ container.Names }}'
contents:
- link: Server2 Wake On LAN
- link: Server2 Power Off
- title: Server Utilities
type: fieldset
contents:
- link: Ping All Servers

View File

@ -1,4 +1,4 @@
import process from 'node:process' import * as process from 'node:process'
import waitOn from 'wait-on' import waitOn from 'wait-on'
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'

View File

@ -1,34 +1,36 @@
import {expect} from 'chai'; import { describe, it, before, after } from 'mocha'
import {By} from 'selenium-webdriver'; import { expect } from 'chai'
import { By } from 'selenium-webdriver'
//import * as waitOn from 'wait-on'
describe('config: general', function () { describe('config: general', function () {
before(async function () { before(async function () {
await runner.start('general') await runner.start('general')
}); })
after(async () => { after(async () => {
await runner.stop() await runner.stop()
}); })
it('Page title', async function () { it('Page title', async function () {
await webdriver.get(runner.baseUrl()) await webdriver.get(runner.baseUrl())
let title = await webdriver.getTitle(); const title = await webdriver.getTitle()
expect(title).to.be.equal("OliveTin") expect(title).to.be.equal("OliveTin")
}) })
it('Footer contains promo', async function () { it('Footer contains promo', async function () {
let ftr = await webdriver.findElement(By.tagName('footer')).getText() const ftr = await webdriver.findElement(By.tagName('footer')).getText()
expect(ftr).to.contain("Documentation") expect(ftr).to.contain('Documentation')
}) })
it('Default buttons are rendered', async function() { it('Default buttons are rendered', async function() {
await webdriver.get(runner.baseUrl()) await webdriver.get(runner.baseUrl())
// await webdriver.manage().setTimeouts({ implicit: 2000 }); // await webdriver.manage().setTimeouts({ implicit: 2000 })
let buttons = await webdriver.findElement(By.id('root-group')).findElements(By.tagName('button')) const buttons = await webdriver.findElement(By.id('root-group')).findElements(By.tagName('button'))
expect(buttons).to.have.length(6) expect(buttons).to.have.length(6)
}) })

View File

@ -1,20 +1,21 @@
import { expect } from 'chai'; import { describe, it, before, after } from 'mocha'
import { expect } from 'chai'
import { By } from 'selenium-webdriver'; import { By } from 'selenium-webdriver'
describe('config: hiddenFooter', function () { describe('config: hiddenFooter', function () {
before(async function () { before(async function () {
await runner.start('hiddenFooter') await runner.start('hiddenFooter')
}); })
after(async () => { after(async () => {
await runner.stop() await runner.stop()
}); })
it('Check that footer is hidden', async () => { it('Check that footer is hidden', async () => {
await webdriver.get(runner.baseUrl()) await webdriver.get(runner.baseUrl())
let footer = await webdriver.findElement(By.tagName('footer')) const footer = await webdriver.findElement(By.tagName('footer'))
expect(await footer.isDisplayed()).to.be.false expect(await footer.isDisplayed()).to.be.false
}) })

View File

@ -1,5 +1,5 @@
import {expect} from 'chai'; import { expect } from 'chai'
import {By} from 'selenium-webdriver'; import { By } from 'selenium-webdriver'
describe('config: hiddenNav', function () { describe('config: hiddenNav', function () {
before(async function () { before(async function () {

View File

@ -1,3 +1,4 @@
import { describe, before, after } from 'mocha'
import { expect } from 'chai' import { expect } from 'chai'
import { By, until } from 'selenium-webdriver' import { By, until } from 'selenium-webdriver'
@ -14,9 +15,16 @@ describe('config: multipleDropdowns', function () {
await webdriver.get(runner.baseUrl()) await webdriver.get(runner.baseUrl())
await webdriver.manage().setTimeouts({ implicit: 2000 }) await webdriver.manage().setTimeouts({ implicit: 2000 })
const button = await webdriver.findElement(By.id('actionButton-bdc45101bbd12c1397557790d9f3e059')).findElement(By.tagName('button')) const buttons = await webdriver.findElements(By.tagName('button'))
let button = null
expect(button).to.not.be.undefined for (const b of buttons) {
if (await b.getAttribute('title') === 'Test multiple dropdowns') {
button = b
}
}
expect(button).to.not.be.null
await button.click() await button.click()

View File

@ -41,6 +41,10 @@ func IsAllowedExec(cfg *config.Config, user *AuthenticatedUser, action *config.A
// IsAllowedView checks if a User is allowed to view an Action // IsAllowedView checks if a User is allowed to view an Action
func IsAllowedView(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool { func IsAllowedView(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool {
if action.Hidden {
return false
}
for _, acl := range getRelevantAcls(cfg, action.Acls, user) { for _, acl := range getRelevantAcls(cfg, action.Acls, user) {
if acl.Permissions.View { if acl.Permissions.View {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
@ -110,7 +114,7 @@ func buildUserAcls(cfg *config.Config, user *AuthenticatedUser) {
} }
} }
func isACLRelevantToAction(cfg *config.Config, actionAcls []string, acl config.AccessControlList, user *AuthenticatedUser) bool { func isACLRelevantToAction(cfg *config.Config, actionAcls []string, acl *config.AccessControlList, user *AuthenticatedUser) bool {
if !slices.Contains(user.acls, acl.Name) { if !slices.Contains(user.acls, acl.Name) {
// If the user does not have this ACL, then it is not relevant // If the user does not have this ACL, then it is not relevant
@ -133,7 +137,7 @@ func getRelevantAcls(cfg *config.Config, actionAcls []string, user *Authenticate
for _, acl := range cfg.AccessControlLists { for _, acl := range cfg.AccessControlLists {
if isACLRelevantToAction(cfg, actionAcls, acl, user) { if isACLRelevantToAction(cfg, actionAcls, acl, user) {
ret = append(ret, &acl) ret = append(ret, acl)
} }
} }

View File

@ -5,14 +5,14 @@ package config
type Action struct { type Action struct {
ID string ID string
Title string Title string
TitleAlias string
Icon string Icon string
Shell string Shell string
ShellAfterCompleted string ShellAfterCompleted string
CSS map[string]string `mapstructure:"omitempty"` CSS map[string]string `mapstructure:"omitempty"`
Timeout int Timeout int
Acls []string Acls []string
Entity []string Entity string
Hidden bool
ExecOnStartup bool ExecOnStartup bool
ExecOnCron []string ExecOnCron []string
ExecOnFileCreatedInDir []string ExecOnFileCreatedInDir []string
@ -38,16 +38,6 @@ type ActionArgumentChoice struct {
Title string Title string
} }
// HelperAction is an action that is used normally to generate entities, it cannot be started manually.
type HelperAction struct {
Title string
Shell string
ExecOnStartup bool
ExecOnCron []string
ExecOnFileCreatedInDir []string
ExecOnFileChangedInDir []string
}
// Entity represents a "thing" that can have multiple actions associated with it. // Entity represents a "thing" that can have multiple actions associated with it.
// for example, a media player with a start and stop action. // for example, a media player with a start and stop action.
type EntityFile struct { type EntityFile struct {
@ -82,9 +72,9 @@ type Config struct {
ListenAddressGrpcActions string ListenAddressGrpcActions string
ExternalRestAddress string ExternalRestAddress string
LogLevel string LogLevel string
Actions []Action `mapstructure:"actions"` Actions []*Action `mapstructure:"actions"`
Entities []EntityFile `mapstructure:"entities"` Entities []*EntityFile `mapstructure:"entities"`
Dashboards []DashboardItem `mapstructure:"dashboards"` Dashboards []*DashboardComponent `mapstructure:"dashboards"`
CheckForUpdates bool CheckForUpdates bool
PageTitle string PageTitle string
ShowFooter bool ShowFooter bool
@ -98,19 +88,21 @@ type Config struct {
AuthHttpHeaderUsername string AuthHttpHeaderUsername string
AuthHttpHeaderUserGroup string AuthHttpHeaderUserGroup string
DefaultPermissions PermissionsList DefaultPermissions PermissionsList
AccessControlLists []AccessControlList AccessControlLists []*AccessControlList
WebUIDir string WebUIDir string
HelperActions []HelperAction `mapstructure:"helperActions"`
CronSupportForSeconds bool CronSupportForSeconds bool
SectionNavigationStyle string SectionNavigationStyle string
DefaultPopupOnStart string DefaultPopupOnStart string
InsecureAllowDumpVars bool
InsecureAllowDumpSos bool
InsecureAllowDumpActionMap bool
} }
type DashboardItem struct { type DashboardComponent struct {
Title string Title string
Type string Type string
Link string Entity string
Contents []DashboardItem Contents []DashboardComponent
} }
// DefaultConfig gets a new Config structure with sensible default values. // DefaultConfig gets a new Config structure with sensible default values.
@ -136,6 +128,9 @@ func DefaultConfig() *Config {
config.CronSupportForSeconds = false config.CronSupportForSeconds = false
config.SectionNavigationStyle = "sidebar" config.SectionNavigationStyle = "sidebar"
config.DefaultPopupOnStart = "nothing" config.DefaultPopupOnStart = "nothing"
config.InsecureAllowDumpVars = false
config.InsecureAllowDumpSos = false
config.InsecureAllowDumpActionMap = false
return &config return &config
} }

View File

@ -4,7 +4,7 @@ package config
func (cfg *Config) FindAction(actionTitle string) *Action { func (cfg *Config) FindAction(actionTitle string) *Action {
for _, action := range cfg.Actions { for _, action := range cfg.Actions {
if action.Title == actionTitle { if action.Title == actionTitle {
return &action return action
} }
} }
@ -36,7 +36,7 @@ func (action *Action) findArg(name string) *ActionArgument {
func (cfg *Config) FindAcl(aclTitle string) *AccessControlList { func (cfg *Config) FindAcl(aclTitle string) *AccessControlList {
for _, acl := range cfg.AccessControlLists { for _, acl := range cfg.AccessControlLists {
if acl.Name == aclTitle { if acl.Name == aclTitle {
return &acl return acl
} }
} }

View File

@ -8,11 +8,11 @@ import (
func TestFindAction(t *testing.T) { func TestFindAction(t *testing.T) {
c := DefaultConfig() c := DefaultConfig()
a1 := Action{} a1 := &Action{}
a1.Title = "a1" a1.Title = "a1"
c.Actions = append(c.Actions, a1) c.Actions = append(c.Actions, a1)
a2 := Action{ a2 := &Action{
Title: "a2", Title: "a2",
Arguments: []ActionArgument{ Arguments: []ActionArgument{
{ {
@ -35,7 +35,7 @@ func TestFindAction(t *testing.T) {
func TestFindAcl(t *testing.T) { func TestFindAcl(t *testing.T) {
c := DefaultConfig() c := DefaultConfig()
acl1 := AccessControlList{ acl1 := &AccessControlList{
Name: "Testing ACL", Name: "Testing ACL",
} }

View File

@ -1,7 +1,9 @@
package config package config
import ( import (
"github.com/google/uuid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"strings"
) )
// Sanitize will look for common configuration issues, and fix them. For example, // Sanitize will look for common configuration issues, and fix them. For example,
@ -28,6 +30,7 @@ func (action *Action) sanitize(cfg *Config) {
action.Timeout = 3 action.Timeout = 3
} }
action.ID = getActionID(action)
action.Icon = lookupHTMLIcon(action.Icon) action.Icon = lookupHTMLIcon(action.Icon)
action.PopupOnStart = sanitizePopupOnStart(action.PopupOnStart, cfg) action.PopupOnStart = sanitizePopupOnStart(action.PopupOnStart, cfg)
@ -40,6 +43,18 @@ func (action *Action) sanitize(cfg *Config) {
} }
} }
func getActionID(action *Action) string {
if action.ID == "" {
return uuid.NewString()
}
if strings.Contains(action.ID, "{{") {
log.Fatalf("Action IDs cannot contain variables")
}
return action.ID
}
func sanitizePopupOnStart(raw string, cfg *Config) string { func sanitizePopupOnStart(raw string, cfg *Config) string {
switch raw { switch raw {
case "execution-dialog": case "execution-dialog":

View File

@ -8,7 +8,7 @@ import (
func TestSanitizeConfig(t *testing.T) { func TestSanitizeConfig(t *testing.T) {
c := DefaultConfig() c := DefaultConfig()
a := Action{ a := &Action{
Title: "Mr Waffles", Title: "Mr Waffles",
Arguments: []ActionArgument{ Arguments: []ActionArgument{
{ {

View File

@ -0,0 +1,162 @@
package entityfiles
import (
"bytes"
"encoding/json"
"fmt"
config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
"github.com/fsnotify/fsnotify"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"io/ioutil"
"strings"
)
func SetupEntityFileWatchers(cfg *config.Config) {
for _, ef := range cfg.Entities {
go watch(ef.File, ef.Name)
loadEntityFile(ef.File, ef.Name)
}
}
func watch(file string, entityname string) {
log.WithFields(log.Fields{
"file": file,
"name": entityname,
}).Infof("Watching entity file")
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Errorf("Could not watch entity file: %v", err)
return
}
defer watcher.Close()
done := make(chan bool)
go func() {
for {
processEvent(watcher, file, entityname)
}
}()
err = watcher.Add(file)
if err != nil {
log.WithFields(log.Fields{
"file": file,
}).Errorf("Could not create entity watcher: %v", err)
}
<-done
}
func processEvent(watcher *fsnotify.Watcher, filename string, entityname string) {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
loadEntityFileIfWritten(&event, filename, entityname)
return
case err := <-watcher.Errors:
log.Errorf("Error in fsnotify: %v", err)
return
}
}
func loadEntityFileIfWritten(event *fsnotify.Event, filename string, entityname string) {
if event.Has(fsnotify.Remove) {
log.WithFields(log.Fields{
"file": filename,
}).Warnf("Entity file deleted! Will no longer be able to watch for changes!")
}
if event.Has(fsnotify.Write) {
loadEntityFile(filename, entityname)
}
}
func loadEntityFile(filename string, entityname string) {
if strings.HasSuffix(filename, ".json") {
loadEntityFileJson(filename, entityname)
} else {
loadEntityFileYaml(filename, entityname)
}
}
func loadEntityFileJson(filename string, entityname string) {
log.WithFields(log.Fields{
"file": filename,
"name": entityname,
}).Infof("Loading entity file with JSON format")
jfile, err := ioutil.ReadFile(filename)
if err != nil {
log.Errorf("ReadIn: %v", err)
return
}
data := make([]map[string]string, 0)
decoder := json.NewDecoder(bytes.NewReader(jfile))
for decoder.More() {
d := make(map[string]string)
err := decoder.Decode(&d)
if err != nil {
log.Errorf("%v", err)
return
}
data = append(data, d)
}
updateEvmFromFile(entityname, data)
}
func loadEntityFileYaml(filename string, entityname string) {
log.WithFields(log.Fields{
"file": filename,
"name": entityname,
}).Infof("Loading entity file with YAML format")
yfile, err := ioutil.ReadFile(filename)
if err != nil {
log.Errorf("ReadIn: %v", err)
return
}
data := make([]map[string]string, 1)
err = yaml.Unmarshal(yfile, &data)
if err != nil {
log.Errorf("Unmarshal: %v", err)
}
updateEvmFromFile(entityname, data)
}
func updateEvmFromFile(entityname string, data []map[string]string) {
count := len(data)
sv.Contents["entities."+entityname+".count"] = fmt.Sprintf("%v", count)
for i, mapp := range data {
prefix := "entities." + entityname + "." + fmt.Sprintf("%v", i)
for k, v := range mapp {
sv.Contents[prefix+"."+k] = v
}
}
}

View File

@ -2,6 +2,7 @@ package executor
import ( import (
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"errors" "errors"
@ -20,9 +21,9 @@ var (
} }
) )
func parseActionArguments(rawShellCommand string, values map[string]string, action *config.Action) (string, error) { func parseActionArguments(rawShellCommand string, values map[string]string, action *config.Action, actionTitle string, entityPrefix string) (string, error) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"actionTitle": action.Title, "actionTitle": actionTitle,
"cmd": rawShellCommand, "cmd": rawShellCommand,
}).Infof("Action parse args - Before") }).Infof("Action parse args - Before")
@ -51,8 +52,10 @@ func parseActionArguments(rawShellCommand string, values map[string]string, acti
rawShellCommand = strings.ReplaceAll(rawShellCommand, match[0], argValue) rawShellCommand = strings.ReplaceAll(rawShellCommand, match[0], argValue)
} }
rawShellCommand = sv.ReplaceEntityVars(entityPrefix, rawShellCommand)
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"actionTitle": action.Title, "actionTitle": actionTitle,
"cmd": rawShellCommand, "cmd": rawShellCommand,
}).Infof("Action parse args - After") }).Infof("Action parse args - After")

View File

@ -33,7 +33,7 @@ func TestArgumentNameNumbers(t *testing.T) {
"person1name": "Fred", "person1name": "Fred",
} }
out, err := parseActionArguments(a1.Shell, values, &a1) out, err := parseActionArguments(a1.Shell, values, &a1, a1.Title, "")
assert.Equal(t, "echo 'Tickling Fred'", out) assert.Equal(t, "echo 'Tickling Fred'", out)
assert.Nil(t, err) assert.Nil(t, err)
@ -53,7 +53,7 @@ func TestArgumentNotProvided(t *testing.T) {
values := map[string]string{} values := map[string]string{}
out, err := parseActionArguments(a1.Shell, values, &a1) out, err := parseActionArguments(a1.Shell, values, &a1, a1.Title, "")
assert.Equal(t, "", out) assert.Equal(t, "", out)
assert.Equal(t, err.Error(), "Required arg not provided: personName") assert.Equal(t, err.Error(), "Required arg not provided: personName")

View File

@ -3,6 +3,7 @@ package executor
import ( import (
acl "github.com/OliveTin/OliveTin/internal/acl" acl "github.com/OliveTin/OliveTin/internal/acl"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"bytes" "bytes"
@ -28,13 +29,15 @@ type Executor struct {
// ExecutionRequest is a request to execute an action. It's passed to an // ExecutionRequest is a request to execute an action. It's passed to an
// Executor. They're created from the grpcapi. // Executor. They're created from the grpcapi.
type ExecutionRequest struct { type ExecutionRequest struct {
ActionName string ActionTitle string
Action *config.Action Action *config.Action
Arguments map[string]string Arguments map[string]string
UUID string TrackingID string
Tags []string Tags []string
Cfg *config.Config Cfg *config.Config
AuthenticatedUser *acl.AuthenticatedUser AuthenticatedUser *acl.AuthenticatedUser
EntityPrefix string
logEntry *InternalLogEntry logEntry *InternalLogEntry
finalParsedCommand string finalParsedCommand string
executor *Executor executor *Executor
@ -44,18 +47,19 @@ type ExecutionRequest struct {
// state of execution (even if the command is not executed). It's designed to be // state of execution (even if the command is not executed). It's designed to be
// easily serializable. // easily serializable.
type InternalLogEntry struct { type InternalLogEntry struct {
DatetimeStarted string DatetimeStarted string
DatetimeFinished string DatetimeFinished string
Stdout string Stdout string
Stderr string Stderr string
StdoutBuffer io.ReadCloser StdoutBuffer io.ReadCloser
StderrBuffer io.ReadCloser StderrBuffer io.ReadCloser
TimedOut bool TimedOut bool
Blocked bool Blocked bool
ExitCode int32 ExitCode int32
Tags []string Tags []string
ExecutionStarted bool ExecutionStarted bool
ExecutionFinished bool ExecutionFinished bool
ExecutionTrackingID string
/* /*
The following 3 properties are obviously on Action normally, but it's useful The following 3 properties are obviously on Action normally, but it's useful
@ -64,7 +68,7 @@ type InternalLogEntry struct {
*/ */
ActionTitle string ActionTitle string
ActionIcon string ActionIcon string
UUID string ActionId string
} }
type executorStepFunc func(*ExecutionRequest) bool type executorStepFunc func(*ExecutionRequest) bool
@ -76,8 +80,7 @@ func DefaultExecutor() *Executor {
e.Logs = make(map[string]*InternalLogEntry) e.Logs = make(map[string]*InternalLogEntry)
e.chainOfCommand = []executorStepFunc{ e.chainOfCommand = []executorStepFunc{
stepLogRequested, stepRequestAction,
stepFindAction,
stepConcurrencyCheck, stepConcurrencyCheck,
stepACLCheck, stepACLCheck,
stepParseArgs, stepParseArgs,
@ -108,21 +111,19 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
// duplicate UUIDs (or just random strings), but this is the only way. // duplicate UUIDs (or just random strings), but this is the only way.
req.logEntry = &InternalLogEntry{ req.logEntry = &InternalLogEntry{
DatetimeStarted: time.Now().Format("2006-01-02 15:04:05"), DatetimeStarted: time.Now().Format("2006-01-02 15:04:05"),
ActionTitle: req.ActionName, ExecutionTrackingID: req.TrackingID,
UUID: req.UUID, Stdout: "",
Stdout: "", Stderr: "",
Stderr: "", ExitCode: -1337, // If an Action is not actually executed, this is the default exit code.
ExitCode: -1337, // If an Action is not actually executed, this is the default exit code. ExecutionStarted: false,
ExecutionStarted: false, ExecutionFinished: false,
ExecutionFinished: false, ActionId: "",
ActionTitle: "notfound",
ActionIcon: "&#x1f4a9;",
} }
e.Logs[req.UUID] = req.logEntry e.Logs[req.TrackingID] = req.logEntry
for _, listener := range e.listeners {
listener.OnExecutionStarted(req.ActionName)
}
wg := new(sync.WaitGroup) wg := new(sync.WaitGroup)
wg.Add(1) wg.Add(1)
@ -132,7 +133,7 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
defer wg.Done() defer wg.Done()
}() }()
return wg, req.UUID return wg, req.TrackingID
} }
func (e *Executor) execChain(req *ExecutionRequest) { func (e *Executor) execChain(req *ExecutionRequest) {
@ -153,7 +154,7 @@ func getConcurrentCount(req *ExecutionRequest) int {
concurrentCount := 0 concurrentCount := 0
for _, log := range req.executor.Logs { for _, log := range req.executor.Logs {
if log.ActionTitle == req.ActionName && !log.ExecutionFinished { if log.ActionId == req.Action.ID && !log.ExecutionFinished {
concurrentCount += 1 concurrentCount += 1
} }
} }
@ -169,7 +170,7 @@ func stepConcurrencyCheck(req *ExecutionRequest) bool {
msg := fmt.Sprintf("Blocked from executing. This would mean this action is running %d times concurrently, but this action has maxExecutions set to %d.", concurrentCount, req.Action.MaxConcurrent) msg := fmt.Sprintf("Blocked from executing. This would mean this action is running %d times concurrently, but this action has maxExecutions set to %d.", concurrentCount, req.Action.MaxConcurrent)
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"actionTitle": req.ActionName, "actionTitle": req.logEntry.ActionTitle,
}).Warnf(msg) }).Warnf(msg)
req.logEntry.Stdout = msg req.logEntry.Stdout = msg
@ -180,29 +181,6 @@ func stepConcurrencyCheck(req *ExecutionRequest) bool {
return true return true
} }
func stepFindAction(req *ExecutionRequest) bool {
if req.Action != nil {
return true
}
actualAction := req.Cfg.FindAction(req.ActionName)
if actualAction == nil {
log.WithFields(log.Fields{
"actionName": req.ActionName,
}).Warnf("Action not found")
req.logEntry.Stderr = "Action not found"
return false
}
req.Action = actualAction
req.logEntry.ActionIcon = actualAction.Icon
return true
}
func stepACLCheck(req *ExecutionRequest) bool { func stepACLCheck(req *ExecutionRequest) bool {
return acl.IsAllowedExec(req.Cfg, req.AuthenticatedUser, req.Action) return acl.IsAllowedExec(req.Cfg, req.AuthenticatedUser, req.Action)
} }
@ -210,7 +188,7 @@ func stepACLCheck(req *ExecutionRequest) bool {
func stepParseArgs(req *ExecutionRequest) bool { func stepParseArgs(req *ExecutionRequest) bool {
var err error var err error
req.finalParsedCommand, err = parseActionArguments(req.Action.Shell, req.Arguments, req.Action) req.finalParsedCommand, err = parseActionArguments(req.Action.Shell, req.Arguments, req.Action, req.logEntry.ActionTitle, req.EntityPrefix)
if err != nil { if err != nil {
req.logEntry.Stdout = err.Error() req.logEntry.Stdout = err.Error()
@ -223,9 +201,32 @@ func stepParseArgs(req *ExecutionRequest) bool {
return true return true
} }
func stepLogRequested(req *ExecutionRequest) bool { func stepRequestAction(req *ExecutionRequest) bool {
// The grpc API always tries to find the action by ID, but it may
if req.Action == nil {
log.WithFields(log.Fields{
"actionTitle": req.ActionTitle,
}).Infof("Action finding")
req.Action = req.Cfg.FindAction(req.ActionTitle)
if req.Action == nil {
log.WithFields(log.Fields{
"actionName": req.ActionTitle,
}).Warnf("Action requested, but not found")
req.logEntry.Stderr = "Action not found: " + req.ActionTitle
return false
}
}
req.logEntry.ActionTitle = sv.ReplaceEntityVars(req.EntityPrefix, req.Action.Title)
req.logEntry.ActionIcon = req.Action.Icon
req.logEntry.ActionId = req.Action.ID
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"actionTitle": req.ActionName, "actionTitle": req.logEntry.ActionTitle,
}).Infof("Action requested") }).Infof("Action requested")
return true return true
@ -233,7 +234,7 @@ func stepLogRequested(req *ExecutionRequest) bool {
func stepLogStart(req *ExecutionRequest) bool { func stepLogStart(req *ExecutionRequest) bool {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"actionTitle": req.Action.Title, "actionTitle": req.logEntry.ActionTitle,
"timeout": req.Action.Timeout, "timeout": req.Action.Timeout,
}).Infof("Action starting") }).Infof("Action starting")
@ -242,7 +243,7 @@ func stepLogStart(req *ExecutionRequest) bool {
func stepLogFinish(req *ExecutionRequest) bool { func stepLogFinish(req *ExecutionRequest) bool {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"actionTitle": req.Action.Title, "actionTitle": req.logEntry.ActionTitle,
"stdout": req.logEntry.Stdout, "stdout": req.logEntry.Stdout,
"stderr": req.logEntry.Stderr, "stderr": req.logEntry.Stderr,
"timedOut": req.logEntry.TimedOut, "timedOut": req.logEntry.TimedOut,
@ -322,7 +323,7 @@ func stepExecAfter(req *ExecutionRequest) bool {
"exitCode": fmt.Sprintf("%v", req.logEntry.ExitCode), "exitCode": fmt.Sprintf("%v", req.logEntry.ExitCode),
} }
finalParsedCommand, _ := parseActionArguments(req.Action.ShellAfterCompleted, args, req.Action) finalParsedCommand, _ := parseActionArguments(req.Action.ShellAfterCompleted, args, req.Action, req.logEntry.ActionTitle, req.EntityPrefix)
cmd := wrapCommandInShell(ctx, finalParsedCommand) cmd := wrapCommandInShell(ctx, finalParsedCommand)
cmd.Stdout = &stdout cmd.Stdout = &stdout

View File

@ -13,7 +13,7 @@ func testingExecutor() (*Executor, *config.Config) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
a1 := config.Action{ a1 := &config.Action{
Title: "Do some tickles", Title: "Do some tickles",
Shell: "echo 'Tickling {{ person }}'", Shell: "echo 'Tickling {{ person }}'",
Arguments: []config.ActionArgument{ Arguments: []config.ActionArgument{
@ -34,7 +34,7 @@ func TestCreateExecutorAndExec(t *testing.T) {
e, cfg := testingExecutor() e, cfg := testingExecutor()
req := ExecutionRequest{ req := ExecutionRequest{
ActionName: "Do some tickles", ActionTitle: "Do some tickles",
AuthenticatedUser: &acl.AuthenticatedUser{Username: "Mr Tickle"}, AuthenticatedUser: &acl.AuthenticatedUser{Username: "Mr Tickle"},
Cfg: cfg, Cfg: cfg,
Arguments: map[string]string{ Arguments: map[string]string{
@ -54,9 +54,9 @@ func TestExecNonExistant(t *testing.T) {
e, cfg := testingExecutor() e, cfg := testingExecutor()
req := ExecutionRequest{ req := ExecutionRequest{
ActionName: "Waffles", ActionTitle: "Waffles",
logEntry: &InternalLogEntry{}, logEntry: &InternalLogEntry{},
Cfg: cfg, Cfg: cfg,
} }
wg, _ := e.ExecRequest(&req) wg, _ := e.ExecRequest(&req)
@ -67,7 +67,7 @@ func TestExecNonExistant(t *testing.T) {
} }
func TestArgumentNameCamelCase(t *testing.T) { func TestArgumentNameCamelCase(t *testing.T) {
a1 := config.Action{ a1 := &config.Action{
Title: "Do some tickles", Title: "Do some tickles",
Shell: "echo 'Tickling {{ personName }}'", Shell: "echo 'Tickling {{ personName }}'",
Arguments: []config.ActionArgument{ Arguments: []config.ActionArgument{
@ -82,14 +82,14 @@ func TestArgumentNameCamelCase(t *testing.T) {
"personName": "Fred", "personName": "Fred",
} }
out, err := parseActionArguments(a1.Shell, values, &a1) out, err := parseActionArguments(a1.Shell, values, a1, a1.Title, "")
assert.Equal(t, "echo 'Tickling Fred'", out) assert.Equal(t, "echo 'Tickling Fred'", out)
assert.Nil(t, err) assert.Nil(t, err)
} }
func TestArgumentNameSnakeCase(t *testing.T) { func TestArgumentNameSnakeCase(t *testing.T) {
a1 := config.Action{ a1 := &config.Action{
Title: "Do some tickles", Title: "Do some tickles",
Shell: "echo 'Tickling {{ person_name }}'", Shell: "echo 'Tickling {{ person_name }}'",
Arguments: []config.ActionArgument{ Arguments: []config.ActionArgument{
@ -104,7 +104,7 @@ func TestArgumentNameSnakeCase(t *testing.T) {
"person_name": "Fred", "person_name": "Fred",
} }
out, err := parseActionArguments(a1.Shell, values, &a1) out, err := parseActionArguments(a1.Shell, values, a1, a1.Title, "")
assert.Equal(t, "echo 'Tickling Fred'", out) assert.Equal(t, "echo 'Tickling Fred'", out)
assert.Nil(t, err) assert.Nil(t, err)

View File

@ -15,6 +15,7 @@ import (
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
executor "github.com/OliveTin/OliveTin/internal/executor" executor "github.com/OliveTin/OliveTin/internal/executor"
installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo" installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
) )
var ( var (
@ -35,18 +36,21 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest)
args[arg.Name] = arg.Value args[arg.Name] = arg.Value
} }
pair, _ := publicActionIdToActionMap[req.ActionId]
execReq := executor.ExecutionRequest{ execReq := executor.ExecutionRequest{
ActionName: req.ActionName, Action: pair.Action,
UUID: req.Uuid, EntityPrefix: pair.EntityPrefix,
TrackingID: req.UniqueTrackingId,
Arguments: args, Arguments: args,
AuthenticatedUser: acl.UserFromContext(ctx, cfg), AuthenticatedUser: acl.UserFromContext(ctx, cfg),
Cfg: cfg, Cfg: cfg,
} }
_, uuid := api.executor.ExecRequest(&execReq) api.executor.ExecRequest(&execReq)
return &pb.StartActionResponse{ return &pb.StartActionResponse{
ExecutionUuid: uuid, ExecutionTrackingId: req.UniqueTrackingId,
}, nil }, nil
} }
@ -54,8 +58,8 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *pb.StartActionA
args := make(map[string]string) args := make(map[string]string)
execReq := executor.ExecutionRequest{ execReq := executor.ExecutionRequest{
ActionName: req.ActionName, Action: findActionByPublicID(req.ActionId),
UUID: uuid.NewString(), TrackingID: uuid.NewString(),
Arguments: args, Arguments: args,
AuthenticatedUser: acl.UserFromContext(ctx, cfg), AuthenticatedUser: acl.UserFromContext(ctx, cfg),
Cfg: cfg, Cfg: cfg,
@ -64,7 +68,7 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *pb.StartActionA
wg, _ := api.executor.ExecRequest(&execReq) wg, _ := api.executor.ExecRequest(&execReq)
wg.Wait() wg.Wait()
internalLogEntry, ok := api.executor.Logs[execReq.UUID] internalLogEntry, ok := api.executor.Logs[execReq.TrackingID]
if ok { if ok {
return &pb.StartActionAndWaitResponse{ return &pb.StartActionAndWaitResponse{
@ -75,67 +79,42 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *pb.StartActionA
} }
} }
func (api *oliveTinAPI) StartActionByAlias(ctx ctx.Context, req *pb.StartActionByAliasRequest) (*pb.StartActionByAliasResponse, error) { func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *pb.StartActionByGetRequest) (*pb.StartActionByGetResponse, error) {
args := make(map[string]string) args := make(map[string]string)
action := findActionByAlias(req.ActionAlias)
if action == nil {
log.Warnf("ByAlias action alias not found: %v, cannot start execution.", req.ActionAlias)
return &pb.StartActionByAliasResponse{
ExecutionUuid: "",
}, errors.New("ByAlias action alias not found")
}
execReq := executor.ExecutionRequest{ execReq := executor.ExecutionRequest{
ActionName: action.Title, Action: findActionByPublicID(req.ActionId),
Action: action, TrackingID: uuid.NewString(),
UUID: uuid.NewString(), Arguments: args,
Arguments: args, AuthenticatedUser: acl.UserFromContext(ctx, cfg),
AuthenticatedUser: &acl.AuthenticatedUser{ Cfg: cfg,
Username: "webhook",
Usergroup: "webhook",
},
Cfg: cfg,
} }
_, uuid := api.executor.ExecRequest(&execReq) _, uniqueTrackingId := api.executor.ExecRequest(&execReq)
return &pb.StartActionByAliasResponse{ return &pb.StartActionByGetResponse{
ExecutionUuid: uuid, ExecutionTrackingId: uniqueTrackingId,
}, nil }, nil
} }
func (api *oliveTinAPI) StartActionByAliasAndWait(ctx ctx.Context, req *pb.StartActionByAliasAndWaitRequest) (*pb.StartActionByAliasAndWaitResponse, error) { func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *pb.StartActionByGetAndWaitRequest) (*pb.StartActionByGetAndWaitResponse, error) {
args := make(map[string]string) args := make(map[string]string)
action := findActionByAlias(req.ActionAlias)
if action == nil {
log.Warnf("ByAlias action alias not found: %v, cannot start execution.", req.ActionAlias)
return &pb.StartActionByAliasAndWaitResponse{}, errors.New("ByAlias action alias not found")
}
execReq := executor.ExecutionRequest{ execReq := executor.ExecutionRequest{
ActionName: action.Title, Action: findActionByPublicID(req.ActionId),
Action: action, TrackingID: uuid.NewString(),
UUID: uuid.NewString(), Arguments: args,
Arguments: args, AuthenticatedUser: acl.UserFromContext(ctx, cfg),
AuthenticatedUser: &acl.AuthenticatedUser{ Cfg: cfg,
Username: "webhook",
Usergroup: "webhook",
},
Cfg: cfg,
} }
wg, _ := api.executor.ExecRequest(&execReq) wg, _ := api.executor.ExecRequest(&execReq)
wg.Wait() wg.Wait()
internalLogEntry, ok := api.executor.Logs[execReq.UUID] internalLogEntry, ok := api.executor.Logs[execReq.TrackingID]
if ok { if ok {
return &pb.StartActionByAliasAndWaitResponse{ return &pb.StartActionByGetAndWaitResponse{
LogEntry: internalLogEntryToPb(internalLogEntry), LogEntry: internalLogEntryToPb(internalLogEntry),
}, nil }, nil
} else { } else {
@ -145,26 +124,27 @@ func (api *oliveTinAPI) StartActionByAliasAndWait(ctx ctx.Context, req *pb.Start
func internalLogEntryToPb(logEntry *executor.InternalLogEntry) *pb.LogEntry { func internalLogEntryToPb(logEntry *executor.InternalLogEntry) *pb.LogEntry {
return &pb.LogEntry{ return &pb.LogEntry{
ActionTitle: logEntry.ActionTitle, ActionTitle: logEntry.ActionTitle,
ActionIcon: logEntry.ActionIcon, ActionIcon: logEntry.ActionIcon,
DatetimeStarted: logEntry.DatetimeStarted, ActionId: logEntry.ActionId,
DatetimeFinished: logEntry.DatetimeFinished, DatetimeStarted: logEntry.DatetimeStarted,
Stdout: logEntry.Stdout, DatetimeFinished: logEntry.DatetimeFinished,
Stderr: logEntry.Stderr, Stdout: logEntry.Stdout,
TimedOut: logEntry.TimedOut, Stderr: logEntry.Stderr,
Blocked: logEntry.Blocked, TimedOut: logEntry.TimedOut,
ExitCode: logEntry.ExitCode, Blocked: logEntry.Blocked,
Tags: logEntry.Tags, ExitCode: logEntry.ExitCode,
ExecutionUuid: logEntry.UUID, Tags: logEntry.Tags,
ExecutionStarted: logEntry.ExecutionStarted, ExecutionTrackingId: logEntry.ExecutionTrackingID,
ExecutionFinished: logEntry.ExecutionFinished, ExecutionStarted: logEntry.ExecutionStarted,
ExecutionFinished: logEntry.ExecutionFinished,
} }
} }
func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *pb.ExecutionStatusRequest) (*pb.ExecutionStatusResponse, error) { func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *pb.ExecutionStatusRequest) (*pb.ExecutionStatusResponse, error) {
res := &pb.ExecutionStatusResponse{} res := &pb.ExecutionStatusResponse{}
logEntry, ok := api.executor.Logs[req.ExecutionUuid] logEntry, ok := api.executor.Logs[req.ExecutionTrackingId]
if !ok { if !ok {
return res, nil return res, nil
@ -224,9 +204,9 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.Ge
// TODO Limit to 10 entries or something to prevent browser lag. // TODO Limit to 10 entries or something to prevent browser lag.
for uuid, logEntry := range api.executor.Logs { for trackingId, logEntry := range api.executor.Logs {
pbLogEntry := internalLogEntryToPb(logEntry) pbLogEntry := internalLogEntryToPb(logEntry)
pbLogEntry.ExecutionUuid = uuid pbLogEntry.ExecutionTrackingId = trackingId
ret.Logs = append(ret.Logs, pbLogEntry) ret.Logs = append(ret.Logs, pbLogEntry)
} }
@ -272,11 +252,53 @@ func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *pb.WhoAmIRequest) (*pb.WhoA
} }
func (api *oliveTinAPI) SosReport(ctx ctx.Context, req *pb.SosReportRequest) (*pb.SosReportResponse, error) { func (api *oliveTinAPI) SosReport(ctx ctx.Context, req *pb.SosReportRequest) (*pb.SosReportResponse, error) {
res := &pb.SosReportResponse{ sos := installationinfo.GetSosReport()
Alert: "Your SOS Report has been logged to OliveTin logs.",
res := &pb.SosReportResponse{}
if cfg.InsecureAllowDumpSos {
res.Alert = sos
} else {
res.Alert = "Your SOS Report has been logged to OliveTin logs."
log.Info(sos)
} }
log.Infof("\n" + installationinfo.GetSosReport()) return res, nil
}
func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *pb.DumpVarsRequest) (*pb.DumpVarsResponse, error) {
res := &pb.DumpVarsResponse{}
if !cfg.InsecureAllowDumpVars {
res.Alert = "Dumping variables is not allowed by default because it is insecure."
return res, nil
}
res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpVars = false again after you don't need it anymore"
res.Contents = sv.Contents
return res, nil
}
func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *pb.DumpPublicIdActionMapRequest) (*pb.DumpPublicIdActionMapResponse, error) {
res := &pb.DumpPublicIdActionMapResponse{}
res.Contents = make(map[string]*pb.ActionEntityPair)
if !cfg.InsecureAllowDumpActionMap {
res.Alert = "Dumping Public IDs is disallowed."
return res, nil
}
for k, v := range publicActionIdToActionMap {
res.Contents[k] = &pb.ActionEntityPair{
ActionTitle: v.Action.Title,
EntityPrefix: v.EntityPrefix,
}
}
res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
return res, nil return res, nil
} }

View File

@ -6,29 +6,83 @@ import (
pb "github.com/OliveTin/OliveTin/gen/grpc" pb "github.com/OliveTin/OliveTin/gen/grpc"
acl "github.com/OliveTin/OliveTin/internal/acl" acl "github.com/OliveTin/OliveTin/internal/acl"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
log "github.com/sirupsen/logrus"
"strconv"
) )
func actionsCfgToPb(cfgActions []config.Action, user *acl.AuthenticatedUser) *pb.GetDashboardComponentsResponse { type ActionWithEntity struct {
Action *config.Action
EntityPrefix string
}
var publicActionIdToActionMap map[string]ActionWithEntity
func init() {
publicActionIdToActionMap = make(map[string]ActionWithEntity)
}
func actionsCfgToPb(cfgActions []*config.Action, user *acl.AuthenticatedUser) *pb.GetDashboardComponentsResponse {
res := &pb.GetDashboardComponentsResponse{} res := &pb.GetDashboardComponentsResponse{}
for _, action := range cfgActions { for _, action := range cfgActions {
if !acl.IsAllowedView(cfg, user, &action) { if !acl.IsAllowedView(cfg, user, action) {
continue continue
} }
btn := actionCfgToPb(action, user) if action.Entity != "" {
res.Actions = append(res.Actions, btn) res.Actions = append(res.Actions, buildActionEntities(action.Entity, action)...)
} else {
btn := actionCfgToPb(action, user)
res.Actions = append(res.Actions, btn)
}
} }
return res return res
} }
func actionCfgToPb(action config.Action, user *acl.AuthenticatedUser) *pb.Action { func buildActionEntities(entityTitle string, tpl *config.Action) []*pb.Action {
ret := make([]*pb.Action, 0)
entityCount, _ := strconv.Atoi(sv.Get("entities." + entityTitle + ".count"))
for i := 0; i < entityCount; i++ {
ret = append(ret, buildEntityAction(tpl, entityTitle, i))
}
return ret
}
func buildEntityAction(tpl *config.Action, entityTitle string, entityIndex int) *pb.Action {
prefix := getEntityPrefix(entityTitle, entityIndex)
virtualActionId := createPublicID(tpl, prefix)
publicActionIdToActionMap[virtualActionId] = ActionWithEntity{
Action: tpl,
EntityPrefix: prefix,
}
return &pb.Action{
Id: virtualActionId,
Title: sv.ReplaceEntityVars(prefix, tpl.Title),
Icon: tpl.Icon,
}
}
func actionCfgToPb(action *config.Action, user *acl.AuthenticatedUser) *pb.Action {
virtualActionId := createPublicID(action, "")
publicActionIdToActionMap[virtualActionId] = ActionWithEntity{
Action: action,
EntityPrefix: "noent",
}
btn := pb.Action{ btn := pb.Action{
Id: fmt.Sprintf("%x", md5.Sum([]byte(action.Title))), Id: virtualActionId,
Title: action.Title, Title: action.Title,
Icon: action.Icon, Icon: action.Icon,
CanExec: acl.IsAllowedExec(cfg, user, &action), CanExec: acl.IsAllowedExec(cfg, user, action),
PopupOnStart: action.PopupOnStart, PopupOnStart: action.PopupOnStart,
} }
@ -63,13 +117,16 @@ func buildChoices(choices []config.ActionArgumentChoice) []*pb.ActionArgumentCho
return ret return ret
} }
func findActionByAlias(alias string) *config.Action { func createPublicID(action *config.Action, entityPrefix string) string {
for _, action := range cfg.Actions { return fmt.Sprintf("%x", md5.Sum([]byte(action.ID+"."+entityPrefix)))
if action.TitleAlias != "" { }
if action.TitleAlias == alias {
return &action func findActionByPublicID(id string) *config.Action {
} pair, found := publicActionIdToActionMap[id]
}
if found {
log.Infof("findPublic %v, %v", id, pair.Action.ID)
return pair.Action
} }
return nil return nil

View File

@ -5,34 +5,29 @@ import (
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
) )
func dashboardCfgToPb(res *pb.GetDashboardComponentsResponse, dashboards []config.DashboardItem) { func dashboardCfgToPb(res *pb.GetDashboardComponentsResponse, dashboards []*config.DashboardComponent) {
for _, dashboard := range dashboards { for _, dashboard := range dashboards {
res.Dashboards = append(res.Dashboards, &pb.DashboardItem{ res.Dashboards = append(res.Dashboards, &pb.DashboardComponent{
Type: "dashboard", Type: "dashboard",
Title: dashboard.Title, Title: dashboard.Title,
Contents: getDashboardContents(&dashboard), Contents: getDashboardComponentContents(dashboard),
}) })
} }
} }
func getDashboardContents(dashboard *config.DashboardItem) []*pb.DashboardItem { func getDashboardComponentContents(dashboard *config.DashboardComponent) []*pb.DashboardComponent {
ret := make([]*pb.DashboardItem, 0) ret := make([]*pb.DashboardComponent, 0)
for _, subitem := range dashboard.Contents { for _, subitem := range dashboard.Contents {
newitem := &pb.DashboardItem{ if subitem.Type == "fieldset" && subitem.Entity != "" {
Title: subitem.Title, ret = append(ret, buildEntityFieldsets(subitem.Entity, &subitem)...)
Type: subitem.Type, continue
} }
if len(subitem.Contents) > 0 { newitem := &pb.DashboardComponent{
if newitem.Type != "fieldset" { Title: subitem.Title,
newitem.Type = "directory" Type: getDashboardComponentType(&subitem),
} Contents: getDashboardComponentContents(&subitem),
newitem.Contents = getDashboardContents(&subitem)
} else {
newitem.Type = "link"
newitem.Link = subitem.Link
} }
ret = append(ret, newitem) ret = append(ret, newitem)
@ -40,3 +35,17 @@ func getDashboardContents(dashboard *config.DashboardItem) []*pb.DashboardItem {
return ret return ret
} }
func getDashboardComponentType(item *config.DashboardComponent) string {
if len(item.Contents) > 0 {
if item.Type != "fieldset" {
return "directory"
}
return "fieldset"
} else if item.Type == "display" {
return "display"
}
return "link"
}

View File

@ -0,0 +1,56 @@
package grpcapi
import (
pb "github.com/OliveTin/OliveTin/gen/grpc"
config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
"fmt"
"strconv"
)
func getEntityPrefix(entityTitle string, entityIndex int) string {
return "entities." + entityTitle + "." + fmt.Sprintf("%v", entityIndex)
}
func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent) []*pb.DashboardComponent {
ret := make([]*pb.DashboardComponent, 0)
entityCount, _ := strconv.Atoi(sv.Get("entities." + entityTitle + ".count"))
for i := 0; i < entityCount; i++ {
ret = append(ret, buildEntityFieldset(tpl, entityTitle, i))
}
return ret
}
func buildEntityFieldset(tpl *config.DashboardComponent, entityTitle string, entityIndex int) *pb.DashboardComponent {
prefix := getEntityPrefix(entityTitle, entityIndex)
return &pb.DashboardComponent{
Title: sv.ReplaceEntityVars(prefix, tpl.Title),
Type: "fieldset",
Contents: buildEntityFieldsetContents(tpl.Contents, prefix),
}
}
func buildEntityFieldsetContents(contents []config.DashboardComponent, prefix string) []*pb.DashboardComponent {
ret := make([]*pb.DashboardComponent, 0)
for _, subitem := range contents {
clone := &pb.DashboardComponent{}
if subitem.Type == "" || subitem.Type == "link" {
clone.Type = "link"
clone.Title = sv.ReplaceEntityVars(prefix, subitem.Title)
} else {
clone.Title = sv.ReplaceEntityVars(prefix, subitem.Title)
clone.Type = subitem.Type
}
ret = append(ret, clone)
}
return ret
}

View File

@ -57,8 +57,9 @@ func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*gr
func TestGetActionsAndStart(t *testing.T) { func TestGetActionsAndStart(t *testing.T) {
cfg = config.DefaultConfig() cfg = config.DefaultConfig()
btn1 := config.Action{} btn1 := &config.Action{}
btn1.Title = "blat" btn1.Title = "blat"
btn1.ID = "blat"
btn1.Shell = "echo 'test'" btn1.Shell = "echo 'test'"
cfg.Actions = append(cfg.Actions, btn1) cfg.Actions = append(cfg.Actions, btn1)
@ -76,7 +77,7 @@ func TestGetActionsAndStart(t *testing.T) {
log.Printf("Response: %+v", respGb) log.Printf("Response: %+v", respGb)
respSa, err := client.StartAction(context.Background(), &pb.StartActionRequest{ActionName: "blat"}) respSa, err := client.StartAction(context.Background(), &pb.StartActionRequest{ActionId: "blat"})
assert.Nil(t, err, "Empty err after start action") assert.Nil(t, err, "Empty err after start action")
assert.NotNil(t, respSa, "Empty err after start action") assert.NotNil(t, respSa, "Empty err after start action")

View File

@ -0,0 +1,16 @@
package installationinfo
import (
"fmt"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
)
func init() {
sv.Contents["OliveTin.build.commit"] = Build.Commit
sv.Contents["OliveTin.build.version"] = Build.Version
sv.Contents["OliveTin.build.date"] = Build.Date
sv.Contents["OliveTin.runtime.os"] = Runtime.OS
sv.Contents["OliveTin.runtime.os.pretty"] = Runtime.OSReleasePrettyName
sv.Contents["OliveTin.runtime.arch"] = Runtime.Arch
sv.Contents["OliveTin.runtime.incontainer"] = fmt.Sprintf("%v", Runtime.InContainer)
}

View File

@ -26,7 +26,7 @@ func Schedule(cfg *config.Config, ex *executor.Executor) {
scheduler.Start() scheduler.Start()
} }
func scheduleAction(cfg *config.Config, scheduler *cron.Cron, cronline string, ex *executor.Executor, action config.Action) { func scheduleAction(cfg *config.Config, scheduler *cron.Cron, cronline string, ex *executor.Executor, action *config.Action) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"action": action.Title, "action": action.Title,
"cronline": cronline, "cronline": cronline,
@ -34,9 +34,9 @@ func scheduleAction(cfg *config.Config, scheduler *cron.Cron, cronline string, e
_, err := scheduler.AddFunc(cronline, func() { _, err := scheduler.AddFunc(cronline, func() {
req := &executor.ExecutionRequest{ req := &executor.ExecutionRequest{
ActionName: action.Title, ActionTitle: action.Title,
Cfg: cfg, Cfg: cfg,
Tags: []string{"cron"}, Tags: []string{"cron"},
AuthenticatedUser: &acl.AuthenticatedUser{ AuthenticatedUser: &acl.AuthenticatedUser{
Username: "cron", Username: "cron",
}, },

View File

@ -20,7 +20,7 @@ func WatchFilesInDirectory(cfg *config.Config, ex *executor.Executor) {
} }
} }
func watch(directory string, action config.Action, cfg *config.Config, ex *executor.Executor, eventType fsnotify.Op) { func watch(directory string, action *config.Action, cfg *config.Config, ex *executor.Executor, eventType fsnotify.Op) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"dir": directory, "dir": directory,
"eventType": eventType, "eventType": eventType,
@ -52,7 +52,7 @@ func watch(directory string, action config.Action, cfg *config.Config, ex *execu
<-done <-done
} }
func processEvent(watcher *fsnotify.Watcher, action config.Action, cfg *config.Config, ex *executor.Executor, eventType fsnotify.Op) { func processEvent(watcher *fsnotify.Watcher, action *config.Action, cfg *config.Config, ex *executor.Executor, eventType fsnotify.Op) {
select { select {
case event, ok := <-watcher.Events: case event, ok := <-watcher.Events:
if !ok { if !ok {
@ -60,18 +60,19 @@ func processEvent(watcher *fsnotify.Watcher, action config.Action, cfg *config.C
} }
checkEvent(&event, action, cfg, ex, eventType) checkEvent(&event, action, cfg, ex, eventType)
break
case err := <-watcher.Errors: case err := <-watcher.Errors:
log.Errorf("Error in fsnotify: %v", err) log.Errorf("Error in fsnotify: %v", err)
return return
} }
} }
func checkEvent(event *fsnotify.Event, action config.Action, cfg *config.Config, ex *executor.Executor, eventType fsnotify.Op) { func checkEvent(event *fsnotify.Event, action *config.Action, cfg *config.Config, ex *executor.Executor, eventType fsnotify.Op) {
if event.Has(eventType) { if event.Has(eventType) {
req := &executor.ExecutionRequest{ req := &executor.ExecutionRequest{
ActionName: action.Title, ActionTitle: action.Title,
Cfg: cfg, Cfg: cfg,
Tags: []string{"fileindir"}, Tags: []string{"fileindir"},
Arguments: map[string]string{ Arguments: map[string]string{
"filename": event.Name, "filename": event.Name,
}, },

View File

@ -19,7 +19,7 @@ func Execute(cfg *config.Config, ex *executor.Executor) {
}).Infof("Startup action") }).Infof("Startup action")
req := &executor.ExecutionRequest{ req := &executor.ExecutionRequest{
ActionName: action.Title, ActionTitle: action.Title,
Arguments: nil, Arguments: nil,
Cfg: cfg, Cfg: cfg,
Tags: []string{"startup"}, Tags: []string{"startup"},

View File

@ -0,0 +1,29 @@
package stringvariables
import (
"regexp"
"strings"
// log "github.com/sirupsen/logrus"
)
var r *regexp.Regexp
func init() {
r = regexp.MustCompile("{{ *?([a-zA-Z0-9_]+)\\.([a-zA-Z0-9_]+) *?}}")
}
func ReplaceEntityVars(prefix string, source string) string {
matches := r.FindAllStringSubmatch(source, -1)
for _, matches := range matches {
if len(matches) == 3 {
property := matches[2]
val := Get(prefix + "." + property)
source = strings.Replace(source, matches[0], val, 1)
}
}
return source
}

View File

@ -0,0 +1,26 @@
/**
* The ephemeralvariablemap is used "only" for variable substitution in config
* titles, shell arguments, etc, in the foorm of {{ key }}, like Jinja2.
*
* OliveTin itself really only ever "writes" to this map, mostly by loading
* EntityFiles, and the only form of "reading" is for the variable substitution
* in configs.
*/
package stringvariables
var Contents map[string]string
func init() {
Contents = make(map[string]string)
}
func Get(key string) string {
v, ok := Contents[key]
if !ok {
return ""
} else {
return v
}
}

View File

@ -56,19 +56,20 @@ func checkOriginPermissive(r *http.Request) bool {
func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.InternalLogEntry) { func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.InternalLogEntry) {
le := &pb.LogEntry{ le := &pb.LogEntry{
ActionTitle: logEntry.ActionTitle, ActionTitle: logEntry.ActionTitle,
ActionIcon: logEntry.ActionIcon, ActionIcon: logEntry.ActionIcon,
DatetimeStarted: logEntry.DatetimeStarted, ActionId: logEntry.ActionId,
DatetimeFinished: logEntry.DatetimeFinished, DatetimeStarted: logEntry.DatetimeStarted,
Stdout: logEntry.Stdout, DatetimeFinished: logEntry.DatetimeFinished,
Stderr: logEntry.Stderr, Stdout: logEntry.Stdout,
TimedOut: logEntry.TimedOut, Stderr: logEntry.Stderr,
Blocked: logEntry.Blocked, TimedOut: logEntry.TimedOut,
ExitCode: logEntry.ExitCode, Blocked: logEntry.Blocked,
Tags: logEntry.Tags, ExitCode: logEntry.ExitCode,
Uuid: logEntry.UUID, Tags: logEntry.Tags,
ExecutionStarted: logEntry.ExecutionStarted, ExecutionTrackingId: logEntry.ExecutionTrackingID,
ExecutionFinished: logEntry.ExecutionFinished, ExecutionStarted: logEntry.ExecutionStarted,
ExecutionFinished: logEntry.ExecutionFinished,
} }
broadcast("ExecutionFinished", le) broadcast("ExecutionFinished", le)

View File

@ -0,0 +1,2 @@
{"Command":"\"/opt/entrypoint.sh\"","CreatedAt":"2024-02-08 15:27:42 +0000 GMT","ID":"4bafe6f9f956","Image":"fedora","Labels":"?","LocalVolumes":"0","Mounts":"","Names":"media-indexer","Networks":"bridge","Ports":"","RunningFor":"13 days ago","Size":"0B","State":"exited","Status":"Exited (128) 13 days ago"}
{"Command":"\"/opt/entrypoint.sh\"","CreatedAt":"2023-12-17 20:58:03 +0000 GMT","ID":"d25f37c49c35","Image":"fedora","Labels":"?","LocalVolumes":"0","Mounts":"","Names":"game-server","Networks":"bridge","Ports":"","RunningFor":"27 days ago","Size":"0B","State":"exited","Status":"Exited (137) 27 days ago"}

View File

@ -1,6 +0,0 @@
sonarr:
state: started
plex:
state: started
sabnzbd:
state: stopped

12
var/entities/servers.yaml Normal file
View File

@ -0,0 +1,12 @@
- name: server1
state: started
hostname: server1.example.com
ip: 192.168.0.1
- name: server2
state: started
hostname: server2.example.com
ip: 192.168.0.2
- name: server3
state: stopped
hostname: server3.example.com
ip: 192.168.0.3

View File

@ -189,7 +189,7 @@
This is the bootstrap code, which relies on very simple, old javascript This is the bootstrap code, which relies on very simple, old javascript
to at least display a helpful error message if we can't use OliveTin. to at least display a helpful error message if we can't use OliveTin.
*/ */
function showBigError (type, friendlyType, message) { window.showBigError = function (type, friendlyType, message) {
clearBigErrors(type) clearBigErrors(type)
console.error('Error ' + type + ': ', message) console.error('Error ' + type + ': ', message)
@ -202,7 +202,7 @@
document.body.prepend(domErr) document.body.prepend(domErr)
} }
function clearBigErrors(additionalClass) { window.clearBigErrors = function (additionalClass) {
let selector = 'div.error' let selector = 'div.error'
if (additionalClass != null) { if (additionalClass != null) {

View File

@ -25,12 +25,10 @@ class ActionButton extends ExecutionFeedbackButton {
this.constructDomFromTemplate() this.constructDomFromTemplate()
// Class attributes // Class attributes
this.temporaryStatusMessage = null
this.isWaiting = false
this.actionCallUrl = window.restBaseUrl + 'StartAction'
this.updateFromJson(json) this.updateFromJson(json)
this.actionId = json.id
// DOM Attributes // DOM Attributes
this.setAttribute('role', 'none') this.setAttribute('role', 'none')
this.btn.title = json.title this.btn.title = json.title
@ -59,15 +57,13 @@ class ActionButton extends ExecutionFeedbackButton {
this.domTitle.innerText = this.btn.title this.domTitle.innerText = this.btn.title
this.domIcon.innerHTML = this.unicodeIcon this.domIcon.innerHTML = this.unicodeIcon
this.setAttribute('id', 'actionButton-' + json.id) this.setAttribute('id', 'actionButton-' + this.actionId)
} }
updateFromJson (json) { updateFromJson (json) {
// Fields that should not be updated // Fields that should not be updated
// //
// title - as the callback URL relies on it // title - as the callback URL relies on it
// actionCallbackUrl - as it's based on the title
// temporaryStatusMessage - as the button might be "waiting" on execution to finish while it's being updated.
if (json.icon === '') { if (json.icon === '') {
this.unicodeIcon = '&#x1f4a9' this.unicodeIcon = '&#x1f4a9'
@ -93,8 +89,6 @@ class ActionButton extends ExecutionFeedbackButton {
} }
startAction (actionArgs) { startAction (actionArgs) {
// this.btn.disabled = true
// this.isWaiting = true
this.btn.classList = [] // Removes old animation classes this.btn.classList = [] // Removes old animation classes
if (actionArgs === undefined) { if (actionArgs === undefined) {
@ -104,14 +98,14 @@ class ActionButton extends ExecutionFeedbackButton {
// UUIDs are create client side, so that we can setup a "execution-button" // UUIDs are create client side, so that we can setup a "execution-button"
// to track the execution before we send the request to the server. // to track the execution before we send the request to the server.
const startActionArgs = { const startActionArgs = {
actionName: this.btn.title, actionId: this.actionId,
arguments: actionArgs, arguments: actionArgs,
uuid: this.getUniqueId() uniqueTrackingId: this.getUniqueId()
} }
this.onActionStarted(startActionArgs.uuid) this.onActionStarted(startActionArgs.uuid)
window.fetch(this.actionCallUrl, { window.fetch(window.restBaseUrl + 'StartAction', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'

View File

@ -187,11 +187,11 @@ function marshalDashboardStructureToHtml (json) {
} }
function marshalLink (item, fieldset) { function marshalLink (item, fieldset) {
let btn = window.actionButtons[item.link] let btn = window.actionButtons[item.title]
if (typeof btn === 'undefined') { if (typeof btn === 'undefined') {
btn = document.createElement('button') btn = document.createElement('button')
btn.innerText = 'Action not found: ' + item.link btn.innerText = 'Action not found: ' + item.title
btn.classList.add('error') btn.classList.add('error')
} }
@ -208,6 +208,9 @@ function marshalContainerContents (json, section, fieldset, parentDashboard) {
marshalDirectoryButton(item, fieldset) marshalDirectoryButton(item, fieldset)
marshalDirectory(item, section) marshalDirectory(item, section)
break break
case 'display':
marshalDisplay(item, fieldset)
break
case 'link': case 'link':
marshalLink(item, fieldset) marshalLink(item, fieldset)
break break
@ -335,6 +338,13 @@ function createDirectoryBreadcrumb (title, link) {
return a return a
} }
function marshalDisplay (item, fieldset) {
const display = document.createElement('div')
display.innerHTML = item.title
fieldset.appendChild(display)
}
function marshalDirectoryButton (item, fieldset) { function marshalDirectoryButton (item, fieldset) {
const directoryButton = document.createElement('button') const directoryButton = document.createElement('button')
directoryButton.innerHTML = '<span class = "icon">&#128193;</span> ' + item.title directoryButton.innerHTML = '<span class = "icon">&#128193;</span> ' + item.title

View File

@ -25,7 +25,7 @@ dialog.big {
fieldset { fieldset {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, 180px); grid-template-columns: repeat(auto-fit, 180px);
grid-template-rows: auto auto auto auto; grid-auto-rows: 1fr;
grid-gap: 1em; grid-gap: 1em;
padding: 0; padding: 0;
text-align: center; text-align: center;