Add per-node action groups with schedules, runs, and logs.
Let operators manage ordered action groups on each host, run them manually or on a schedule over SSH, and inspect disk-backed JSON run logs from a node detail UI.
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func seedNodeForActions(t *testing.T, configDir string) settings.Node {
|
||||
t.Helper()
|
||||
node := settings.Node{
|
||||
ID: "11111111-1111-4111-8111-111111111111",
|
||||
Kind: settings.NodeKindContainer,
|
||||
Name: "box-1",
|
||||
HostIP: "127.0.0.1",
|
||||
Username: "root",
|
||||
GroupName: settings.AdministratorsGroupName,
|
||||
PublicKey: "ssh-ed25519 AAAA",
|
||||
KeyAlgo: "ed25519",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := settings.SaveNodes(configDir, settings.NodeStore{Nodes: []settings.Node{node}}); err != nil {
|
||||
t.Fatalf("SaveNodes: %v", err)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
node := seedNodeForActions(t, configDir)
|
||||
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
|
||||
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": "Nightly",
|
||||
"schedule": map[string]any{
|
||||
"enabled": false,
|
||||
"kind": "interval",
|
||||
"every_seconds": 3600,
|
||||
"times": []any{},
|
||||
},
|
||||
})
|
||||
createReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups",
|
||||
bytes.NewReader(createBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
var created actionGroupResponse
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create: %v", err)
|
||||
}
|
||||
groupID := created.Group.ID
|
||||
|
||||
addLibBody, _ := json.Marshal(map[string]any{
|
||||
"source": "library",
|
||||
"library_action_id": settings.BuiltinActionUptimeID,
|
||||
})
|
||||
addReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||
bytes.NewReader(addLibBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
addRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(addRec, addReq)
|
||||
if addRec.Code != http.StatusOK {
|
||||
t.Fatalf("add item status=%d body=%s", addRec.Code, addRec.Body.String())
|
||||
}
|
||||
var withItem actionGroupResponse
|
||||
if err := json.Unmarshal(addRec.Body.Bytes(), &withItem); err != nil {
|
||||
t.Fatalf("decode add: %v", err)
|
||||
}
|
||||
if len(withItem.Group.Items) != 1 || withItem.Group.Items[0].Source != settings.ActionItemSourceLibrary {
|
||||
t.Fatalf("items = %#v", withItem.Group.Items)
|
||||
}
|
||||
itemID := withItem.Group.Items[0].ID
|
||||
|
||||
addCustomBody, _ := json.Marshal(map[string]any{
|
||||
"source": "local",
|
||||
"name": "Echo",
|
||||
"kind": "shell",
|
||||
"body": "echo hi",
|
||||
"env": []any{},
|
||||
})
|
||||
addCustomReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||
bytes.NewReader(addCustomBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
addCustomRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(addCustomRec, addCustomReq)
|
||||
if addCustomRec.Code != http.StatusOK {
|
||||
t.Fatalf("add custom status=%d body=%s", addCustomRec.Code, addCustomRec.Body.String())
|
||||
}
|
||||
var withTwo actionGroupResponse
|
||||
_ = json.Unmarshal(addCustomRec.Body.Bytes(), &withTwo)
|
||||
if len(withTwo.Group.Items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(withTwo.Group.Items))
|
||||
}
|
||||
customID := withTwo.Group.Items[1].ID
|
||||
|
||||
orderBody, _ := json.Marshal(map[string]any{
|
||||
"item_ids": []string{customID, itemID},
|
||||
})
|
||||
orderReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/order",
|
||||
bytes.NewReader(orderBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
orderRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(orderRec, orderReq)
|
||||
if orderRec.Code != http.StatusOK {
|
||||
t.Fatalf("reorder status=%d body=%s", orderRec.Code, orderRec.Body.String())
|
||||
}
|
||||
var reordered actionGroupResponse
|
||||
_ = json.Unmarshal(orderRec.Body.Bytes(), &reordered)
|
||||
if reordered.Group.Items[0].ID != customID || reordered.Group.Items[1].ID != itemID {
|
||||
t.Fatalf("order = %#v", reordered.Group.Items)
|
||||
}
|
||||
|
||||
cloneReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+itemID+"/clone",
|
||||
nil,
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
cloneRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(cloneRec, cloneReq)
|
||||
if cloneRec.Code != http.StatusOK {
|
||||
t.Fatalf("clone status=%d body=%s", cloneRec.Code, cloneRec.Body.String())
|
||||
}
|
||||
var cloned actionGroupResponse
|
||||
_ = json.Unmarshal(cloneRec.Body.Bytes(), &cloned)
|
||||
var clonedItem settings.NodeActionItem
|
||||
for _, item := range cloned.Group.Items {
|
||||
if item.ID == itemID {
|
||||
clonedItem = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if clonedItem.Source != settings.ActionItemSourceLocal || clonedItem.Name != "Uptime" {
|
||||
t.Fatalf("cloned item = %#v", clonedItem)
|
||||
}
|
||||
|
||||
// Forbidden without actions.create for a limited user session is covered below via groups.
|
||||
listReq := withSession(
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-groups", nil),
|
||||
cookie,
|
||||
)
|
||||
listRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRec, listReq)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d", listRec.Code)
|
||||
}
|
||||
|
||||
logsReq := withSession(
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-logs", nil),
|
||||
cookie,
|
||||
)
|
||||
logsRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(logsRec, logsReq)
|
||||
if logsRec.Code != http.StatusOK {
|
||||
t.Fatalf("logs list status=%d body=%s", logsRec.Code, logsRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionGroupsForbiddenWithoutPermission(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
node := seedNodeForActions(t, configDir)
|
||||
|
||||
// Downgrade Administrators permissions to nodes.read only.
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
payload.Groups[0].Permissions = []string{"nodes.read"}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
body, _ := json.Marshal(map[string]any{"name": "Nope"})
|
||||
req := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups",
|
||||
bytes.NewReader(body),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user