feature: The mega dashboards & entities commit.
This commit is contained in:
parent
fddf83f27d
commit
381bf59fbd
3
Makefile
3
Makefile
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
230
config.yaml
230
config.yaml
|
|
@ -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: "🇦"
|
|
||||||
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: '🇦'
|
||||||
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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: "💩",
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
sonarr:
|
|
||||||
state: started
|
|
||||||
plex:
|
|
||||||
state: started
|
|
||||||
sabnzbd:
|
|
||||||
state: stopped
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 = '💩'
|
this.unicodeIcon = '💩'
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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">📁</span> ' + item.title
|
directoryButton.innerHTML = '<span class = "icon">📁</span> ' + item.title
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue