feat: expand entity list and detail APIs with properties and related … (#1068)

This commit is contained in:
James Read 2026-07-07 00:09:24 +01:00 committed by GitHub
commit 506ad4c883
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1610 additions and 506 deletions

View File

@ -323,6 +323,12 @@ entities:
# Docs: https://docs.olivetin.app/entities/intro.html
- file: entities/servers.yaml
name: server
icon: ssh
properties:
- name: hostname
title: Hostname
- name: ip
title: IP
- file: entities/containers.json
name: container

View File

@ -247,6 +247,27 @@ export declare type ActionArgumentChoice = Message<"olivetin.api.v1.ActionArgume
*/
export declare const ActionArgumentChoiceSchema: GenMessage<ActionArgumentChoice>;
/**
* @generated from message olivetin.api.v1.EntityRelatedAction
*/
export declare type EntityRelatedAction = Message<"olivetin.api.v1.EntityRelatedAction"> & {
/**
* @generated from field: olivetin.api.v1.Action action = 1;
*/
action?: Action | undefined;
/**
* @generated from field: map<string, string> prefilled_arguments = 2;
*/
prefilledArguments: { [key: string]: string };
};
/**
* Describes the message olivetin.api.v1.EntityRelatedAction.
* Use `create(EntityRelatedActionSchema)` to create a new message.
*/
export declare const EntityRelatedActionSchema: GenMessage<EntityRelatedAction>;
/**
* @generated from message olivetin.api.v1.Entity
*/
@ -275,6 +296,16 @@ export declare type Entity = Message<"olivetin.api.v1.Entity"> & {
* @generated from field: map<string, string> fields = 5;
*/
fields: { [key: string]: string };
/**
* @generated from field: repeated olivetin.api.v1.EntityRelatedAction related_actions = 6;
*/
relatedActions: EntityRelatedAction[];
/**
* @generated from field: string icon = 7;
*/
icon: string;
};
/**
@ -1894,6 +1925,25 @@ export declare const GetActionBindingResponseSchema: GenMessage<GetActionBinding
* @generated from message olivetin.api.v1.GetEntitiesRequest
*/
export declare type GetEntitiesRequest = Message<"olivetin.api.v1.GetEntitiesRequest"> & {
/**
* @generated from field: string entity_type = 1;
*/
entityType: string;
/**
* @generated from field: string filter = 2;
*/
filter: string;
/**
* @generated from field: int32 page = 3;
*/
page: number;
/**
* @generated from field: int32 page_size = 4;
*/
pageSize: number;
};
/**
@ -1936,6 +1986,21 @@ export declare type EntityDefinition = Message<"olivetin.api.v1.EntityDefinition
* @generated from field: repeated string used_on_dashboards = 3;
*/
usedOnDashboards: string[];
/**
* @generated from field: string icon = 4;
*/
icon: string;
/**
* @generated from field: repeated olivetin.api.v1.EntityProperty properties = 5;
*/
properties: EntityProperty[];
/**
* @generated from field: int32 total_instances = 6;
*/
totalInstances: number;
};
/**
@ -1944,6 +2009,27 @@ export declare type EntityDefinition = Message<"olivetin.api.v1.EntityDefinition
*/
export declare const EntityDefinitionSchema: GenMessage<EntityDefinition>;
/**
* @generated from message olivetin.api.v1.EntityProperty
*/
export declare type EntityProperty = Message<"olivetin.api.v1.EntityProperty"> & {
/**
* @generated from field: string name = 1;
*/
name: string;
/**
* @generated from field: string title = 2;
*/
title: string;
};
/**
* Describes the message olivetin.api.v1.EntityProperty.
* Use `create(EntityPropertySchema)` to create a new message.
*/
export declare const EntityPropertySchema: GenMessage<EntityProperty>;
/**
* @generated from message olivetin.api.v1.GetEntityRequest
*/
@ -2194,3 +2280,4 @@ export declare const OliveTinApiService: GenService<{
output: typeof EntitySchema;
},
}>;

