fix: broken tests after changing the way arguments are parsed

This commit is contained in:
jamesread 2025-10-01 22:20:56 +01:00
parent a26a8bb032
commit a464e6a445
9 changed files with 129 additions and 44 deletions

1
.gitignore vendored
View File

@ -12,3 +12,4 @@ frontend/dist/
frontend/node_modules frontend/node_modules
custom-frontend custom-frontend
integration-tests/screenshots/ integration-tests/screenshots/
.vscode/

View File

@ -5,6 +5,12 @@
# Listen on all addresses available, port 1337 # Listen on all addresses available, port 1337
listenAddressSingleHTTPFrontend: 0.0.0.0:1337 listenAddressSingleHTTPFrontend: 0.0.0.0:1337
bannerMessage: "This is an early alpha version of OliveTin 3000. Many thanks are broken, many things will change."
bannerCss: "background-color: #b2e4b2; color: black; font-size: small; text-align: center; padding: .6em; border-radius: 0.5em;"
insecureAllowDumpSos: true
insecureAllowDumpVars: true
# Choose from INFO (default), WARN and DEBUG # Choose from INFO (default), WARN and DEBUG
logLevel: "INFO" logLevel: "INFO"
@ -99,7 +105,7 @@ actions:
# Docs: https://docs.olivetin.app/solutions/container-control-panel/index.html # Docs: https://docs.olivetin.app/solutions/container-control-panel/index.html
- title: Restart Docker Container - title: Restart Docker Container
icon: restart icon: restart
shell: docker restart {{ container }} shell: docker restart {{ .CurrentEntity }}
arguments: arguments:
- name: container - name: container
title: Container name title: Container name
@ -205,15 +211,15 @@ actions:
shell: "echo 'Ping all servers'" shell: "echo 'Ping all servers'"
icon: ping icon: ping
- title: Start {{ container.Names }} - title: Start {{ .CurrentEntity.Names }}
icon: box icon: box
shell: docker start {{ container.Names }} shell: docker start {{ .CurrentEntity.Names }}
entity: container entity: container
triggers: ["Update container entity file"] triggers: ["Update container entity file"]
- title: Stop {{ container.Names }} - title: Stop {{ .CurrentEntity.Names }}
icon: box icon: box
shell: docker stop {{ container.Names }} shell: docker stop {{ .CurrentEntity.Names }}
entity: container entity: container
triggers: ["Update container entity file"] triggers: ["Update container entity file"]
@ -287,7 +293,7 @@ dashboards:
# actions grouped together without a folder. # actions grouped together without a folder.
- type: fieldset - type: fieldset
entity: server entity: server
title: 'Server: {{ server.hostname }}' title: 'Server: {{ .CurrentEntity.hostname }}'
contents: contents:
# By default OliveTin will look for an action with a matching title # By default OliveTin will look for an action with a matching title
# and put it on the dashboard. # and put it on the dashboard.
@ -306,7 +312,7 @@ dashboards:
# This is the second dashboard. # This is the second dashboard.
- title: My Containers - title: My Containers
contents: contents:
- title: 'Container {{ container.Names }} ({{ container.Image }})' - title: 'Container {{ .CurrentEntity.Names }} ({{ .CurrentEntity.Image }})'
entity: container entity: container
type: fieldset type: fieldset
contents: contents:
@ -314,5 +320,5 @@ dashboards:
title: | title: |
{{ container.RunningFor }} <br /><br /><strong>{{ container.State }}</strong> {{ container.RunningFor }} <br /><br /><strong>{{ container.State }}</strong>
- title: 'Start {{ container.Names }}' - title: 'Start {{ .CurrentEntity.Names }}'
- title: 'Stop {{ container.Names }}' - title: 'Stop {{ .CurrentEntity.Names }}'

View File

