feature: The mega dashboards & entities commit.

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

View File

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

View File

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

View File

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

View File

@ -10,23 +10,41 @@ logLevel: "INFO"
# Actions (buttons) to show up on the WebUI:
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
shell: docker restart mediacontainer
icon: box
entity: container
# This uses `popupOnStart: execution-dialog-stdout-only` to simply show just
# the command output.
- title: Check disk space
icon: disk
shell: df -h /media
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
shell: dmesg | tail
icon: logs
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
shell: /opt/backupScript.sh
shellAfterCompleted: "apprise -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ stdout }} '"
@ -35,13 +53,9 @@ actions:
icon: backup
popupOnStart: execution-dialog
- title: date
shell: date
timeout: 6
icon: clock
popupOnStart: execution-button
# This will send 1 ping (-c 1)
# When you want to prompt users for input, that is when you should use
# `arguments` - this presents a popup dialog and asks for argument values.
#
# Docs: https://docs.olivetin.app/action-ping.html
- title: Ping host
shell: ping {{ host }} -c {{ count }}
@ -60,25 +74,6 @@ actions:
default: 1
description: How many times to do you want to ping?
# OliveTin can run long-running jobs like Ansible playbooks.
#
# For such jobs, you will need to install ansible-playbook on the host where
# you are running OliveTin, or in the container.
#
# You probably want a much longer timeout as well (so that ansible completes).
- title: "Run Ansible Playbook"
icon: "&#x1F1E6"
shell: ansible-playbook -i /etc/hosts /root/myRepo/myPlaybook.yaml
timeout: 120
# Restart httpd on host "server1"
# Docs: https://docs.olivetin.app/action-ping.html
- title: restart httpd on server1
titleAlias: restart_httpd
icon: restart
timeout: 1
shell: ssh root@server1 'service httpd restart'
# OliveTin can control containers - docker is just a command line app.
#
# However, if you are running in a container you will need to do some setup,
@ -96,6 +91,10 @@ actions:
- value: traefik
- 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
icon: ashtonished
shell: rm -rf /opt/oldBackups/
@ -103,35 +102,154 @@ actions:
- type: confirmation
title: Are you sure?!
- title: Server1 Power Off
shell: echo "Power Off Server 1"
- title: Server2 Wake On LAN
shell: echo "Sending Wake on LAN to Server 2"
# Sometimes you want to run actions on other servers - don't overcomplicate
# 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
shell: echo "Power Off Server 2"
# There are several built-in shortcuts for the `icon` option, but you
# can also just specify any HTML, this includes any unicode character,
# or a <img = "..." /> link to a custom icon.
#
# Docs: https://docs.olivetin.app/icons.html
#
# Lots of people use OliveTin to easily execute ansible-playbooks. You
# probably want a much longer timeout as well (so that ansible completes).
#
# Docs: https://docs.olivetin.app/ansible-playbook.html
- title: "Run Ansible Playbook"
icon: '&#x1F1E6;'
shell: ansible-playbook -i /etc/hosts /root/myRepo/myPlaybook.yaml
timeout: 120
# The following actions are "dummy" actions, used in a Dashboard. As long as
# you have these referenced in a dashboard, they will not who up in the
# `actions` view.
- title: Ping hypervisor1
shell: echo "hypervisor1 online"
- title: Ping hypervisor2
shell: echo "hypervisor2 online"
- title: "{{ server.name }} Wake on Lan"
shell: echo "Sending Wake on LAN to {{ server.hostname }}"
entity: server
- title: "{{ server.name }} Power Off"
shell: "echo 'Power Off Server: {{ server.hostname }}'"
entity: server
- title: Ping All Servers
shell: echo "Ping all servers"
shell: "echo 'Ping all servers'"
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:
# Top level items are dsahboards.
- title: My Servers
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
contents:
- title: Server 1
contents:
- link: Server1 Power Off
- title: Server 2
contents:
- link: Server2 Wake On LAN
- link: Server2 Power Off
- title: Server Utilities
type: fieldset
contents:
- link: Ping All Servers
- type: display
title: |
{{ container.Names }} <br /><br /><strong>{{ container.State }}</strong>
- title: 'Start {{ container.Names }}'
- title: 'Stop {{ container.Names }}'

View File

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

View File