File diff suppressed because one or more lines are too long

View File

@ -58,12 +58,19 @@ message ActionArgumentChoice {
string title = 2;
}
message EntityRelatedAction {
Action action = 1;
map<string, string> prefilled_arguments = 2;
}
message Entity {
string title = 1;
string unique_key = 2;
string type = 3;
repeated string directories = 4;
map<string, string> fields = 5;
repeated EntityRelatedAction related_actions = 6;
string icon = 7;
}
message GetDashboardResponse {
@ -428,6 +435,10 @@ message GetActionBindingResponse {
}
message GetEntitiesRequest {
string entity_type = 1;
string filter = 2;
int32 page = 3;
int32 page_size = 4;
}
message GetEntitiesResponse {
@ -438,6 +449,14 @@ message EntityDefinition {
string title = 1;
repeated Entity instances = 2;
repeated string used_on_dashboards = 3;
string icon = 4;
repeated EntityProperty properties = 5;
int32 total_instances = 6;
}
message EntityProperty {
string name = 1;
string title = 2;
}
message GetEntityRequest {

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,14 @@ import (
ctx "context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path"
"sort"
"strings"
"sync"
"time"
"connectrpc.com/connect"
"google.golang.org/protobuf/encoding/protojson"
@ -16,11 +21,6 @@ import (
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"fmt"
"net/http"
"sync"
"time"
acl "github.com/OliveTin/OliveTin/internal/acl"
auth "github.com/OliveTin/OliveTin/internal/auth"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
@ -1377,21 +1377,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
}
entityMap := entities.GetEntities()
entityNames := make([]string, 0, len(entityMap))
for name := range entityMap {
entityNames = append(entityNames, name)
}
sort.Strings(entityNames)
entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
for _, name := range entityNames {
def := &apiv1.EntityDefinition{
Title: name,
UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
Instances: buildSortedEntityInstances(name, entityMap[name]),
}
entityDefinitions = append(entityDefinitions, def)
}
entityDefinitions := api.buildEntityDefinitionsResponse(req.Msg, entityMap)
res := &apiv1.GetEntitiesResponse{
EntityDefinitions: entityDefinitions,
@ -1400,7 +1386,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
return connect.NewResponse(res), nil
}
func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity) []*apiv1.Entity {
func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity, properties []config.EntityProperty) []*apiv1.Entity {
instanceKeys := make([]string, 0, len(entityInstances))
for key := range entityInstances {
instanceKeys = append(instanceKeys, key)
@ -1414,6 +1400,7 @@ func buildSortedEntityInstances(entityType string, entityInstances map[string]*e
Title: e.Title,
UniqueKey: e.UniqueKey,
Type: entityType,
Fields: entityListFields(e.Data, properties),
})
}
return instances
@ -1515,17 +1502,100 @@ func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.Ge
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
}
res := buildEntityResponse(entity, req.Msg.Type, api.cfg.Dashboards)
res := buildEntityResponse(entity, req.Msg.Type, api.cfg)
res.RelatedActions = api.relatedActionsForEntity(user, req.Msg.Type, entity)
return connect.NewResponse(res), nil
}
func buildEntityResponse(entity *entities.Entity, entityType string, dashboards []*config.DashboardComponent) *apiv1.Entity {
func entityTypeIcon(cfg *config.Config, entityType string) string {
entityFile := entityFileForType(cfg, entityType)
if entityFile == nil {
return ""
}
return entityFile.Icon
}
func entityFileForType(cfg *config.Config, entityType string) *config.EntityFile {
for _, entityFile := range cfg.Entities {
if entityFile != nil && entityFile.Name == entityType {
return entityFile
}
}
return nil
}
func entityPropertiesFromFile(entityFile *config.EntityFile) []config.EntityProperty {
if entityFile == nil {
return nil
}
return entityFile.Properties
}
func entityDefinitionProperties(properties []config.EntityProperty) []*apiv1.EntityProperty {
if len(properties) == 0 {
return nil
}
result := make([]*apiv1.EntityProperty, 0, len(properties))
for _, property := range properties {
result = append(result, &apiv1.EntityProperty{
Name: property.Name,
Title: property.Title,
})
}
return result
}
func entityListFields(data any, properties []config.EntityProperty) map[string]string {
if len(properties) == 0 {
return nil
}
fields := make(map[string]string, len(properties))
for _, property := range properties {
fields[property.Name] = entityPropertyValue(data, property.Name)
}
return fields
}
func entityPropertyValue(data any, propertyName string) string {
dataMap, ok := data.(map[string]any)
if !ok {
return ""
}
if value, found := dataMap[propertyName]; found {
return fmt.Sprintf("%v", value)
}
return entityPropertyValueCaseInsensitive(dataMap, propertyName)
}
func entityPropertyValueCaseInsensitive(dataMap map[string]any, propertyName string) string {
propertyNameLower := strings.ToLower(propertyName)
for key, value := range dataMap {
if strings.ToLower(key) == propertyNameLower {
return fmt.Sprintf("%v", value)
}
}
return ""
}
func buildEntityResponse(entity *entities.Entity, entityType string, cfg *config.Config) *apiv1.Entity {
properties := entityPropertiesFromFile(entityFileForType(cfg, entityType))
res := &apiv1.Entity{
Title: entity.Title,
UniqueKey: entity.UniqueKey,
Type: entityType,
Directories: findDirectoriesInEntityFieldsets(entityType, dashboards),
Fields: serializeEntityFields(entity.Data),
Directories: findDirectoriesInEntityFieldsets(entityType, cfg.Dashboards),
Fields: entityFieldsForResponse(entity.Data, properties),
Icon: entityTypeIcon(cfg, entityType),
}
return res
}

View File

@ -0,0 +1,147 @@
package api
import (
"sort"
"strings"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
)
const defaultEntityInstancesPageSize = 10
func (api *oliveTinAPI) buildEntityDefinitionsResponse(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
if req != nil && req.EntityType != "" {
return api.buildFilteredEntityDefinitions(req, entityMap)
}
return api.buildAllEntityDefinitions(entityMap)
}
func (api *oliveTinAPI) buildAllEntityDefinitions(entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
entityNames := sortedEntityTypeNames(entityMap)
entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
for _, name := range entityNames {
entityFile := entityFileForType(api.cfg, name)
properties := entityPropertiesFromFile(entityFile)
instances := buildSortedEntityInstances(name, entityMap[name], properties)
def := &apiv1.EntityDefinition{
Title: name,
UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
Icon: entityTypeIcon(api.cfg, name),
Properties: entityDefinitionProperties(properties),
TotalInstances: int32(len(instances)),
}
if len(properties) == 0 {
def.Instances = instances
}
entityDefinitions = append(entityDefinitions, def)
}
return entityDefinitions
}
func (api *oliveTinAPI) buildFilteredEntityDefinitions(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
entityInstances, ok := entityMap[req.EntityType]
if !ok || len(entityInstances) == 0 {
return nil
}
entityFile := entityFileForType(api.cfg, req.EntityType)
properties := entityPropertiesFromFile(entityFile)
instances := buildSortedEntityInstances(req.EntityType, entityInstances, properties)
filtered := filterEntityInstances(instances, req.Filter)
pageSize := normalizeEntityInstancesPageSize(req.PageSize)
page := normalizeEntityInstancesPage(req.Page)
def := &apiv1.EntityDefinition{
Title: req.EntityType,
UsedOnDashboards: findDashboardsForEntity(req.EntityType, api.cfg.Dashboards),
Icon: entityTypeIcon(api.cfg, req.EntityType),
Properties: entityDefinitionProperties(properties),
TotalInstances: int32(len(filtered)),
Instances: paginateEntityInstances(filtered, page, pageSize),
}
return []*apiv1.EntityDefinition{def}
}
func sortedEntityTypeNames(entityMap entities.EntitiesByClass) []string {
entityNames := make([]string, 0, len(entityMap))
for name := range entityMap {
entityNames = append(entityNames, name)
}
sort.Strings(entityNames)
return entityNames
}
func normalizeEntityInstancesPage(page int32) int32 {
if page < 1 {
return 1
}
return page
}
func normalizeEntityInstancesPageSize(pageSize int32) int32 {
if pageSize < 1 {
return defaultEntityInstancesPageSize
}
return pageSize
}
func filterEntityInstances(instances []*apiv1.Entity, filter string) []*apiv1.Entity {
filter = strings.TrimSpace(strings.ToLower(filter))
if filter == "" {
return instances
}
filtered := make([]*apiv1.Entity, 0, len(instances))
for _, instance := range instances {
if entityInstanceMatchesFilter(instance, filter) {
filtered = append(filtered, instance)
}
}
return filtered
}
func entityInstanceMatchesFilter(instance *apiv1.Entity, filter string) bool {
if strings.Contains(strings.ToLower(instance.Title), filter) {
return true
}
for _, value := range instance.Fields {
if strings.Contains(strings.ToLower(value), filter) {
return true
}
}
return false
}
func paginateEntityInstances(instances []*apiv1.Entity, page, pageSize int32) []*apiv1.Entity {
start := int((page - 1) * pageSize)
if start >= len(instances) {
return []*apiv1.Entity{}
}
end := start + int(pageSize)
if end > len(instances) {
end = len(instances)
}
return instances[start:end]
}
func entityFieldsForResponse(data any, properties []config.EntityProperty) map[string]string {
if len(properties) > 0 {
return entityListFields(data, properties)
}
return serializeEntityFields(data)
}

View File

@ -0,0 +1,107 @@
package api
import (
"context"
"testing"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/executor"
)
func TestGetEntitiesPaginatesAndFiltersInstances(t *testing.T) {
entities.ClearEntitiesOfType("server")
entities.AddEntity("server", "0", map[string]any{"name": "alpha", "hostname": "alpha.example.com", "ip": "10.0.0.1"})
entities.AddEntity("server", "1", map[string]any{"name": "beta", "hostname": "beta.example.com", "ip": "10.0.0.2"})
entities.AddEntity("server", "2", map[string]any{"name": "gamma", "hostname": "gamma.example.com", "ip": "10.0.0.3"})
t.Cleanup(func() {
entities.ClearEntitiesOfType("server")
})
cfg := config.DefaultConfig()
cfg.Entities = []*config.EntityFile{
{
Name: "server",
Properties: []config.EntityProperty{
{Name: "hostname", Title: "Hostname"},
{Name: "ip", Title: "IP"},
},
},
}
cfg.Sanitize()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
filteredResp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
EntityType: "server",
Filter: "beta",
Page: 1,
PageSize: 10,
}))
require.NoError(t, err)
require.Len(t, filteredResp.Msg.EntityDefinitions, 1)
assert.Equal(t, int32(1), filteredResp.Msg.EntityDefinitions[0].TotalInstances)
require.Len(t, filteredResp.Msg.EntityDefinitions[0].Instances, 1)
assert.Equal(t, "beta.example.com", filteredResp.Msg.EntityDefinitions[0].Instances[0].Fields["hostname"])
pagedResp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
EntityType: "server",
Page: 2,
PageSize: 1,
}))
require.NoError(t, err)
require.Len(t, pagedResp.Msg.EntityDefinitions, 1)
assert.Equal(t, int32(3), pagedResp.Msg.EntityDefinitions[0].TotalInstances)
require.Len(t, pagedResp.Msg.EntityDefinitions[0].Instances, 1)
assert.Equal(t, "1", pagedResp.Msg.EntityDefinitions[0].Instances[0].UniqueKey)
}
func TestGetEntityRestrictsFieldsToConfiguredProperties(t *testing.T) {
entities.ClearEntitiesOfType("server")
entities.AddEntity("server", "0", map[string]any{
"name": "alpha",
"hostname": "alpha.example.com",
"ip": "10.0.0.1",
"groups": []string{"admins"},
})
t.Cleanup(func() {
entities.ClearEntitiesOfType("server")
})
cfg := config.DefaultConfig()
cfg.Entities = []*config.EntityFile{
{
Name: "server",
Properties: []config.EntityProperty{
{Name: "hostname", Title: "Hostname"},
{Name: "ip", Title: "IP"},
},
},
}
cfg.Sanitize()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
resp, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
Type: "server",
UniqueKey: "0",
}))
require.NoError(t, err)
require.NotNil(t, resp.Msg)
assert.Equal(t, "alpha.example.com", resp.Msg.Fields["hostname"])
assert.Equal(t, "10.0.0.1", resp.Msg.Fields["ip"])
assert.NotContains(t, resp.Msg.Fields, "groups")
assert.NotContains(t, resp.Msg.Fields, "name")
}