@ -1,11 +1,12 @@
package api package api
import ( import (
"connectrpc.com/connect"
"context" "context"
"github.com/stretchr/testify/assert"
"testing" "testing"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
@ -15,24 +16,27 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path"
) )
func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) { func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) {
ex := executor.DefaultExecutor(injectedConfig) ex := executor.DefaultExecutor(injectedConfig)
ex.RebuildActionMap() ex.RebuildActionMap()
path, handler := GetNewHandler(ex) apiPath, apiHandler := GetNewHandler(ex)
path = "/api" + path
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { mux.Handle("/api/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Infof("HTTP Request: %s %s", r.Method, r.URL.Path) log.Infof("HTTP Request: %s %s", r.Method, r.URL.Path)
http.StripPrefix("/api/", handler) // Translate /api/<service>/<method> to <service>/<method>
}) fn := path.Base(r.URL.Path)
r.URL.Path = apiPath + fn
log.Infof("API path is %s", path) apiHandler.ServeHTTP(w, r)
}))
log.Infof("API path is %s", apiPath)
httpclient := &http.Client{} httpclient := &http.Client{}
@ -40,7 +44,7 @@ func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*ht
client := apiv1connect.NewOliveTinApiServiceClient(httpclient, ts.URL+"/api") client := apiv1connect.NewOliveTinApiServiceClient(httpclient, ts.URL+"/api")
log.Infof("Test server URL is %s", ts.URL+path) log.Infof("Test server URL is %s", ts.URL+"/api"+apiPath)
return ts, client return ts, client
} }
@ -59,11 +63,16 @@ func TestGetActionsAndStart(t *testing.T) {
conn, client := getNewTestServerAndClient(t, cfg) conn, client := getNewTestServerAndClient(t, cfg)
respInit, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{})) respInit, errInit := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
respGetReady, err := client.GetReadyz(context.Background(), connect.NewRequest(&apiv1.GetReadyzRequest{})) respGetReady, errReady := client.GetReadyz(context.Background(), connect.NewRequest(&apiv1.GetReadyzRequest{}))
if err != nil { if errInit != nil {
t.Errorf("GetDashboardComponentsRequest: %v", err) t.Errorf("Init request failed: %v", errInit)
return
}
if errReady != nil {
t.Errorf("GetReadyz request failed: %v", errReady)
return return
} }

View File