@ -1,34 +1,36 @@
import {expect} from 'chai';
import {By} from 'selenium-webdriver';
import { describe, it, before, after } from 'mocha'
import { expect } from 'chai'
import { By } from 'selenium-webdriver'
//import * as waitOn from 'wait-on'
describe('config: general', function () {
before(async function () {
await runner.start('general')
});
})
after(async () => {
await runner.stop()
});
})
it('Page title', async function () {
await webdriver.get(runner.baseUrl())
let title = await webdriver.getTitle();
const title = await webdriver.getTitle()
expect(title).to.be.equal("OliveTin")
})
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() {
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)
})

View File

@ -1,20 +1,21 @@
import { expect } from 'chai';
import { describe, it, before, after } from 'mocha'
import { expect } from 'chai'
import { By } from 'selenium-webdriver';
import { By } from 'selenium-webdriver'
describe('config: hiddenFooter', function () {
before(async function () {
await runner.start('hiddenFooter')
});
})
after(async () => {
await runner.stop()
});
})
it('Check that footer is hidden', async () => {
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
})

View File

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

View File

@ -1,3 +1,4 @@
import { describe, before, after } from 'mocha'
import { expect } from 'chai'
import { By, until } from 'selenium-webdriver'
@ -14,9 +15,16 @@ describe('config: multipleDropdowns', function () {
await webdriver.get(runner.baseUrl())
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()

View File

@ -41,6 +41,10 @@ func IsAllowedExec(cfg *config.Config, user *AuthenticatedUser, action *config.A
// IsAllowedView checks if a User is allowed to view an Action
func IsAllowedView(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool {
if action.Hidden {
return false
}
for _, acl := range getRelevantAcls(cfg, action.Acls, user) {
if acl.Permissions.View {
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 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 {
if isACLRelevantToAction(cfg, actionAcls, acl, user) {
ret = append(ret, &acl)
ret = append(ret, acl)
}
}

View File

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

View File

@ -4,7 +4,7 @@ package config
func (cfg *Config) FindAction(actionTitle string) *Action {
for _, action := range cfg.Actions {
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 {
for _, acl := range cfg.AccessControlLists {
if acl.Name == aclTitle {
return &acl
return acl
}
}

View File

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

View File

@ -1,7 +1,9 @@
package config
import (
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"strings"
)
// 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.ID = getActionID(action)
action.Icon = lookupHTMLIcon(action.Icon)
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 {
switch raw {
case "execution-dialog":

View File

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

View File

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

View File

@ -2,6 +2,7 @@ package executor
import (
config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
log "github.com/sirupsen/logrus"
"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{
"actionTitle": action.Title,
"actionTitle": actionTitle,
"cmd": rawShellCommand,
}).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 = sv.ReplaceEntityVars(entityPrefix, rawShellCommand)
log.WithFields(log.Fields{
"actionTitle": action.Title,
"actionTitle": actionTitle,
"cmd": rawShellCommand,
}).Infof("Action parse args - After")

View File

@ -33,7 +33,7 @@ func TestArgumentNameNumbers(t *testing.T) {
"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.Nil(t, err)
@ -53,7 +53,7 @@ func TestArgumentNotProvided(t *testing.T) {
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, err.Error(), "Required arg not provided: personName")

View File

@ -3,6 +3,7 @@ package executor
import (
acl "github.com/OliveTin/OliveTin/internal/acl"
config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
log "github.com/sirupsen/logrus"
"bytes"
@ -28,13 +29,15 @@ type Executor struct {
// ExecutionRequest is a request to execute an action. It's passed to an
// Executor. They're created from the grpcapi.
type ExecutionRequest struct {
ActionName string
ActionTitle string
Action *config.Action
Arguments map[string]string
UUID string
TrackingID string
Tags []string
Cfg *config.Config
AuthenticatedUser *acl.AuthenticatedUser
EntityPrefix string
logEntry *InternalLogEntry
finalParsedCommand string
executor *Executor
@ -56,6 +59,7 @@ type InternalLogEntry struct {
Tags []string
ExecutionStarted bool
ExecutionFinished bool
ExecutionTrackingID string
/*
The following 3 properties are obviously on Action normally, but it's useful
@ -64,7 +68,7 @@ type InternalLogEntry struct {
*/
ActionTitle string
ActionIcon string
UUID string
ActionId string
}
type executorStepFunc func(*ExecutionRequest) bool
@ -76,8 +80,7 @@ func DefaultExecutor() *Executor {
e.Logs = make(map[string]*InternalLogEntry)
e.chainOfCommand = []executorStepFunc{
stepLogRequested,
stepFindAction,
stepRequestAction,
stepConcurrencyCheck,
stepACLCheck,
stepParseArgs,
@ -109,20 +112,18 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
req.logEntry = &InternalLogEntry{
DatetimeStarted: time.Now().Format("2006-01-02 15:04:05"),
ActionTitle: req.ActionName,
UUID: req.UUID,
ExecutionTrackingID: req.TrackingID,
Stdout: "",
Stderr: "",
ExitCode: -1337, // If an Action is not actually executed, this is the default exit code.
ExecutionStarted: false,
ExecutionFinished: false,
ActionId: "",
ActionTitle: "notfound",
ActionIcon: "&#x1f4a9;",
}
e.Logs[req.UUID] = req.logEntry
for _, listener := range e.listeners {
listener.OnExecutionStarted(req.ActionName)
}
e.Logs[req.TrackingID] = req.logEntry
wg := new(sync.WaitGroup)
wg.Add(1)
@ -132,7 +133,7 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
defer wg.Done()
}()
return wg, req.UUID
return wg, req.TrackingID
}
func (e *Executor) execChain(req *ExecutionRequest) {
@ -153,7 +154,7 @@ func getConcurrentCount(req *ExecutionRequest) int {
concurrentCount := 0
for _, log := range req.executor.Logs {
if log.ActionTitle == req.ActionName && !log.ExecutionFinished {
if log.ActionId == req.Action.ID && !log.ExecutionFinished {
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)
log.WithFields(log.Fields{
"actionTitle": req.ActionName,
"actionTitle": req.logEntry.ActionTitle,
}).Warnf(msg)
req.logEntry.Stdout = msg
@ -180,29 +181,6 @@ func stepConcurrencyCheck(req *ExecutionRequest) bool {
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 {
return acl.IsAllowedExec(req.Cfg, req.AuthenticatedUser, req.Action)
}
@ -210,7 +188,7 @@ func stepACLCheck(req *ExecutionRequest) bool {
func stepParseArgs(req *ExecutionRequest) bool {
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 {
req.logEntry.Stdout = err.Error()
@ -223,9 +201,32 @@ func stepParseArgs(req *ExecutionRequest) bool {
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.ActionName,
"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{
"actionTitle": req.logEntry.ActionTitle,
}).Infof("Action requested")
return true
@ -233,7 +234,7 @@ func stepLogRequested(req *ExecutionRequest) bool {
func stepLogStart(req *ExecutionRequest) bool {
log.WithFields(log.Fields{
"actionTitle": req.Action.Title,
"actionTitle": req.logEntry.ActionTitle,
"timeout": req.Action.Timeout,
}).Infof("Action starting")
@ -242,7 +243,7 @@ func stepLogStart(req *ExecutionRequest) bool {
func stepLogFinish(req *ExecutionRequest) bool {
log.WithFields(log.Fields{
"actionTitle": req.Action.Title,
"actionTitle": req.logEntry.ActionTitle,
"stdout": req.logEntry.Stdout,
"stderr": req.logEntry.Stderr,
"timedOut": req.logEntry.TimedOut,
@ -322,7 +323,7 @@ func stepExecAfter(req *ExecutionRequest) bool {
"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.Stdout = &stdout

View File

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

View File

@ -15,6 +15,7 @@ import (
config "github.com/OliveTin/OliveTin/internal/config"
executor "github.com/OliveTin/OliveTin/internal/executor"
installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
)
var (
@ -35,18 +36,21 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest)
args[arg.Name] = arg.Value
}
pair, _ := publicActionIdToActionMap[req.ActionId]
execReq := executor.ExecutionRequest{
ActionName: req.ActionName,
UUID: req.Uuid,
Action: pair.Action,
EntityPrefix: pair.EntityPrefix,
TrackingID: req.UniqueTrackingId,
Arguments: args,
AuthenticatedUser: acl.UserFromContext(ctx, cfg),
Cfg: cfg,
}
_, uuid := api.executor.ExecRequest(&execReq)
api.executor.ExecRequest(&execReq)
return &pb.StartActionResponse{
ExecutionUuid: uuid,
ExecutionTrackingId: req.UniqueTrackingId,
}, nil
}
@ -54,8 +58,8 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *pb.StartActionA
args := make(map[string]string)
execReq := executor.ExecutionRequest{
ActionName: req.ActionName,
UUID: uuid.NewString(),
Action: findActionByPublicID(req.ActionId),
TrackingID: uuid.NewString(),
Arguments: args,
AuthenticatedUser: acl.UserFromContext(ctx, cfg),
Cfg: cfg,
@ -64,7 +68,7 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *pb.StartActionA
wg, _ := api.executor.ExecRequest(&execReq)
wg.Wait()
internalLogEntry, ok := api.executor.Logs[execReq.UUID]
internalLogEntry, ok := api.executor.Logs[execReq.TrackingID]
if ok {
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)
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{
ActionName: action.Title,
Action: action,
UUID: uuid.NewString(),
Action: findActionByPublicID(req.ActionId),
TrackingID: uuid.NewString(),
Arguments: args,
AuthenticatedUser: &acl.AuthenticatedUser{
Username: "webhook",
Usergroup: "webhook",
},
AuthenticatedUser: acl.UserFromContext(ctx, cfg),
Cfg: cfg,
}
_, uuid := api.executor.ExecRequest(&execReq)
_, uniqueTrackingId := api.executor.ExecRequest(&execReq)
return &pb.StartActionByAliasResponse{
ExecutionUuid: uuid,
return &pb.StartActionByGetResponse{
ExecutionTrackingId: uniqueTrackingId,
}, 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)
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{
ActionName: action.Title,
Action: action,
UUID: uuid.NewString(),
Action: findActionByPublicID(req.ActionId),
TrackingID: uuid.NewString(),
Arguments: args,
AuthenticatedUser: &acl.AuthenticatedUser{
Username: "webhook",
Usergroup: "webhook",
},
AuthenticatedUser: acl.UserFromContext(ctx, cfg),
Cfg: cfg,
}
wg, _ := api.executor.ExecRequest(&execReq)
wg.Wait()
internalLogEntry, ok := api.executor.Logs[execReq.UUID]
internalLogEntry, ok := api.executor.Logs[execReq.TrackingID]
if ok {
return &pb.StartActionByAliasAndWaitResponse{
return &pb.StartActionByGetAndWaitResponse{
LogEntry: internalLogEntryToPb(internalLogEntry),
}, nil
} else {
@ -147,6 +126,7 @@ func internalLogEntryToPb(logEntry *executor.InternalLogEntry) *pb.LogEntry {
return &pb.LogEntry{
ActionTitle: logEntry.ActionTitle,
ActionIcon: logEntry.ActionIcon,
ActionId: logEntry.ActionId,
DatetimeStarted: logEntry.DatetimeStarted,
DatetimeFinished: logEntry.DatetimeFinished,
Stdout: logEntry.Stdout,
@ -155,7 +135,7 @@ func internalLogEntryToPb(logEntry *executor.InternalLogEntry) *pb.LogEntry {
Blocked: logEntry.Blocked,
ExitCode: logEntry.ExitCode,
Tags: logEntry.Tags,
ExecutionUuid: logEntry.UUID,
ExecutionTrackingId: logEntry.ExecutionTrackingID,
ExecutionStarted: logEntry.ExecutionStarted,
ExecutionFinished: logEntry.ExecutionFinished,
}
@ -164,7 +144,7 @@ func internalLogEntryToPb(logEntry *executor.InternalLogEntry) *pb.LogEntry {
func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *pb.ExecutionStatusRequest) (*pb.ExecutionStatusResponse, error) {
res := &pb.ExecutionStatusResponse{}
logEntry, ok := api.executor.Logs[req.ExecutionUuid]
logEntry, ok := api.executor.Logs[req.ExecutionTrackingId]
if !ok {
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.
for uuid, logEntry := range api.executor.Logs {
for trackingId, logEntry := range api.executor.Logs {
pbLogEntry := internalLogEntryToPb(logEntry)
pbLogEntry.ExecutionUuid = uuid
pbLogEntry.ExecutionTrackingId = trackingId
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) {
res := &pb.SosReportResponse{
Alert: "Your SOS Report has been logged to OliveTin logs.",
sos := installationinfo.GetSosReport()
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
}

View File

@ -6,29 +6,83 @@ import (
pb "github.com/OliveTin/OliveTin/gen/grpc"
acl "github.com/OliveTin/OliveTin/internal/acl"
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{}
for _, action := range cfgActions {
if !acl.IsAllowedView(cfg, user, &action) {
if !acl.IsAllowedView(cfg, user, action) {
continue
}
if action.Entity != "" {
res.Actions = append(res.Actions, buildActionEntities(action.Entity, action)...)
} else {
btn := actionCfgToPb(action, user)
res.Actions = append(res.Actions, btn)
}
}
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{
Id: fmt.Sprintf("%x", md5.Sum([]byte(action.Title))),
Id: virtualActionId,
Title: action.Title,
Icon: action.Icon,
CanExec: acl.IsAllowedExec(cfg, user, &action),
CanExec: acl.IsAllowedExec(cfg, user, action),
PopupOnStart: action.PopupOnStart,
}
@ -63,13 +117,16 @@ func buildChoices(choices []config.ActionArgumentChoice) []*pb.ActionArgumentCho
return ret
}
func findActionByAlias(alias string) *config.Action {
for _, action := range cfg.Actions {
if action.TitleAlias != "" {
if action.TitleAlias == alias {
return &action
}
}
func createPublicID(action *config.Action, entityPrefix string) string {
return fmt.Sprintf("%x", md5.Sum([]byte(action.ID+"."+entityPrefix)))
}
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

View File

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

View File

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

View File

@ -57,8 +57,9 @@ func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*gr
func TestGetActionsAndStart(t *testing.T) {
cfg = config.DefaultConfig()
btn1 := config.Action{}
btn1 := &config.Action{}
btn1.Title = "blat"
btn1.ID = "blat"
btn1.Shell = "echo 'test'"
cfg.Actions = append(cfg.Actions, btn1)
@ -76,7 +77,7 @@ func TestGetActionsAndStart(t *testing.T) {
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.NotNil(t, respSa, "Empty err after start action")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -58,6 +58,7 @@ func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.Interna
le := &pb.LogEntry{
ActionTitle: logEntry.ActionTitle,
ActionIcon: logEntry.ActionIcon,
ActionId: logEntry.ActionId,
DatetimeStarted: logEntry.DatetimeStarted,
DatetimeFinished: logEntry.DatetimeFinished,
Stdout: logEntry.Stdout,
@ -66,7 +67,7 @@ func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.Interna
Blocked: logEntry.Blocked,
ExitCode: logEntry.ExitCode,
Tags: logEntry.Tags,
Uuid: logEntry.UUID,
ExecutionTrackingId: logEntry.ExecutionTrackingID,
ExecutionStarted: logEntry.ExecutionStarted,
ExecutionFinished: logEntry.ExecutionFinished,
}

View File

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

View File

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

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

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

View File

@ -189,7 +189,7 @@
This is the bootstrap code, which relies on very simple, old javascript
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)
console.error('Error ' + type + ': ', message)
@ -202,7 +202,7 @@
document.body.prepend(domErr)
}
function clearBigErrors(additionalClass) {
window.clearBigErrors = function (additionalClass) {
let selector = 'div.error'
if (additionalClass != null) {

View File

@ -25,12 +25,10 @@ class ActionButton extends ExecutionFeedbackButton {
this.constructDomFromTemplate()
// Class attributes
this.temporaryStatusMessage = null
this.isWaiting = false
this.actionCallUrl = window.restBaseUrl + 'StartAction'
this.updateFromJson(json)
this.actionId = json.id
// DOM Attributes
this.setAttribute('role', 'none')
this.btn.title = json.title
@ -59,15 +57,13 @@ class ActionButton extends ExecutionFeedbackButton {
this.domTitle.innerText = this.btn.title
this.domIcon.innerHTML = this.unicodeIcon
this.setAttribute('id', 'actionButton-' + json.id)
this.setAttribute('id', 'actionButton-' + this.actionId)
}
updateFromJson (json) {
// Fields that should not be updated
//
// 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 === '') {
this.unicodeIcon = '&#x1f4a9'
@ -93,8 +89,6 @@ class ActionButton extends ExecutionFeedbackButton {
}
startAction (actionArgs) {
// this.btn.disabled = true
// this.isWaiting = true
this.btn.classList = [] // Removes old animation classes
if (actionArgs === undefined) {
@ -104,14 +98,14 @@ class ActionButton extends ExecutionFeedbackButton {
// 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.
const startActionArgs = {
actionName: this.btn.title,
actionId: this.actionId,
arguments: actionArgs,
uuid: this.getUniqueId()
uniqueTrackingId: this.getUniqueId()
}
this.onActionStarted(startActionArgs.uuid)
window.fetch(this.actionCallUrl, {
window.fetch(window.restBaseUrl + 'StartAction', {
method: 'POST',
headers: {
'Content-Type': 'application/json'

View File

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

View File

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