View File

@ -0,0 +1,126 @@
package api
import (
"sort"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/executor"
"github.com/OliveTin/OliveTin/internal/tpl"
)
type relatedActionCandidate struct {
binding *executor.ActionBinding
prefilled map[string]string
}
func (api *oliveTinAPI) relatedActionsForEntity(user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []*apiv1.EntityRelatedAction {
rr := api.createDashboardRenderRequest(user, entityType, entity.UniqueKey)
populateActiveBindingStates(rr)
candidates := collectRelatedActionCandidates(api, user, entityType, entity)
sortRelatedActionCandidates(candidates)
return buildEntityRelatedActions(candidates, rr)
}
func collectRelatedActionCandidates(api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []relatedActionCandidate {
seen := make(map[string]bool)
candidates := make([]relatedActionCandidate, 0)
api.executor.MapActionBindingsLock.RLock()
defer api.executor.MapActionBindingsLock.RUnlock()
for _, binding := range api.executor.MapActionBindings {
tryAppendRelatedCandidate(&candidates, seen, api, user, entityType, entity, binding)
}
return candidates
}
func tryAppendRelatedCandidate(candidates *[]relatedActionCandidate, seen map[string]bool, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity, binding *executor.ActionBinding) {
prefilled, ok := relatedPrefillForBinding(binding, entityType, entity)
if !ok || !bindingViewableForRelated(seen, api, user, binding) {
return
}
seen[binding.ID] = true
*candidates = append(*candidates, relatedActionCandidate{
binding: binding,
prefilled: prefilled,
})
}
func bindingViewableForRelated(seen map[string]bool, api *oliveTinAPI, user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) bool {
return binding != nil && binding.Action != nil && !seen[binding.ID] && api.userCanViewAction(user, binding.Action)
}
func relatedPrefillForBinding(binding *executor.ActionBinding, entityType string, entity *entities.Entity) (map[string]string, bool) {
if isEntityBoundBindingFor(binding, entityType, entity) {
return nil, true
}
return argumentEntityPrefill(binding, entityType, entity)
}
func argumentEntityPrefill(binding *executor.ActionBinding, entityType string, entity *entities.Entity) (map[string]string, bool) {
if binding == nil || binding.Entity != nil || binding.Action == nil {
return nil, false
}
prefilled := buildPrefilledArgumentsForEntity(binding.Action, entityType, entity)
return prefilled, len(prefilled) > 0
}
func isEntityBoundBindingFor(binding *executor.ActionBinding, entityType string, entity *entities.Entity) bool {
if entity == nil || !bindingHasEntity(binding) {
return false
}
return binding.Action.Entity == entityType && binding.Entity.UniqueKey == entity.UniqueKey
}
func bindingHasEntity(binding *executor.ActionBinding) bool {
return binding != nil && binding.Entity != nil && binding.Action != nil
}
func buildPrefilledArgumentsForEntity(action *config.Action, entityType string, entity *entities.Entity) map[string]string {
prefilled := make(map[string]string)
for i := range action.Arguments {
arg := &action.Arguments[i]
if arg.Entity != entityType || len(arg.Choices) != 1 {
continue
}
prefilled[arg.Name] = tpl.ParseTemplateOfActionBeforeExec(arg.Choices[0].Value, entity)
}
return prefilled
}
func sortRelatedActionCandidates(candidates []relatedActionCandidate) {
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].binding.ConfigOrder < candidates[j].binding.ConfigOrder
})
}
func buildEntityRelatedActions(candidates []relatedActionCandidate, rr *DashboardRenderRequest) []*apiv1.EntityRelatedAction {
result := make([]*apiv1.EntityRelatedAction, 0, len(candidates))
for _, candidate := range candidates {
action := buildAction(candidate.binding, rr)
if action == nil {
continue
}
result = append(result, &apiv1.EntityRelatedAction{
Action: action,
PrefilledArguments: candidate.prefilled,
})
}
return result
}