@ -11,10 +11,11 @@ import (
var tpl = template.New("tpl") var tpl = template.New("tpl")
var legacyEntityRegex = regexp.MustCompile(`{{ ([a-zA-Z0-9_]+)\.*?([a-zA-Z0-9_\.]+) }}`) var legacyArgumentRegex = regexp.MustCompile(`{{ ([a-zA-Z0-9_]+) }}`)
var legacyEntityPropertiesRegex = regexp.MustCompile(`{{ ([a-zA-Z0-9_]+)\.([a-zA-Z0-9_\.]+) }}`)
func migrateLegacyArgumentNames(rawShellCommand string) string { func migrateLegacyEntityProperties(rawShellCommand string) string {
foundArgumentNames := legacyEntityRegex.FindAllStringSubmatch(rawShellCommand, -1) foundArgumentNames := legacyEntityPropertiesRegex.FindAllStringSubmatch(rawShellCommand, -1)
for _, match := range foundArgumentNames { for _, match := range foundArgumentNames {
entityName := match[1] entityName := match[1]
@ -45,8 +46,28 @@ func migrateLegacyArgumentNames(rawShellCommand string) string {
return rawShellCommand return rawShellCommand
} }
func migrateLegacyArgumentNames(rawShellCommand string) string {
foundArgumentNames := legacyArgumentRegex.FindAllStringSubmatch(rawShellCommand, -1)
for _, match := range foundArgumentNames {
argName := match[1]
if !strings.HasPrefix(argName, ".Arguments.") {
log.WithFields(log.Fields{
"old": argName,
"new": ".Arguments." + argName,
}).Warnf("Legacy variable name found, changing to Argument")
rawShellCommand = strings.ReplaceAll(rawShellCommand, argName, ".Arguments."+argName)
}
}
return rawShellCommand
}
func ParseTemplateWithArgs(source string, ent *Entity, args map[string]string) string { func ParseTemplateWithArgs(source string, ent *Entity, args map[string]string) string {
source = migrateLegacyArgumentNames(source) source = migrateLegacyArgumentNames(source)
source = migrateLegacyEntityProperties(source)
ret := "" ret := ""

View File

@ -25,7 +25,7 @@ var (
) )
func parseCommandForReplacements(shellCommand string, values map[string]string, entity any) (string, error) { func parseCommandForReplacements(shellCommand string, values map[string]string, entity any) (string, error) {
r := regexp.MustCompile(`{{ *?\.Arguments\.([a-zA-Z0-9_]+?) *?}}`) r := regexp.MustCompile(`{{ *?([a-zA-Z0-9_]+?) *?}}`)
foundArgumentNames := r.FindAllStringSubmatch(shellCommand, -1) foundArgumentNames := r.FindAllStringSubmatch(shellCommand, -1)
for _, match := range foundArgumentNames { for _, match := range foundArgumentNames {
@ -66,7 +66,7 @@ func parseActionArguments(values map[string]string, action *config.Action, entit
}).Debugf("Arg assigned") }).Debugf("Arg assigned")
} }
parsedShellCommand := entities.ParseTemplateWith(rawShellCommand, entity) parsedShellCommand := entities.ParseTemplateWithArgs(rawShellCommand, entity, values)
redactedShellCommand := redactShellCommand(parsedShellCommand, action.Arguments, values) redactedShellCommand := redactShellCommand(parsedShellCommand, action.Arguments, values)
if err != nil { if err != nil {

View File

@ -257,26 +257,35 @@ func TestTypeSafetyCheckUnicodeIdentifier(t *testing.T) {
name string name string
field string field string
value string value string
hasError bool expectsError bool
}{ }{
{"Valid unicode identifier", "name", "hello_world", false}, {"Valid unicode identifier", "name", "hello_world", false},
{"Valid with numbers", "name", "test123", false}, {"Valid with numbers", "name", "test123", false},
{"Valid with spaces", "name", "hello world", false},
{"Valid with path separators", "name", "path/to/file", false},
{"Valid with backslashes", "name", "path\\to\\file", false},
{"Valid with dots", "name", "file.txt", false}, {"Valid with dots", "name", "file.txt", false},
{"Valid with underscores", "name", "my_file_name", false}, {"Valid with underscores", "name", "my_file_name", false},
{"Invalid with special chars", "name", "hello@world", true}, {"Invalid with special chars", "name", "hello@world", true},
{"Invalid with brackets", "name", "hello[world]", true}, {"Invalid with brackets", "name", "hello[world]", true},
{"Invalid with spaces", "name", "hello world", true},
{"Invalid with path separators", "name", "path/to/file", true},
{"Invalid with backslashes", "name", "path\\to\\file", true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := TypeSafetyCheck(tt.field, tt.value, "unicode_identifier") err := TypeSafetyCheck(tt.field, tt.value, "unicode_identifier")
if tt.hasError {
assert.NotNil(t, err, "Expected error for value '%s'", tt.value) if tt.expectsError {
if err == nil {
t.Errorf("Expected error for value '%s', but got none", tt.value)
} else {
t.Logf("Received expected error for value '%s': %v", tt.value, err)
}
} else { } else {
assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err) if err != nil {
t.Errorf("Expected no error for value '%s', but got: %v", tt.value, err)
} else {
t.Logf("No error for valid value '%s' as expected", tt.value)
}
} }
}) })
} }

View File

@ -447,6 +447,13 @@ func stepParseArgs(req *ExecutionRequest) bool {
func stepRequestAction(req *ExecutionRequest) bool { func stepRequestAction(req *ExecutionRequest) bool {
metricActionsRequested.Inc() metricActionsRequested.Inc()
// If there is no binding or action, do not proceed. Leave default
// log entry values (icon/title/id) and stop execution gracefully.
if req.Binding == nil || req.Binding.Action == nil {
log.Warnf("Action request has no binding/action; skipping execution")
return false
}
req.logEntry.ActionConfigTitle = req.Binding.Action.Title req.logEntry.ActionConfigTitle = req.Binding.Action.Title
req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity) req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity)
req.logEntry.ActionIcon = req.Binding.Action.Icon req.logEntry.ActionIcon = req.Binding.Action.Icon

View File

@ -1,9 +1,10 @@
package executor package executor
import ( import (
"github.com/stretchr/testify/assert"
"testing" "testing"
"github.com/stretchr/testify/assert"
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"
) )
@ -41,6 +42,12 @@ func TestCreateExecutorAndExec(t *testing.T) {
}, },
} }
// Ensure bindings are available and set the binding to the only configured action
e.RebuildActionMap()
if len(cfg.Actions) > 0 {
req.Binding = e.FindBindingWithNoEntity(cfg.Actions[0])
}
assert.NotNil(t, e, "Create an executor") assert.NotNil(t, e, "Create an executor")
wg, _ := e.ExecRequest(&req) wg, _ := e.ExecRequest(&req)
@ -114,11 +121,11 @@ func TestGetLogsEmpty(t *testing.T) {
assert.Equal(t, int64(10), cfg.LogHistoryPageSize, "Logs page size should be 10") assert.Equal(t, int64(10), cfg.LogHistoryPageSize, "Logs page size should be 10")
logs, remaining := e.GetLogTrackingIds(0, 10) logs, paging := e.GetLogTrackingIds(0, 10)
assert.NotNil(t, logs, "Logs should not be nil") assert.NotNil(t, logs, "Logs should not be nil")
assert.Equal(t, 0, len(logs), "No logs yet") assert.Equal(t, 0, len(logs), "No logs yet")
assert.Equal(t, int64(0), remaining, "There should be no remaining logs") assert.Equal(t, int64(0), paging.CountRemaining, "There should be no remaining logs")
} }
func TestGetLogsLessThanPageSize(t *testing.T) { func TestGetLogsLessThanPageSize(t *testing.T) {
@ -130,12 +137,15 @@ func TestGetLogsLessThanPageSize(t *testing.T) {
}) })
cfg.Sanitize() cfg.Sanitize()
// Rebuild action map to include newly added action
e.RebuildActionMap()
assert.Equal(t, int64(10), cfg.LogHistoryPageSize, "Logs page size should be 10") assert.Equal(t, int64(10), cfg.LogHistoryPageSize, "Logs page size should be 10")
logEntries, remaining := e.GetLogTrackingIds(0, 10) logEntries, paging := e.GetLogTrackingIds(0, 10)
assert.Equal(t, 0, len(logEntries), "There should be 0 logs") assert.Equal(t, 0, len(logEntries), "There should be 0 logs")
assert.Zero(t, remaining, "There should be no remaining logs") assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
execNewReqAndWait(e, "blat", cfg) execNewReqAndWait(e, "blat", cfg)
execNewReqAndWait(e, "blat", cfg) execNewReqAndWait(e, "blat", cfg)
@ -145,10 +155,10 @@ func TestGetLogsLessThanPageSize(t *testing.T) {
execNewReqAndWait(e, "blat", cfg) execNewReqAndWait(e, "blat", cfg)
execNewReqAndWait(e, "blat", cfg) execNewReqAndWait(e, "blat", cfg)
logEntries, remaining = e.GetLogTrackingIds(0, 10) logEntries, paging = e.GetLogTrackingIds(0, 10)
assert.Equal(t, 7, len(logEntries), "There should be 7 logs") assert.Equal(t, 7, len(logEntries), "There should be 7 logs")
assert.Zero(t, remaining, "There should be no remaining logs") assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
execNewReqAndWait(e, "blat", cfg) execNewReqAndWait(e, "blat", cfg)
execNewReqAndWait(e, "blat", cfg) execNewReqAndWait(e, "blat", cfg)
@ -156,10 +166,10 @@ func TestGetLogsLessThanPageSize(t *testing.T) {
execNewReqAndWait(e, "blat", cfg) execNewReqAndWait(e, "blat", cfg)
execNewReqAndWait(e, "blat", cfg) execNewReqAndWait(e, "blat", cfg)
logEntries, remaining = e.GetLogTrackingIds(0, 10) logEntries, paging = e.GetLogTrackingIds(0, 10)
assert.Equal(t, 10, len(logEntries), "There should be 10 logs") assert.Equal(t, 10, len(logEntries), "There should be 10 logs")
assert.Equal(t, int64(2), remaining, "There should be 1 remaining logs") assert.Equal(t, int64(2), paging.CountRemaining, "There should be 1 remaining logs")
} }
func execNewReqAndWait(e *Executor, title string, cfg *config.Config) { func execNewReqAndWait(e *Executor, title string, cfg *config.Config) {
@ -168,6 +178,19 @@ func execNewReqAndWait(e *Executor, title string, cfg *config.Config) {
Cfg: cfg, Cfg: cfg,
} }
// Ensure we have a binding for the requested title
e.RebuildActionMap()
var action *config.Action
for _, a := range cfg.Actions {
if a.Title == title {
action = a
break
}
}
if action != nil {
req.Binding = e.FindBindingWithNoEntity(action)
}
wg, _ := e.ExecRequest(req) wg, _ := e.ExecRequest(req)
wg.Wait() wg.Wait()
} }
@ -245,6 +268,9 @@ func TestMangleInvalidArgumentValues(t *testing.T) {
cfg.Actions = append(cfg.Actions, a1) cfg.Actions = append(cfg.Actions, a1)
cfg.Sanitize() cfg.Sanitize()
// Build bindings for newly added action
e.RebuildActionMap()
req := ExecutionRequest{ req := ExecutionRequest{
// Action: a1, // Action: a1,
AuthenticatedUser: acl.UserFromSystem(cfg, "testuser"), AuthenticatedUser: acl.UserFromSystem(cfg, "testuser"),
@ -254,6 +280,9 @@ func TestMangleInvalidArgumentValues(t *testing.T) {
}, },
} }
// Set binding to our appended action
req.Binding = e.FindBindingWithNoEntity(a1)
wg, _ := e.ExecRequest(&req) wg, _ := e.ExecRequest(&req)
wg.Wait() wg.Wait()

View File

@ -48,6 +48,9 @@ func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) {
mux.Handle("/api/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/api/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fn := path.Base(r.URL.Path) fn := path.Base(r.URL.Path)
// Translate /api/foo/bar to /api/bar - this preserves compatibility
// with OliveTin 2k.
r.URL.Path = apiPath + fn r.URL.Path = apiPath + fn
log.Debugf("SingleFrontend HTTP API Req URL after rewrite: %v", r.URL.Path) log.Debugf("SingleFrontend HTTP API Req URL after rewrite: %v", r.URL.Path)