View File

@ -0,0 +1,270 @@
package api
import (
"context"
"testing"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/executor"
)
func setupHostEntityTestData(t *testing.T) {
t.Helper()
entities.ClearEntitiesOfType("host")
entities.AddEntity("host", "0", map[string]any{"name": "stuffbox", "hostname": "192.168.66.8"})
entities.AddEntity("host", "1", map[string]any{"name": "lurker", "hostname": "192.168.66.1"})
t.Cleanup(func() {
entities.ClearEntitiesOfType("host")
})
}
func buildRelatedActionsTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) {
t.Helper()
cfg := config.DefaultConfig()
cfg.DefaultPermissions.View = false
cfg.DefaultPermissions.Exec = false
cfg.Actions = append(cfg.Actions,
&config.Action{
Title: "Secret Entity Action",
Shell: "echo secret",
Entity: "host",
},
&config.Action{
Title: "Hidden Host Action",
Shell: "echo hidden",
Entity: "host",
Hidden: true,
},
&config.Action{
ID: "run_playbook",
Title: "Run Automation Playbook",
Shell: "host '{{ ansible_host }}'",
Arguments: []config.ActionArgument{
{
Name: "ansible_host",
Title: "Host",
Entity: "host",
Choices: []config.ActionArgumentChoice{
{Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"},
},
},
},
},
&config.Action{
Title: "Public Host Action",
Shell: "echo public",
Entity: "host",
},
)
cfg.AccessControlLists = append(cfg.AccessControlLists,
&config.AccessControlList{
Name: "restricted",
MatchUsernames: []string{"low"},
AddToEveryAction: true,
Permissions: config.PermissionsList{View: false, Exec: false, Logs: false, Kill: false},
},
&config.AccessControlList{
Name: "full",
MatchUsernames: []string{"admin"},
AddToEveryAction: true,
Permissions: config.PermissionsList{View: true, Exec: true, Logs: true, Kill: true},
},
)
cfg.Entities = []*config.EntityFile{
{File: "hosts.yaml", Name: "host", Icon: "ssh"},
}
cfg.Sanitize()
lowUser := &authpublic.AuthenticatedUser{Username: "low", Acls: []string{"restricted"}}
adminUser := &authpublic.AuthenticatedUser{Username: "admin", Acls: []string{"full"}}
return cfg, lowUser, adminUser
}
func getEntityRelatedActionTitles(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
t.Helper()
entity, ok := entities.GetEntityInstances(entityType)[entityKey]
require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
related := api.relatedActionsForEntity(user, entityType, entity)
titles := make([]string, 0, len(related))
for _, item := range related {
if item.Action != nil {
titles = append(titles, item.Action.Title)
}
}
return titles
}
func getEntityRelatedBindingIDs(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
t.Helper()
entity, ok := entities.GetEntityInstances(entityType)[entityKey]
require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
related := api.relatedActionsForEntity(user, entityType, entity)
ids := make([]string, 0, len(related))
for _, item := range related {
if item.Action != nil {
ids = append(ids, item.Action.BindingId)
}
}
return ids
}
func TestGetEntityRelatedActionsDeniesRestrictedView(t *testing.T) {
setupHostEntityTestData(t)
cfg, lowUser, _ := buildRelatedActionsTestConfig(t)
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
ids := getEntityRelatedBindingIDs(t, api, lowUser, "host", "0")
assert.Empty(t, ids)
titles := getEntityRelatedActionTitles(t, api, lowUser, "host", "0")
assert.Empty(t, titles)
}
func TestGetEntityRelatedActionsAllowsAdminView(t *testing.T) {
setupHostEntityTestData(t)
cfg, _, adminUser := buildRelatedActionsTestConfig(t)
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
assert.Contains(t, titles, "Run Automation Playbook")
assert.Contains(t, titles, "Public Host Action")
assert.Contains(t, titles, "Secret Entity Action")
}
func TestGetEntityRelatedActionsExcludesHiddenActions(t *testing.T) {
setupHostEntityTestData(t)
cfg, _, adminUser := buildRelatedActionsTestConfig(t)
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
assert.NotContains(t, titles, "Hidden Host Action")
}
func TestGetEntityRelatedActionsEntityBoundMatchesInstanceOnly(t *testing.T) {
setupHostEntityTestData(t)
cfg := config.DefaultConfig()
cfg.Actions = append(cfg.Actions, &config.Action{
Title: "{{ host.name }} Wake",
Shell: "echo wake",
Entity: "host",
})
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
titlesHost0 := getEntityRelatedActionTitles(t, api, user, "host", "0")
assert.Len(t, titlesHost0, 1)
assert.Equal(t, "stuffbox Wake", titlesHost0[0])
titlesHost1 := getEntityRelatedActionTitles(t, api, user, "host", "1")
assert.Len(t, titlesHost1, 1)
assert.Equal(t, "lurker Wake", titlesHost1[0])
}
func TestGetEntityRelatedActionsPrefillsArgumentEntityValues(t *testing.T) {
setupHostEntityTestData(t)
cfg := config.DefaultConfig()
cfg.Actions = append(cfg.Actions, &config.Action{
ID: "run_playbook",
Title: "Run Automation Playbook",
Shell: "host '{{ ansible_host }}'",
Arguments: []config.ActionArgument{
{
Name: "ansible_host",
Title: "Host",
Entity: "host",
Choices: []config.ActionArgumentChoice{
{Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"},
},
},
},
})
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
entity := entities.GetEntityInstances("host")["0"]
related := api.relatedActionsForEntity(user, "host", entity)
require.Len(t, related, 1)
require.NotNil(t, related[0].Action)
assert.Equal(t, "run_playbook", related[0].Action.BindingId)
assert.Equal(t, "192.168.66.8", related[0].PrefilledArguments["ansible_host"])
}
func TestGetEntityDeniesGuestsWhenLoginRequired(t *testing.T) {
setupHostEntityTestData(t)
cfg := config.DefaultConfig()
cfg.AuthRequireGuestsToLogin = true
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
_, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
Type: "host",
UniqueKey: "0",
}))
require.Error(t, err)
assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
}
func TestGetEntityReturnsRelatedActionsForAdmin(t *testing.T) {
setupHostEntityTestData(t)
cfg, _, adminUser := buildRelatedActionsTestConfig(t)
cfg.AuthHttpHeaderUsername = "X-Ot-User"
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
req := connect.NewRequest(&apiv1.GetEntityRequest{
Type: "host",
UniqueKey: "0",
})
req.Header().Set("X-Ot-User", adminUser.Username)
resp, err := client.GetEntity(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, resp.Msg)
assert.Equal(t, "&#128272;", resp.Msg.Icon)
foundPlaybook := false
for _, related := range resp.Msg.RelatedActions {
if related.Action != nil && related.Action.BindingId == "run_playbook" {
foundPlaybook = true
assert.Equal(t, "192.168.66.8", related.PrefilledArguments["ansible_host"])
}
}
assert.True(t, foundPlaybook, "admin should see argument-entity related action in GetEntity response")
}

View File

@ -120,6 +120,15 @@ func TestGetActionsAndStart(t *testing.T) {
func TestGetEntities(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Entities = []*config.EntityFile{
{
Name: "server",
Properties: []config.EntityProperty{
{Name: "hostname", Title: "Hostname"},
},
},
}
cfg.Sanitize()
ts, client := getNewTestServerAndClient(cfg)
defer ts.Close()
@ -138,6 +147,25 @@ func TestGetEntities(t *testing.T) {
validateEntityOrderAndStructure(t, entityDefinitions)
validateNoDuplicates(t, entityDefinitions)
validateConsistency(t, client, entityDefinitions)
validateEntityListProperties(t, client)
}
func validateEntityListProperties(t *testing.T, client apiv1connect.OliveTinApiServiceClient) {
resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
EntityType: "server",
Page: 1,
PageSize: 10,
}))
require.NoError(t, err)
serverDef := resp.Msg.EntityDefinitions[0]
require.NotNil(t, serverDef, "server entity definition should be present")
require.Len(t, serverDef.Properties, 1)
assert.Equal(t, "hostname", serverDef.Properties[0].Name)
assert.Equal(t, "Hostname", serverDef.Properties[0].Title)
assert.Equal(t, int32(3), serverDef.TotalInstances)
require.Len(t, serverDef.Instances, 3)
assert.Equal(t, "alpha.example.com", serverDef.Instances[0].Fields["hostname"])
}
func setupTestEntities() {
@ -166,10 +194,8 @@ func validateEntityOrderAndStructure(t *testing.T, entityDefinitions []*apiv1.En
assert.Equal(t, "postgres", entityDefinitions[1].Instances[1].UniqueKey, "Second database instance should be 'postgres' (alphabetically second)")
assert.Equal(t, "server", entityDefinitions[2].Title, "Third entity should be 'server' (alphabetically third)")
assert.Equal(t, 3, len(entityDefinitions[2].Instances), "Server should have 3 instances")
assert.Equal(t, "alpha", entityDefinitions[2].Instances[0].UniqueKey, "First server instance should be 'alpha' (alphabetically first)")
assert.Equal(t, "beta", entityDefinitions[2].Instances[1].UniqueKey, "Second server instance should be 'beta' (alphabetically second)")
assert.Equal(t, "zebra", entityDefinitions[2].Instances[2].UniqueKey, "Third server instance should be 'zebra' (alphabetically third)")
assert.Equal(t, 0, len(entityDefinitions[2].Instances), "Server instances should not be included in bulk list response")
assert.Equal(t, int32(3), entityDefinitions[2].TotalInstances, "Server should report total instance count")
}
func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) {

View File

@ -105,6 +105,13 @@ type EntityFile struct {
File string `koanf:"file"`
Name string `koanf:"name"`
Icon string `koanf:"icon"`
Properties []EntityProperty `koanf:"properties"`
}
// EntityProperty defines a column shown when listing entity instances in the UI.
type EntityProperty struct {
Name string `koanf:"name"`
Title string `koanf:"title"`
}
// PermissionsList defines what users can do with an action.

View File

@ -30,6 +30,7 @@ func (cfg *Config) Sanitize() {
cfg.sanitizeActionGroups()
cfg.sanitizeActionGroupReferences()
cfg.sanitizeEntities()
if err := cfg.validateReservedActionArgumentNames(); err != nil {
log.Fatalf("%v", err)
@ -291,6 +292,25 @@ func (cfg *Config) sanitizeActionGroupReferences() {
}
}
func (cfg *Config) sanitizeEntities() {
for _, entityFile := range cfg.Entities {
if entityFile == nil {
continue
}
entityFile.Icon = lookupHTMLIcon(entityFile.Icon, "")
sanitizeEntityProperties(entityFile)
}
}
func sanitizeEntityProperties(entityFile *EntityFile) {
for idx := range entityFile.Properties {
if entityFile.Properties[idx].Title == "" {
entityFile.Properties[idx].Title = entityFile.Properties[idx].Name
}
}
}
func (cfg *Config) warnInvalidActionGroupReference(action *Action, groupName string) {
group, found := cfg.ActionGroups[groupName]
if !found {