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:
@@ -36,9 +36,10 @@ func main() {
|
||||
|
||||
listenHost := settings.ListenHost(configDir)
|
||||
listenAddr := listenHost + ":" + port
|
||||
handler, app := api.NewRouterWithApp(configDir)
|
||||
server := &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: api.NewRouter(configDir),
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
@@ -53,6 +54,10 @@ func main() {
|
||||
signal.Notify(stopSignals, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stopSignals
|
||||
|
||||
if app.Executor != nil {
|
||||
app.Executor.StopScheduler()
|
||||
}
|
||||
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
type actionGroupsResponse struct {
|
||||
Groups []settings.NodeActionGroup `json:"groups"`
|
||||
}
|
||||
|
||||
type actionGroupResponse struct {
|
||||
Group settings.NodeActionGroup `json:"group"`
|
||||
}
|
||||
|
||||
type actionRunResponse struct {
|
||||
Run settings.ActionGroupRunRecord `json:"run"`
|
||||
}
|
||||
|
||||
type actionLogsListResponse struct {
|
||||
Files []settings.ActionLogFileInfo `json:"files"`
|
||||
}
|
||||
|
||||
type actionLogFileResponse struct {
|
||||
Log settings.ActionLogFile `json:"log"`
|
||||
}
|
||||
|
||||
type createActionGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Schedule *settings.ActionGroupSchedule `json:"schedule"`
|
||||
}
|
||||
|
||||
type patchActionGroupRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Schedule *settings.ActionGroupSchedule `json:"schedule"`
|
||||
}
|
||||
|
||||
type createActionGroupItemRequest struct {
|
||||
Source settings.ActionItemSource `json:"source"`
|
||||
LibraryActionID string `json:"library_action_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Kind settings.ActionKind `json:"kind"`
|
||||
Body string `json:"body"`
|
||||
Env []settings.ActionEnvVar `json:"env"`
|
||||
}
|
||||
|
||||
type patchActionGroupItemRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Kind *settings.ActionKind `json:"kind"`
|
||||
Body *string `json:"body"`
|
||||
Env *[]settings.ActionEnvVar `json:"env"`
|
||||
}
|
||||
|
||||
type reorderActionGroupItemsRequest struct {
|
||||
ItemIDs []string `json:"item_ids"`
|
||||
}
|
||||
|
||||
func (app *App) actionGroupsListHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupsResponse{Groups: settings.GroupsForNode(store, node.ID)})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupsCreateHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsCreate(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var payload createActionGroupRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(payload.Name)
|
||||
if name == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
schedule := settings.ActionGroupSchedule{
|
||||
Enabled: false,
|
||||
Kind: settings.ActionGroupScheduleInterval,
|
||||
Times: []settings.ActionGroupTimeSpec{},
|
||||
}
|
||||
if payload.Schedule != nil {
|
||||
schedule = *payload.Schedule
|
||||
if schedule.Times == nil {
|
||||
schedule.Times = []settings.ActionGroupTimeSpec{}
|
||||
}
|
||||
}
|
||||
if err := validateSchedule(schedule); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
groupID, err := auth.NewUUID()
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
group := settings.NodeActionGroup{
|
||||
ID: groupID,
|
||||
NodeID: node.ID,
|
||||
Name: name,
|
||||
Schedule: schedule,
|
||||
Items: []settings.NodeActionItem{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
store.Groups = append(store.Groups, group)
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: group})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupsPatchHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
|
||||
var payload patchActionGroupRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if index < 0 || store.Groups[index].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
|
||||
group := store.Groups[index]
|
||||
if payload.Name != nil {
|
||||
name := strings.TrimSpace(*payload.Name)
|
||||
if name == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group name is required"})
|
||||
return
|
||||
}
|
||||
group.Name = name
|
||||
}
|
||||
if payload.Schedule != nil {
|
||||
schedule := *payload.Schedule
|
||||
if schedule.Times == nil {
|
||||
schedule.Times = []settings.ActionGroupTimeSpec{}
|
||||
}
|
||||
if err := validateSchedule(schedule); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
group.Schedule = schedule
|
||||
}
|
||||
group.UpdatedAt = time.Now().UTC()
|
||||
store.Groups[index] = group
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: group})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupsDeleteHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsDelete(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if index < 0 || store.Groups[index].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
store.Groups = append(store.Groups[:index], store.Groups[index+1:]...)
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupsResponse{Groups: settings.GroupsForNode(store, node.ID)})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupItemsCreateHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsCreate(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
|
||||
var payload createActionGroupItemRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
item, err := buildNodeActionItem(payload)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if index < 0 || store.Groups[index].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if item.Source == settings.ActionItemSourceLibrary {
|
||||
library, libErr := settings.LoadActionsOrSeed(app.ConfigDir)
|
||||
if libErr != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: libErr.Error()})
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, action := range library.Actions {
|
||||
if action.ID == item.LibraryActionID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library action not found"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
store.Groups[index].Items = append(store.Groups[index].Items, item)
|
||||
store.Groups[index].UpdatedAt = time.Now().UTC()
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[index]})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
itemID := strings.TrimSpace(request.PathValue("iid"))
|
||||
|
||||
var payload patchActionGroupItemRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID)
|
||||
if itemIndex < 0 {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
|
||||
return
|
||||
}
|
||||
item := store.Groups[groupIndex].Items[itemIndex]
|
||||
if item.Source != settings.ActionItemSourceLocal {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library actions must be cloned before editing"})
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Name != nil {
|
||||
item.Name = strings.TrimSpace(*payload.Name)
|
||||
}
|
||||
if payload.Description != nil {
|
||||
item.Description = strings.TrimSpace(*payload.Description)
|
||||
}
|
||||
if payload.Kind != nil {
|
||||
item.Kind = *payload.Kind
|
||||
}
|
||||
if payload.Body != nil {
|
||||
item.Body = strings.TrimSpace(*payload.Body)
|
||||
}
|
||||
if payload.Env != nil {
|
||||
item.Env = *payload.Env
|
||||
}
|
||||
if item.Env == nil {
|
||||
item.Env = []settings.ActionEnvVar{}
|
||||
}
|
||||
if err := validateActionFields(item.Name, item.Kind, item.Body, item.Env); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store.Groups[groupIndex].Items[itemIndex] = item
|
||||
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupItemsCloneHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsCreate(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
itemID := strings.TrimSpace(request.PathValue("iid"))
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID)
|
||||
if itemIndex < 0 {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
|
||||
return
|
||||
}
|
||||
item := store.Groups[groupIndex].Items[itemIndex]
|
||||
if item.Source != settings.ActionItemSourceLibrary {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "only library actions can be cloned"})
|
||||
return
|
||||
}
|
||||
|
||||
library, err := settings.LoadActionsOrSeed(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
var libraryAction settings.Action
|
||||
found := false
|
||||
for _, action := range library.Actions {
|
||||
if action.ID == item.LibraryActionID {
|
||||
libraryAction = action
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library action not found"})
|
||||
return
|
||||
}
|
||||
|
||||
env := libraryAction.Env
|
||||
if env == nil {
|
||||
env = []settings.ActionEnvVar{}
|
||||
}
|
||||
item.Source = settings.ActionItemSourceLocal
|
||||
item.LibraryActionID = ""
|
||||
item.Name = libraryAction.Name
|
||||
item.Description = libraryAction.Description
|
||||
item.Kind = libraryAction.Kind
|
||||
item.Body = libraryAction.Body
|
||||
item.Env = env
|
||||
|
||||
store.Groups[groupIndex].Items[itemIndex] = item
|
||||
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupItemsDeleteHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsDelete(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
itemID := strings.TrimSpace(request.PathValue("iid"))
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID)
|
||||
if itemIndex < 0 {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
|
||||
return
|
||||
}
|
||||
items := store.Groups[groupIndex].Items
|
||||
store.Groups[groupIndex].Items = append(items[:itemIndex], items[itemIndex+1:]...)
|
||||
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupItemsOrderHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
|
||||
var payload reorderActionGroupItemsRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
|
||||
current := store.Groups[groupIndex].Items
|
||||
if len(payload.ItemIDs) != len(current) {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "item_ids must include every item exactly once"})
|
||||
return
|
||||
}
|
||||
byID := make(map[string]settings.NodeActionItem, len(current))
|
||||
for _, item := range current {
|
||||
byID[item.ID] = item
|
||||
}
|
||||
reordered := make([]settings.NodeActionItem, 0, len(payload.ItemIDs))
|
||||
seen := make(map[string]struct{}, len(payload.ItemIDs))
|
||||
for _, itemID := range payload.ItemIDs {
|
||||
item, exists := byID[itemID]
|
||||
if !exists {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "unknown item id in order list"})
|
||||
return
|
||||
}
|
||||
if _, dup := seen[itemID]; dup {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "duplicate item id in order list"})
|
||||
return
|
||||
}
|
||||
seen[itemID] = struct{}{}
|
||||
reordered = append(reordered, item)
|
||||
}
|
||||
store.Groups[groupIndex].Items = reordered
|
||||
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeJobsRun(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
group, found := app.findGroupForNode(node.ID, groupID)
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
if app.Executor == nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "executor unavailable"})
|
||||
return
|
||||
}
|
||||
actor := ""
|
||||
if user, ok := UserFromContext(request.Context()); ok {
|
||||
actor = user.Username
|
||||
}
|
||||
record, err := app.Executor.RunGroup(group, settings.ActionRunTriggerManual, actor)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionRunResponse{Run: record})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupItemsRunHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeJobsRun(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
itemID := strings.TrimSpace(request.PathValue("iid"))
|
||||
group, found := app.findGroupForNode(node.ID, groupID)
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
itemIndex := settings.FindNodeActionItemIndex(group.Items, itemID)
|
||||
if itemIndex < 0 {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
|
||||
return
|
||||
}
|
||||
if app.Executor == nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "executor unavailable"})
|
||||
return
|
||||
}
|
||||
actor := ""
|
||||
if user, ok := UserFromContext(request.Context()); ok {
|
||||
actor = user.Username
|
||||
}
|
||||
record, err := app.Executor.RunItem(group, group.Items[itemIndex], settings.ActionRunTriggerManual, actor)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionRunResponse{Run: record})
|
||||
}
|
||||
|
||||
func (app *App) actionLogsListHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeLogsRead(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
files, err := settings.ListActionLogFiles(app.ConfigDir, node.ID)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionLogsListResponse{Files: files})
|
||||
}
|
||||
|
||||
func (app *App) actionLogsGetHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeLogsRead(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filename := strings.TrimSpace(request.PathValue("filename"))
|
||||
logFile, err := settings.ReadActionLogFileByName(app.ConfigDir, node.ID, filename)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "invalid log filename") {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "log file not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "log file not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionLogFileResponse{Log: logFile})
|
||||
}
|
||||
|
||||
func (app *App) loadAccessibleNode(writer http.ResponseWriter, request *http.Request) (settings.Node, bool) {
|
||||
if err := app.authorizeNodesRead(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
nodeID := strings.TrimSpace(request.PathValue("id"))
|
||||
if nodeID == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
for _, node := range store.Nodes {
|
||||
if node.ID != nodeID {
|
||||
continue
|
||||
}
|
||||
if !userCanAccessNode(user, settingsPayload.Groups, node) {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
return node, true
|
||||
}
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
|
||||
func (app *App) findGroupForNode(nodeID string, groupID string) (settings.NodeActionGroup, bool) {
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
return settings.NodeActionGroup{}, false
|
||||
}
|
||||
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if index < 0 || store.Groups[index].NodeID != nodeID {
|
||||
return settings.NodeActionGroup{}, false
|
||||
}
|
||||
return store.Groups[index], true
|
||||
}
|
||||
|
||||
func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeActionItem, error) {
|
||||
itemID, err := auth.NewUUID()
|
||||
if err != nil {
|
||||
return settings.NodeActionItem{}, err
|
||||
}
|
||||
switch payload.Source {
|
||||
case settings.ActionItemSourceLibrary:
|
||||
libraryID := strings.TrimSpace(payload.LibraryActionID)
|
||||
if libraryID == "" {
|
||||
return settings.NodeActionItem{}, errors.New("library_action_id is required")
|
||||
}
|
||||
return settings.NodeActionItem{
|
||||
ID: itemID,
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
LibraryActionID: libraryID,
|
||||
Env: []settings.ActionEnvVar{},
|
||||
}, nil
|
||||
case settings.ActionItemSourceLocal:
|
||||
env := payload.Env
|
||||
if env == nil {
|
||||
env = []settings.ActionEnvVar{}
|
||||
}
|
||||
name := strings.TrimSpace(payload.Name)
|
||||
body := strings.TrimSpace(payload.Body)
|
||||
if err := validateActionFields(name, payload.Kind, body, env); err != nil {
|
||||
return settings.NodeActionItem{}, err
|
||||
}
|
||||
return settings.NodeActionItem{
|
||||
ID: itemID,
|
||||
Source: settings.ActionItemSourceLocal,
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(payload.Description),
|
||||
Kind: payload.Kind,
|
||||
Body: body,
|
||||
Env: env,
|
||||
}, nil
|
||||
default:
|
||||
return settings.NodeActionItem{}, errors.New("source must be library or local")
|
||||
}
|
||||
}
|
||||
|
||||
func validateSchedule(schedule settings.ActionGroupSchedule) error {
|
||||
switch schedule.Kind {
|
||||
case settings.ActionGroupScheduleInterval:
|
||||
if schedule.Enabled && schedule.EverySeconds < 1 {
|
||||
return errors.New("every_seconds must be at least 1 when interval schedule is enabled")
|
||||
}
|
||||
return nil
|
||||
case settings.ActionGroupScheduleTimes:
|
||||
if schedule.Enabled && len(schedule.Times) == 0 {
|
||||
return errors.New("at least one time is required when times schedule is enabled")
|
||||
}
|
||||
for _, spec := range schedule.Times {
|
||||
hour, minute, ok := parseScheduleHHMM(spec.Time)
|
||||
if !ok {
|
||||
return errors.New("time must be HH:MM")
|
||||
}
|
||||
_ = hour
|
||||
_ = minute
|
||||
for _, day := range spec.DaysOfWeek {
|
||||
if day < 0 || day > 6 {
|
||||
return errors.New("days_of_week values must be 0-6")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case "":
|
||||
return errors.New("schedule kind is required")
|
||||
default:
|
||||
return errors.New("schedule kind must be interval or times")
|
||||
}
|
||||
}
|
||||
|
||||
func parseScheduleHHMM(value string) (hour int, minute int, ok bool) {
|
||||
parts := strings.Split(strings.TrimSpace(value), ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if _, err := fmtSscanf(parts[0], &hour); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if _, err := fmtSscanf(parts[1], &minute); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return hour, minute, true
|
||||
}
|
||||
|
||||
func fmtSscanf(input string, dest *int) (int, error) {
|
||||
n := 0
|
||||
for _, ch := range input {
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0, errors.New("not a number")
|
||||
}
|
||||
n = n*10 + int(ch-'0')
|
||||
}
|
||||
if input == "" {
|
||||
return 0, errors.New("empty")
|
||||
}
|
||||
*dest = n
|
||||
return 1, nil
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ var errNodesDeleteRequired = errors.New("permission nodes.delete required")
|
||||
var errActionsCreateRequired = errors.New("permission actions.create required")
|
||||
var errActionsUpdateRequired = errors.New("permission actions.update required")
|
||||
var errActionsDeleteRequired = errors.New("permission actions.delete required")
|
||||
var errJobsRunRequired = errors.New("permission jobs.run required")
|
||||
var errLogsReadRequired = errors.New("permission logs.read required")
|
||||
|
||||
// permissionsForUser returns the union of permissions from the user's groups.
|
||||
@@ -160,3 +161,19 @@ func authorizeActionsUpdate(request *http.Request, configDir string) error {
|
||||
func authorizeActionsDelete(request *http.Request, configDir string) error {
|
||||
return authorizeNamedPermission(request, configDir, "actions.delete", errActionsDeleteRequired)
|
||||
}
|
||||
|
||||
func (app *App) authorizeJobsRun(request *http.Request) error {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
|
||||
payload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !userHasPermission(user, payload.Groups, "jobs.run") {
|
||||
return errJobsRunRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,11 +8,20 @@ const defaultAllowedOrigin = "http://localhost:5173"
|
||||
|
||||
// NewRouter builds the HTTP API with setup gating, auth, and network middleware.
|
||||
func NewRouter(configDir string) http.Handler {
|
||||
handler, _ := NewRouterWithApp(configDir)
|
||||
return handler
|
||||
}
|
||||
|
||||
// NewRouterWithApp builds the router and returns the App for lifecycle control (scheduler stop).
|
||||
func NewRouterWithApp(configDir string) (http.Handler, *App) {
|
||||
app, err := newApp(configDir)
|
||||
if err != nil {
|
||||
// newApp currently always succeeds; keep signature for future.
|
||||
panic(err)
|
||||
}
|
||||
if app.Executor != nil {
|
||||
app.Executor.StartScheduler()
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", HealthHandler)
|
||||
@@ -55,8 +64,21 @@ func NewRouter(configDir string) http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
|
||||
mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
|
||||
mux.HandleFunc("GET /api/v1/nodes/{id}/action-groups", app.actionGroupsListHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups", app.actionGroupsCreateHandler)
|
||||
mux.HandleFunc("PATCH /api/v1/nodes/{id}/action-groups/{gid}", app.actionGroupsPatchHandler)
|
||||
mux.HandleFunc("DELETE /api/v1/nodes/{id}/action-groups/{gid}", app.actionGroupsDeleteHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items", app.actionGroupItemsCreateHandler)
|
||||
mux.HandleFunc("PATCH /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}", app.actionGroupItemsPatchHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}/clone", app.actionGroupItemsCloneHandler)
|
||||
mux.HandleFunc("DELETE /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}", app.actionGroupItemsDeleteHandler)
|
||||
mux.HandleFunc("PUT /api/v1/nodes/{id}/action-groups/{gid}/items/order", app.actionGroupItemsOrderHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/run", app.actionGroupsRunHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}/run", app.actionGroupItemsRunHandler)
|
||||
mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs", app.actionLogsListHandler)
|
||||
mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs/{filename}", app.actionLogsGetHandler)
|
||||
mux.HandleFunc("GET /api/v1/node-logs", app.nodeLogsListHandler)
|
||||
mux.HandleFunc("GET /api/v1/auth-logs", app.authLogsListHandler)
|
||||
|
||||
return app.withCORS(app.withMiddleware(mux))
|
||||
return app.withCORS(app.withMiddleware(mux)), app
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
@@ -79,6 +80,7 @@ type App struct {
|
||||
ConfigDir string
|
||||
Key []byte
|
||||
Sessions *auth.SessionManager
|
||||
Executor *runner.Executor
|
||||
|
||||
pendingTOTPMu sync.Mutex
|
||||
pendingTOTP map[string]string // username -> secret (setup only)
|
||||
@@ -88,17 +90,21 @@ func newApp(configDir string) (*App, error) {
|
||||
key, err := settings.KeyFromEnv()
|
||||
if err != nil {
|
||||
// Key is required for setup complete / auth; status still works without it.
|
||||
return &App{
|
||||
app := &App{
|
||||
ConfigDir: configDir,
|
||||
pendingTOTP: map[string]string{},
|
||||
}, nil
|
||||
Executor: runner.NewExecutor(configDir, nil),
|
||||
}
|
||||
return &App{
|
||||
return app, nil
|
||||
}
|
||||
app := &App{
|
||||
ConfigDir: configDir,
|
||||
Key: key,
|
||||
Sessions: auth.NewSessionManager(configDir, key),
|
||||
pendingTOTP: map[string]string{},
|
||||
}, nil
|
||||
Executor: runner.NewExecutor(configDir, key),
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (app *App) requireKey() error {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var placeholderPattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}`)
|
||||
|
||||
// PlaceholderContext holds values used to expand action body placeholders.
|
||||
type PlaceholderContext struct {
|
||||
Host string
|
||||
IP string
|
||||
Username string
|
||||
Env map[string]string
|
||||
Secrets map[string]string
|
||||
}
|
||||
|
||||
// ExpandPlaceholders replaces {{cc.host}}, {{node.ip}}, {{env.NAME}}, etc.
|
||||
// Unknown or unresolved secrets expand to empty strings.
|
||||
func ExpandPlaceholders(body string, ctx PlaceholderContext) string {
|
||||
return placeholderPattern.ReplaceAllStringFunc(body, func(match string) string {
|
||||
submatches := placeholderPattern.FindStringSubmatch(match)
|
||||
if len(submatches) < 2 {
|
||||
return match
|
||||
}
|
||||
key := strings.TrimSpace(submatches[1])
|
||||
switch key {
|
||||
case "cc.host", "node.host":
|
||||
return ctx.Host
|
||||
case "cc.ip", "node.ip":
|
||||
return ctx.IP
|
||||
case "node.username":
|
||||
return ctx.Username
|
||||
default:
|
||||
if strings.HasPrefix(key, "env.") {
|
||||
name := strings.TrimPrefix(key, "env.")
|
||||
if ctx.Env != nil {
|
||||
return ctx.Env[name]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(key, "secret.") {
|
||||
name := strings.TrimPrefix(key, "secret.")
|
||||
if ctx.Secrets != nil {
|
||||
return ctx.Secrets[name]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExpandPlaceholders(t *testing.T) {
|
||||
body := "ping {{node.ip}} as {{node.username}} host={{cc.host}} env={{env.FOO}} secret={{secret.TOKEN}} unknown={{nope}}"
|
||||
got := ExpandPlaceholders(body, PlaceholderContext{
|
||||
Host: "web-1",
|
||||
IP: "10.0.0.5",
|
||||
Username: "deploy",
|
||||
Env: map[string]string{"FOO": "bar"},
|
||||
Secrets: map[string]string{},
|
||||
})
|
||||
want := "ping 10.0.0.5 as deploy host=web-1 env=bar secret= unknown="
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const defaultSSHRunTimeout = 5 * time.Minute
|
||||
|
||||
// SSHRunResult captures stdout, stderr, and exit status from a remote command.
|
||||
type SSHRunResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
// RunSSHShell runs a single shell command on the remote host over SSH.
|
||||
func RunSSHShell(
|
||||
hostIP string,
|
||||
username string,
|
||||
privateKeyPEM string,
|
||||
passphrase string,
|
||||
command string,
|
||||
env map[string]string,
|
||||
timeout time.Duration,
|
||||
) (SSHRunResult, error) {
|
||||
if strings.TrimSpace(command) == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("command is required")
|
||||
}
|
||||
fullCommand := prependEnvExports(env, command)
|
||||
return runSSHSession(hostIP, username, privateKeyPEM, passphrase, fullCommand, timeout)
|
||||
}
|
||||
|
||||
// RunSSHScript uploads and runs a multi-line script via bash -s on the remote host.
|
||||
func RunSSHScript(
|
||||
hostIP string,
|
||||
username string,
|
||||
privateKeyPEM string,
|
||||
passphrase string,
|
||||
scriptBody string,
|
||||
env map[string]string,
|
||||
timeout time.Duration,
|
||||
) (SSHRunResult, error) {
|
||||
if strings.TrimSpace(scriptBody) == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("script body is required")
|
||||
}
|
||||
|
||||
hostIP = strings.TrimSpace(hostIP)
|
||||
username = strings.TrimSpace(username)
|
||||
if hostIP == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("host IP is required")
|
||||
}
|
||||
if username == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("username is required")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = defaultSSHRunTimeout
|
||||
}
|
||||
|
||||
signer, err := parseSigner(privateKeyPEM, passphrase)
|
||||
if err != nil {
|
||||
return SSHRunResult{}, err
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: sshDialTimeout,
|
||||
}
|
||||
|
||||
address := net.JoinHostPort(hostIP, defaultSSHPort)
|
||||
client, err := ssh.Dial("tcp", address, config)
|
||||
if err != nil {
|
||||
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
var stdoutBuf bytes.Buffer
|
||||
var stderrBuf bytes.Buffer
|
||||
session.Stdout = &stdoutBuf
|
||||
session.Stderr = &stderrBuf
|
||||
session.Stdin = strings.NewReader(scriptBody)
|
||||
|
||||
remoteCommand := prependEnvExports(env, "bash -s")
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- session.Run(remoteCommand)
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
|
||||
case <-timer.C:
|
||||
_ = session.Close()
|
||||
return SSHRunResult{
|
||||
Stdout: stdoutBuf.String(),
|
||||
Stderr: stderrBuf.String(),
|
||||
ExitCode: -1,
|
||||
}, fmt.Errorf("ssh command timed out after %s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func runSSHSession(
|
||||
hostIP string,
|
||||
username string,
|
||||
privateKeyPEM string,
|
||||
passphrase string,
|
||||
command string,
|
||||
timeout time.Duration,
|
||||
) (SSHRunResult, error) {
|
||||
hostIP = strings.TrimSpace(hostIP)
|
||||
username = strings.TrimSpace(username)
|
||||
if hostIP == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("host IP is required")
|
||||
}
|
||||
if username == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("username is required")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = defaultSSHRunTimeout
|
||||
}
|
||||
|
||||
signer, err := parseSigner(privateKeyPEM, passphrase)
|
||||
if err != nil {
|
||||
return SSHRunResult{}, err
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: sshDialTimeout,
|
||||
}
|
||||
|
||||
address := net.JoinHostPort(hostIP, defaultSSHPort)
|
||||
client, err := ssh.Dial("tcp", address, config)
|
||||
if err != nil {
|
||||
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
var stdoutBuf bytes.Buffer
|
||||
var stderrBuf bytes.Buffer
|
||||
session.Stdout = &stdoutBuf
|
||||
session.Stderr = &stderrBuf
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- session.Run(command)
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
|
||||
case <-timer.C:
|
||||
_ = session.Close()
|
||||
return SSHRunResult{
|
||||
Stdout: stdoutBuf.String(),
|
||||
Stderr: stderrBuf.String(),
|
||||
ExitCode: -1,
|
||||
}, fmt.Errorf("ssh command timed out after %s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func finishSSHResult(stdout string, stderr string, err error) (SSHRunResult, error) {
|
||||
result := SSHRunResult{
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: 0,
|
||||
}
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if exitErr, ok := err.(*ssh.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitStatus()
|
||||
return result, nil
|
||||
}
|
||||
result.ExitCode = -1
|
||||
return result, err
|
||||
}
|
||||
|
||||
func prependEnvExports(env map[string]string, command string) string {
|
||||
if len(env) == 0 {
|
||||
return command
|
||||
}
|
||||
var builder strings.Builder
|
||||
for name, value := range env {
|
||||
builder.WriteString("export ")
|
||||
builder.WriteString(shellQuote(name))
|
||||
builder.WriteString("=")
|
||||
builder.WriteString(shellQuote(value))
|
||||
builder.WriteString("; ")
|
||||
}
|
||||
builder.WriteString(command)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
const defaultRunTimeout = 5 * time.Minute
|
||||
|
||||
// Executor runs action groups and items against nodes over SSH.
|
||||
type Executor struct {
|
||||
ConfigDir string
|
||||
Key []byte
|
||||
|
||||
mu sync.Mutex
|
||||
running map[string]struct{} // groupID -> running
|
||||
stopCh chan struct{}
|
||||
stopped chan struct{}
|
||||
tickerOn bool
|
||||
}
|
||||
|
||||
// NewExecutor creates an Executor for the given config directory and encryption key.
|
||||
func NewExecutor(configDir string, key []byte) *Executor {
|
||||
return &Executor{
|
||||
ConfigDir: configDir,
|
||||
Key: key,
|
||||
running: map[string]struct{}{},
|
||||
stopCh: make(chan struct{}),
|
||||
stopped: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// StartScheduler begins the background schedule ticker (30s).
|
||||
func (executor *Executor) StartScheduler() {
|
||||
executor.mu.Lock()
|
||||
if executor.tickerOn {
|
||||
executor.mu.Unlock()
|
||||
return
|
||||
}
|
||||
executor.tickerOn = true
|
||||
executor.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer close(executor.stopped)
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-executor.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
executor.tickSchedules()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StopScheduler stops the background ticker and waits for it to exit.
|
||||
func (executor *Executor) StopScheduler() {
|
||||
executor.mu.Lock()
|
||||
if !executor.tickerOn {
|
||||
executor.mu.Unlock()
|
||||
return
|
||||
}
|
||||
executor.tickerOn = false
|
||||
executor.mu.Unlock()
|
||||
close(executor.stopCh)
|
||||
<-executor.stopped
|
||||
}
|
||||
|
||||
func (executor *Executor) tryLockGroup(groupID string) bool {
|
||||
executor.mu.Lock()
|
||||
defer executor.mu.Unlock()
|
||||
if _, ok := executor.running[groupID]; ok {
|
||||
return false
|
||||
}
|
||||
executor.running[groupID] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (executor *Executor) unlockGroup(groupID string) {
|
||||
executor.mu.Lock()
|
||||
defer executor.mu.Unlock()
|
||||
delete(executor.running, groupID)
|
||||
}
|
||||
|
||||
func (executor *Executor) tickSchedules() {
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(executor.ConfigDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for _, group := range store.Groups {
|
||||
if !settings.IsScheduleDue(group, now) {
|
||||
continue
|
||||
}
|
||||
groupCopy := group
|
||||
go func() {
|
||||
_, _ = executor.RunGroup(groupCopy, settings.ActionRunTriggerSchedule, "scheduler")
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvedAction is the effective action definition after library resolution.
|
||||
type ResolvedAction struct {
|
||||
Name string
|
||||
Description string
|
||||
Kind settings.ActionKind
|
||||
Body string
|
||||
Env []settings.ActionEnvVar
|
||||
Source settings.ActionItemSource
|
||||
}
|
||||
|
||||
// ResolveItem resolves a library or local item to an executable action.
|
||||
func ResolveItem(item settings.NodeActionItem, library []settings.Action) (ResolvedAction, error) {
|
||||
switch item.Source {
|
||||
case settings.ActionItemSourceLibrary:
|
||||
if strings.TrimSpace(item.LibraryActionID) == "" {
|
||||
return ResolvedAction{}, fmt.Errorf("library action id is required")
|
||||
}
|
||||
for _, action := range library {
|
||||
if action.ID == item.LibraryActionID {
|
||||
env := action.Env
|
||||
if env == nil {
|
||||
env = []settings.ActionEnvVar{}
|
||||
}
|
||||
return ResolvedAction{
|
||||
Name: action.Name,
|
||||
Description: action.Description,
|
||||
Kind: action.Kind,
|
||||
Body: action.Body,
|
||||
Env: env,
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return ResolvedAction{}, fmt.Errorf("library action %q not found", item.LibraryActionID)
|
||||
case settings.ActionItemSourceLocal:
|
||||
if strings.TrimSpace(item.Name) == "" {
|
||||
return ResolvedAction{}, fmt.Errorf("local action name is required")
|
||||
}
|
||||
if strings.TrimSpace(item.Body) == "" {
|
||||
return ResolvedAction{}, fmt.Errorf("local action body is required")
|
||||
}
|
||||
if item.Kind != settings.ActionKindShell && item.Kind != settings.ActionKindScript {
|
||||
return ResolvedAction{}, fmt.Errorf("local action kind must be shell or script")
|
||||
}
|
||||
env := item.Env
|
||||
if env == nil {
|
||||
env = []settings.ActionEnvVar{}
|
||||
}
|
||||
return ResolvedAction{
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
Kind: item.Kind,
|
||||
Body: item.Body,
|
||||
Env: env,
|
||||
Source: settings.ActionItemSourceLocal,
|
||||
}, nil
|
||||
default:
|
||||
return ResolvedAction{}, fmt.Errorf("unknown action item source %q", item.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// RunGroup executes all items in order, appends a log file entry, and updates LastRunAt.
|
||||
func (executor *Executor) RunGroup(
|
||||
group settings.NodeActionGroup,
|
||||
trigger settings.ActionRunTrigger,
|
||||
actor string,
|
||||
) (settings.ActionGroupRunRecord, error) {
|
||||
if !executor.tryLockGroup(group.ID) {
|
||||
return settings.ActionGroupRunRecord{}, fmt.Errorf("action group is already running")
|
||||
}
|
||||
defer executor.unlockGroup(group.ID)
|
||||
|
||||
return executor.runItems(group, group.Items, trigger, actor)
|
||||
}
|
||||
|
||||
// RunItem executes a single item (still logged under the parent group file).
|
||||
func (executor *Executor) RunItem(
|
||||
group settings.NodeActionGroup,
|
||||
item settings.NodeActionItem,
|
||||
trigger settings.ActionRunTrigger,
|
||||
actor string,
|
||||
) (settings.ActionGroupRunRecord, error) {
|
||||
lockKey := group.ID + ":" + item.ID
|
||||
if !executor.tryLockGroup(lockKey) {
|
||||
return settings.ActionGroupRunRecord{}, fmt.Errorf("action is already running")
|
||||
}
|
||||
defer executor.unlockGroup(lockKey)
|
||||
|
||||
return executor.runItems(group, []settings.NodeActionItem{item}, trigger, actor)
|
||||
}
|
||||
|
||||
func (executor *Executor) runItems(
|
||||
group settings.NodeActionGroup,
|
||||
items []settings.NodeActionItem,
|
||||
trigger settings.ActionRunTrigger,
|
||||
actor string,
|
||||
) (settings.ActionGroupRunRecord, error) {
|
||||
runID, err := auth.NewUUID()
|
||||
if err != nil {
|
||||
return settings.ActionGroupRunRecord{}, err
|
||||
}
|
||||
|
||||
node, privateKey, passphrase, err := executor.loadNodeCredentials(group.NodeID)
|
||||
if err != nil {
|
||||
return settings.ActionGroupRunRecord{}, err
|
||||
}
|
||||
|
||||
libraryStore, err := settings.LoadActionsOrSeed(executor.ConfigDir)
|
||||
if err != nil {
|
||||
return settings.ActionGroupRunRecord{}, err
|
||||
}
|
||||
|
||||
startedAt := time.Now().UTC()
|
||||
results := make([]settings.ActionItemRunResult, 0, len(items))
|
||||
|
||||
for _, item := range items {
|
||||
itemStarted := time.Now().UTC()
|
||||
resolved, resolveErr := ResolveItem(item, libraryStore.Actions)
|
||||
if resolveErr != nil {
|
||||
results = append(results, settings.ActionItemRunResult{
|
||||
ItemID: item.ID,
|
||||
ActionName: item.Name,
|
||||
Source: string(item.Source),
|
||||
ExitCode: -1,
|
||||
Stdout: "",
|
||||
Stderr: "",
|
||||
Error: resolveErr.Error(),
|
||||
StartedAt: itemStarted,
|
||||
FinishedAt: time.Now().UTC(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
envMap := make(map[string]string, len(resolved.Env))
|
||||
for _, envVar := range resolved.Env {
|
||||
envMap[envVar.Name] = envVar.Value
|
||||
}
|
||||
|
||||
expandedBody := auth.ExpandPlaceholders(resolved.Body, auth.PlaceholderContext{
|
||||
Host: node.Name,
|
||||
IP: node.HostIP,
|
||||
Username: node.Username,
|
||||
Env: envMap,
|
||||
Secrets: map[string]string{},
|
||||
})
|
||||
|
||||
var sshResult auth.SSHRunResult
|
||||
var runErr error
|
||||
switch resolved.Kind {
|
||||
case settings.ActionKindShell:
|
||||
sshResult, runErr = auth.RunSSHShell(
|
||||
node.HostIP,
|
||||
node.Username,
|
||||
privateKey,
|
||||
passphrase,
|
||||
expandedBody,
|
||||
envMap,
|
||||
defaultRunTimeout,
|
||||
)
|
||||
case settings.ActionKindScript:
|
||||
sshResult, runErr = auth.RunSSHScript(
|
||||
node.HostIP,
|
||||
node.Username,
|
||||
privateKey,
|
||||
passphrase,
|
||||
expandedBody,
|
||||
envMap,
|
||||
defaultRunTimeout,
|
||||
)
|
||||
default:
|
||||
runErr = fmt.Errorf("unsupported action kind %q", resolved.Kind)
|
||||
}
|
||||
|
||||
itemResult := settings.ActionItemRunResult{
|
||||
ItemID: item.ID,
|
||||
ActionName: resolved.Name,
|
||||
Source: string(resolved.Source),
|
||||
ExitCode: sshResult.ExitCode,
|
||||
Stdout: sshResult.Stdout,
|
||||
Stderr: sshResult.Stderr,
|
||||
StartedAt: itemStarted,
|
||||
FinishedAt: time.Now().UTC(),
|
||||
}
|
||||
if runErr != nil {
|
||||
itemResult.Error = runErr.Error()
|
||||
if itemResult.ExitCode == 0 {
|
||||
itemResult.ExitCode = -1
|
||||
}
|
||||
}
|
||||
results = append(results, itemResult)
|
||||
}
|
||||
|
||||
finishedAt := time.Now().UTC()
|
||||
record := settings.ActionGroupRunRecord{
|
||||
ID: runID,
|
||||
NodeID: group.NodeID,
|
||||
GroupID: group.ID,
|
||||
GroupName: group.Name,
|
||||
Trigger: trigger,
|
||||
Actor: actor,
|
||||
StartedAt: startedAt,
|
||||
FinishedAt: finishedAt,
|
||||
Actions: results,
|
||||
}
|
||||
|
||||
if err := settings.AppendActionLogRun(executor.ConfigDir, record); err != nil {
|
||||
return record, fmt.Errorf("append action log: %w", err)
|
||||
}
|
||||
if err := settings.UpdateGroupLastRunAt(executor.ConfigDir, group.ID, finishedAt); err != nil {
|
||||
return record, fmt.Errorf("update last run: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (executor *Executor) loadNodeCredentials(nodeID string) (settings.Node, string, string, error) {
|
||||
if len(executor.Key) == 0 {
|
||||
return settings.Node{}, "", "", fmt.Errorf("%s is not set", settings.ConfigKeyEnvVar)
|
||||
}
|
||||
|
||||
nodeStore, err := settings.LoadNodesOrEmpty(executor.ConfigDir)
|
||||
if err != nil {
|
||||
return settings.Node{}, "", "", err
|
||||
}
|
||||
var node settings.Node
|
||||
found := false
|
||||
for _, candidate := range nodeStore.Nodes {
|
||||
if candidate.ID == nodeID {
|
||||
node = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return settings.Node{}, "", "", fmt.Errorf("node not found")
|
||||
}
|
||||
|
||||
keyStore, err := settings.LoadNodeKeysOrEmpty(executor.ConfigDir, executor.Key)
|
||||
if err != nil {
|
||||
return settings.Node{}, "", "", err
|
||||
}
|
||||
for _, entry := range keyStore.Keys {
|
||||
if entry.NodeID == nodeID {
|
||||
return node, entry.PrivateKey, entry.Passphrase, nil
|
||||
}
|
||||
}
|
||||
return settings.Node{}, "", "", fmt.Errorf("node private key not found")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func TestResolveItemLibraryAndLocal(t *testing.T) {
|
||||
library := []settings.Action{
|
||||
{
|
||||
ID: settings.BuiltinActionUptimeID,
|
||||
Name: "Uptime",
|
||||
Kind: settings.ActionKindShell,
|
||||
Body: "uptime",
|
||||
Env: []settings.ActionEnvVar{},
|
||||
},
|
||||
}
|
||||
|
||||
resolved, err := ResolveItem(settings.NodeActionItem{
|
||||
ID: "i1",
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
LibraryActionID: settings.BuiltinActionUptimeID,
|
||||
}, library)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveItem library: %v", err)
|
||||
}
|
||||
if resolved.Name != "Uptime" || resolved.Body != "uptime" {
|
||||
t.Fatalf("resolved = %#v", resolved)
|
||||
}
|
||||
|
||||
local, err := ResolveItem(settings.NodeActionItem{
|
||||
ID: "i2",
|
||||
Source: settings.ActionItemSourceLocal,
|
||||
Name: "Echo",
|
||||
Kind: settings.ActionKindShell,
|
||||
Body: "echo hi",
|
||||
Env: []settings.ActionEnvVar{},
|
||||
}, library)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveItem local: %v", err)
|
||||
}
|
||||
if local.Name != "Echo" {
|
||||
t.Fatalf("local = %#v", local)
|
||||
}
|
||||
|
||||
if _, err := ResolveItem(settings.NodeActionItem{
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
LibraryActionID: "missing",
|
||||
}, library); err == nil {
|
||||
t.Fatal("expected missing library action error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxActionLogRuns is the retention cap per action-group log file.
|
||||
const MaxActionLogRuns = 200
|
||||
|
||||
// ActionLogDir returns action-logs/<nodeID> under the config dir.
|
||||
func ActionLogDir(configDir string, nodeID string) string {
|
||||
return filepath.Join(configDir, ActionLogsDirName, nodeID)
|
||||
}
|
||||
|
||||
// ActionLogFilePath returns the JSON log path for a node + group name.
|
||||
func ActionLogFilePath(configDir string, nodeID string, groupName string) string {
|
||||
filename := DiskSafeFilename(groupName) + ".json"
|
||||
return filepath.Join(ActionLogDir(configDir, nodeID), filename)
|
||||
}
|
||||
|
||||
// AppendActionLogRun appends a run record to the group's log file and trims retention.
|
||||
func AppendActionLogRun(configDir string, record ActionGroupRunRecord) error {
|
||||
if strings.TrimSpace(record.NodeID) == "" {
|
||||
return fmt.Errorf("node id is required")
|
||||
}
|
||||
if strings.TrimSpace(record.GroupName) == "" {
|
||||
return fmt.Errorf("group name is required")
|
||||
}
|
||||
|
||||
dir := ActionLogDir(configDir, record.NodeID)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return fmt.Errorf("create action log dir: %w", err)
|
||||
}
|
||||
|
||||
path := ActionLogFilePath(configDir, record.NodeID, record.GroupName)
|
||||
file, err := LoadActionLogFileOrEmpty(path, record.NodeID, record.GroupID, record.GroupName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file.NodeID = record.NodeID
|
||||
file.GroupID = record.GroupID
|
||||
file.GroupName = record.GroupName
|
||||
file.Runs = append(file.Runs, record)
|
||||
if len(file.Runs) > MaxActionLogRuns {
|
||||
file.Runs = file.Runs[len(file.Runs)-MaxActionLogRuns:]
|
||||
}
|
||||
|
||||
return SaveActionLogFile(path, file)
|
||||
}
|
||||
|
||||
// LoadActionLogFile reads a log JSON file from path.
|
||||
func LoadActionLogFile(path string) (ActionLogFile, error) {
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ActionLogFile{}, err
|
||||
}
|
||||
var file ActionLogFile
|
||||
if err := json.Unmarshal(payload, &file); err != nil {
|
||||
return ActionLogFile{}, fmt.Errorf("parse action log: %w", err)
|
||||
}
|
||||
if file.Runs == nil {
|
||||
file.Runs = []ActionGroupRunRecord{}
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// LoadActionLogFileOrEmpty returns an empty file when missing.
|
||||
func LoadActionLogFileOrEmpty(
|
||||
path string,
|
||||
nodeID string,
|
||||
groupID string,
|
||||
groupName string,
|
||||
) (ActionLogFile, error) {
|
||||
file, err := LoadActionLogFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ActionLogFile{
|
||||
NodeID: nodeID,
|
||||
GroupID: groupID,
|
||||
GroupName: groupName,
|
||||
Runs: []ActionGroupRunRecord{},
|
||||
}, nil
|
||||
}
|
||||
return ActionLogFile{}, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// SaveActionLogFile writes a log JSON file with mode 0640.
|
||||
func SaveActionLogFile(path string, file ActionLogFile) error {
|
||||
if file.Runs == nil {
|
||||
file.Runs = []ActionGroupRunRecord{}
|
||||
}
|
||||
payload, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode action log: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
return fmt.Errorf("write action log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListActionLogFiles lists JSON log files for a node.
|
||||
func ListActionLogFiles(configDir string, nodeID string) ([]ActionLogFileInfo, error) {
|
||||
dir := ActionLogDir(configDir, nodeID)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []ActionLogFileInfo{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("list action logs: %w", err)
|
||||
}
|
||||
|
||||
result := make([]ActionLogFileInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
groupName := strings.TrimSuffix(name, ".json")
|
||||
groupID := ""
|
||||
file, loadErr := LoadActionLogFile(filepath.Join(dir, name))
|
||||
if loadErr == nil {
|
||||
if file.GroupName != "" {
|
||||
groupName = file.GroupName
|
||||
}
|
||||
groupID = file.GroupID
|
||||
}
|
||||
result = append(result, ActionLogFileInfo{
|
||||
Filename: name,
|
||||
GroupName: groupName,
|
||||
GroupID: groupID,
|
||||
ModTime: info.ModTime().UTC(),
|
||||
SizeBytes: info.Size(),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ReadActionLogFileByName reads one log file for a node after validating the name.
|
||||
func ReadActionLogFileByName(configDir string, nodeID string, filename string) (ActionLogFile, error) {
|
||||
safeName := filepath.Base(strings.TrimSpace(filename))
|
||||
if safeName == "." || safeName == ".." || safeName == "" {
|
||||
return ActionLogFile{}, fmt.Errorf("invalid log filename")
|
||||
}
|
||||
if !strings.HasSuffix(safeName, ".json") {
|
||||
return ActionLogFile{}, fmt.Errorf("invalid log filename")
|
||||
}
|
||||
if strings.Contains(safeName, "/") || strings.Contains(safeName, `\`) {
|
||||
return ActionLogFile{}, fmt.Errorf("invalid log filename")
|
||||
}
|
||||
|
||||
path := filepath.Join(ActionLogDir(configDir, nodeID), safeName)
|
||||
cleaned := filepath.Clean(path)
|
||||
if !strings.HasPrefix(cleaned, filepath.Clean(ActionLogDir(configDir, nodeID))+string(os.PathSeparator)) {
|
||||
return ActionLogFile{}, fmt.Errorf("invalid log filename")
|
||||
}
|
||||
return LoadActionLogFile(cleaned)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var diskSafeFilenamePattern = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||
|
||||
// LoadNodeActionGroups reads and parses node-action-groups.json from dir.
|
||||
func LoadNodeActionGroups(dir string) (NodeActionGroupStore, error) {
|
||||
path := filepath.Join(dir, NodeActionGroupsFileName)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return NodeActionGroupStore{}, err
|
||||
}
|
||||
|
||||
var store NodeActionGroupStore
|
||||
if err := json.Unmarshal(payload, &store); err != nil {
|
||||
return NodeActionGroupStore{}, fmt.Errorf("parse node action groups: %w", err)
|
||||
}
|
||||
normalizeNodeActionGroupStore(&store)
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// LoadNodeActionGroupsOrEmpty returns an empty store when the file is missing.
|
||||
func LoadNodeActionGroupsOrEmpty(dir string) (NodeActionGroupStore, error) {
|
||||
store, err := LoadNodeActionGroups(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return NodeActionGroupStore{Groups: []NodeActionGroup{}}, nil
|
||||
}
|
||||
return NodeActionGroupStore{}, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// SaveNodeActionGroups writes node-action-groups.json to dir with mode 0640.
|
||||
func SaveNodeActionGroups(dir string, store NodeActionGroupStore) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
normalizeNodeActionGroupStore(&store)
|
||||
|
||||
payload, err := json.MarshalIndent(store, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode node action groups: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, NodeActionGroupsFileName)
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
return fmt.Errorf("write node action groups: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeNodeActionGroupStore(store *NodeActionGroupStore) {
|
||||
if store.Groups == nil {
|
||||
store.Groups = []NodeActionGroup{}
|
||||
}
|
||||
for index := range store.Groups {
|
||||
if store.Groups[index].Items == nil {
|
||||
store.Groups[index].Items = []NodeActionItem{}
|
||||
}
|
||||
if store.Groups[index].Schedule.Times == nil {
|
||||
store.Groups[index].Schedule.Times = []ActionGroupTimeSpec{}
|
||||
}
|
||||
for itemIndex := range store.Groups[index].Items {
|
||||
if store.Groups[index].Items[itemIndex].Env == nil {
|
||||
store.Groups[index].Items[itemIndex].Env = []ActionEnvVar{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DiskSafeFilename converts a group name into a safe filename stem.
|
||||
func DiskSafeFilename(name string) string {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return "unnamed"
|
||||
}
|
||||
safe := diskSafeFilenamePattern.ReplaceAllString(trimmed, "_")
|
||||
safe = strings.Trim(safe, "._-")
|
||||
if safe == "" {
|
||||
return "unnamed"
|
||||
}
|
||||
if len(safe) > 120 {
|
||||
safe = safe[:120]
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
// FindNodeActionGroupIndex returns the index of a group by id, or -1.
|
||||
func FindNodeActionGroupIndex(groups []NodeActionGroup, groupID string) int {
|
||||
for index := range groups {
|
||||
if groups[index].ID == groupID {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// FindNodeActionItemIndex returns the index of an item by id, or -1.
|
||||
func FindNodeActionItemIndex(items []NodeActionItem, itemID string) int {
|
||||
for index := range items {
|
||||
if items[index].ID == itemID {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// GroupsForNode returns groups belonging to nodeID.
|
||||
func GroupsForNode(store NodeActionGroupStore, nodeID string) []NodeActionGroup {
|
||||
result := make([]NodeActionGroup, 0)
|
||||
for _, group := range store.Groups {
|
||||
if group.NodeID == nodeID {
|
||||
result = append(result, group)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UpdateGroupLastRunAt sets LastRunAt and UpdatedAt for a group and saves.
|
||||
func UpdateGroupLastRunAt(dir string, groupID string, at time.Time) error {
|
||||
store, err := LoadNodeActionGroupsOrEmpty(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index := FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if index < 0 {
|
||||
return fmt.Errorf("action group not found")
|
||||
}
|
||||
runAt := at.UTC()
|
||||
store.Groups[index].LastRunAt = &runAt
|
||||
store.Groups[index].UpdatedAt = runAt
|
||||
return SaveNodeActionGroups(dir, store)
|
||||
}
|
||||
|
||||
// IsScheduleDue reports whether the group should run at now given its schedule and LastRunAt.
|
||||
func IsScheduleDue(group NodeActionGroup, now time.Time) bool {
|
||||
schedule := group.Schedule
|
||||
if !schedule.Enabled {
|
||||
return false
|
||||
}
|
||||
localNow := now.In(time.Local)
|
||||
|
||||
switch schedule.Kind {
|
||||
case ActionGroupScheduleInterval:
|
||||
if schedule.EverySeconds < 1 {
|
||||
return false
|
||||
}
|
||||
if group.LastRunAt == nil {
|
||||
return true
|
||||
}
|
||||
elapsed := localNow.Sub(group.LastRunAt.In(time.Local))
|
||||
return elapsed >= time.Duration(schedule.EverySeconds)*time.Second
|
||||
case ActionGroupScheduleTimes:
|
||||
if len(schedule.Times) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, spec := range schedule.Times {
|
||||
if !timeSpecMatches(spec, localNow) {
|
||||
continue
|
||||
}
|
||||
if group.LastRunAt == nil {
|
||||
return true
|
||||
}
|
||||
lastLocal := group.LastRunAt.In(time.Local)
|
||||
// Fire once per matching minute window.
|
||||
sameMinute := lastLocal.Year() == localNow.Year() &&
|
||||
lastLocal.YearDay() == localNow.YearDay() &&
|
||||
lastLocal.Hour() == localNow.Hour() &&
|
||||
lastLocal.Minute() == localNow.Minute()
|
||||
if !sameMinute {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func timeSpecMatches(spec ActionGroupTimeSpec, now time.Time) bool {
|
||||
hour, minute, ok := parseHHMM(spec.Time)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if now.Hour() != hour || now.Minute() != minute {
|
||||
return false
|
||||
}
|
||||
if len(spec.DaysOfWeek) == 0 {
|
||||
return true
|
||||
}
|
||||
weekday := int(now.Weekday())
|
||||
for _, day := range spec.DaysOfWeek {
|
||||
if day == weekday {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseHHMM(value string) (hour int, minute int, ok bool) {
|
||||
parts := strings.Split(strings.TrimSpace(value), ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if _, err := fmt.Sscanf(parts[0], "%d", &hour); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &minute); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return hour, minute, true
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDiskSafeFilename(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Daily Backup": "Daily_Backup",
|
||||
" hello!! ": "hello",
|
||||
"../etc/passwd": "etc_passwd",
|
||||
"": "unnamed",
|
||||
"a/b\\c:d*e?f": "a_b_c_d_e_f",
|
||||
}
|
||||
for input, want := range cases {
|
||||
got := DiskSafeFilename(input)
|
||||
if got != want {
|
||||
t.Fatalf("DiskSafeFilename(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeActionGroupsRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Now().UTC()
|
||||
store := NodeActionGroupStore{
|
||||
Groups: []NodeActionGroup{
|
||||
{
|
||||
ID: "g1",
|
||||
NodeID: "n1",
|
||||
Name: "Nightly",
|
||||
Schedule: ActionGroupSchedule{
|
||||
Enabled: true,
|
||||
Kind: ActionGroupScheduleInterval,
|
||||
EverySeconds: 60,
|
||||
Times: []ActionGroupTimeSpec{},
|
||||
},
|
||||
Items: []NodeActionItem{
|
||||
{
|
||||
ID: "i1",
|
||||
Source: ActionItemSourceLibrary,
|
||||
LibraryActionID: BuiltinActionUptimeID,
|
||||
Env: []ActionEnvVar{},
|
||||
},
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := SaveNodeActionGroups(dir, store); err != nil {
|
||||
t.Fatalf("SaveNodeActionGroups: %v", err)
|
||||
}
|
||||
loaded, err := LoadNodeActionGroups(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadNodeActionGroups: %v", err)
|
||||
}
|
||||
if len(loaded.Groups) != 1 || loaded.Groups[0].Name != "Nightly" {
|
||||
t.Fatalf("loaded = %#v", loaded)
|
||||
}
|
||||
if len(loaded.Groups[0].Items) != 1 {
|
||||
t.Fatalf("items = %#v", loaded.Groups[0].Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsScheduleDueInterval(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.Local)
|
||||
group := NodeActionGroup{
|
||||
Schedule: ActionGroupSchedule{
|
||||
Enabled: true,
|
||||
Kind: ActionGroupScheduleInterval,
|
||||
EverySeconds: 300,
|
||||
},
|
||||
}
|
||||
if !IsScheduleDue(group, now) {
|
||||
t.Fatal("expected due when never run")
|
||||
}
|
||||
last := now.Add(-301 * time.Second)
|
||||
group.LastRunAt = &last
|
||||
if !IsScheduleDue(group, now) {
|
||||
t.Fatal("expected due after interval elapsed")
|
||||
}
|
||||
recent := now.Add(-10 * time.Second)
|
||||
group.LastRunAt = &recent
|
||||
if IsScheduleDue(group, now) {
|
||||
t.Fatal("expected not due within interval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsScheduleDueTimes(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 14, 30, 10, 0, time.Local) // Sunday
|
||||
group := NodeActionGroup{
|
||||
Schedule: ActionGroupSchedule{
|
||||
Enabled: true,
|
||||
Kind: ActionGroupScheduleTimes,
|
||||
Times: []ActionGroupTimeSpec{
|
||||
{Time: "14:30", DaysOfWeek: []int{0}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !IsScheduleDue(group, now) {
|
||||
t.Fatal("expected due at matching time")
|
||||
}
|
||||
sameMinute := now
|
||||
group.LastRunAt = &sameMinute
|
||||
if IsScheduleDue(group, now) {
|
||||
t.Fatal("expected not due again in same minute")
|
||||
}
|
||||
group.LastRunAt = nil
|
||||
group.Schedule.Times[0].DaysOfWeek = []int{1} // Monday only
|
||||
if IsScheduleDue(group, now) {
|
||||
t.Fatal("expected not due on wrong weekday")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionLogAppendAndList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
record := ActionGroupRunRecord{
|
||||
ID: "run-1",
|
||||
NodeID: "node-abc",
|
||||
GroupID: "group-1",
|
||||
GroupName: "Nightly Jobs",
|
||||
Trigger: ActionRunTriggerManual,
|
||||
StartedAt: time.Now().UTC(),
|
||||
FinishedAt: time.Now().UTC(),
|
||||
Actions: []ActionItemRunResult{},
|
||||
}
|
||||
if err := AppendActionLogRun(dir, record); err != nil {
|
||||
t.Fatalf("AppendActionLogRun: %v", err)
|
||||
}
|
||||
path := ActionLogFilePath(dir, "node-abc", "Nightly Jobs")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected log file at %s: %v", path, err)
|
||||
}
|
||||
files, err := ListActionLogFiles(dir, "node-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("ListActionLogFiles: %v", err)
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("files = %#v", files)
|
||||
}
|
||||
wantName := DiskSafeFilename("Nightly Jobs") + ".json"
|
||||
if files[0].Filename != wantName {
|
||||
t.Fatalf("filename = %q want %q", files[0].Filename, wantName)
|
||||
}
|
||||
loaded, err := ReadActionLogFileByName(dir, "node-abc", wantName)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadActionLogFileByName: %v", err)
|
||||
}
|
||||
if len(loaded.Runs) != 1 || loaded.Runs[0].ID != "run-1" {
|
||||
t.Fatalf("loaded = %#v", loaded)
|
||||
}
|
||||
if _, err := ReadActionLogFileByName(dir, "node-abc", "../etc/passwd"); err == nil {
|
||||
t.Fatal("expected path traversal rejected")
|
||||
}
|
||||
_ = filepath.Base(wantName)
|
||||
}
|
||||
@@ -21,6 +21,8 @@ const (
|
||||
NodeAuditFileName = "node-audit.json"
|
||||
AuthAuditFileName = "auth-audit.json"
|
||||
ActionsFileName = "actions.json"
|
||||
NodeActionGroupsFileName = "node-action-groups.json"
|
||||
ActionLogsDirName = "action-logs"
|
||||
)
|
||||
|
||||
// ResolveDir returns the config directory using precedence:
|
||||
|
||||
@@ -144,6 +144,116 @@ type ActionStore struct {
|
||||
Actions []Action `json:"actions"`
|
||||
}
|
||||
|
||||
// ActionItemSource identifies whether a node action item is a library ref or local copy.
|
||||
type ActionItemSource string
|
||||
|
||||
const (
|
||||
ActionItemSourceLibrary ActionItemSource = "library"
|
||||
ActionItemSourceLocal ActionItemSource = "local"
|
||||
)
|
||||
|
||||
// ActionGroupScheduleKind identifies how an action group is scheduled.
|
||||
type ActionGroupScheduleKind string
|
||||
|
||||
const (
|
||||
ActionGroupScheduleInterval ActionGroupScheduleKind = "interval"
|
||||
ActionGroupScheduleTimes ActionGroupScheduleKind = "times"
|
||||
)
|
||||
|
||||
// ActionGroupTimeSpec is one clock time (and optional weekdays) for a times schedule.
|
||||
type ActionGroupTimeSpec struct {
|
||||
Time string `json:"time"` // "HH:MM" in server local time
|
||||
DaysOfWeek []int `json:"days_of_week,omitempty"` // 0=Sun..6=Sat; empty = every day
|
||||
}
|
||||
|
||||
// ActionGroupSchedule configures when an action group runs automatically.
|
||||
type ActionGroupSchedule struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Kind ActionGroupScheduleKind `json:"kind"`
|
||||
EverySeconds int `json:"every_seconds,omitempty"`
|
||||
Times []ActionGroupTimeSpec `json:"times,omitempty"`
|
||||
}
|
||||
|
||||
// NodeActionItem is one ordered action inside a node action group.
|
||||
type NodeActionItem struct {
|
||||
ID string `json:"id"`
|
||||
Source ActionItemSource `json:"source"`
|
||||
LibraryActionID string `json:"library_action_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Kind ActionKind `json:"kind,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Env []ActionEnvVar `json:"env,omitempty"`
|
||||
}
|
||||
|
||||
// NodeActionGroup is a named ordered set of actions with one schedule on a node.
|
||||
type NodeActionGroup struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Name string `json:"name"`
|
||||
Schedule ActionGroupSchedule `json:"schedule"`
|
||||
Items []NodeActionItem `json:"items"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NodeActionGroupStore is the plain JSON payload in node-action-groups.json.
|
||||
type NodeActionGroupStore struct {
|
||||
Groups []NodeActionGroup `json:"groups"`
|
||||
}
|
||||
|
||||
// ActionRunTrigger identifies what started an action run.
|
||||
type ActionRunTrigger string
|
||||
|
||||
const (
|
||||
ActionRunTriggerManual ActionRunTrigger = "manual"
|
||||
ActionRunTriggerSchedule ActionRunTrigger = "schedule"
|
||||
)
|
||||
|
||||
// ActionItemRunResult is the captured output of one action within a run.
|
||||
type ActionItemRunResult struct {
|
||||
ItemID string `json:"item_id"`
|
||||
ActionName string `json:"action_name"`
|
||||
Source string `json:"source"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt time.Time `json:"finished_at"`
|
||||
}
|
||||
|
||||
// ActionGroupRunRecord is one execution of a group (or a single-item manual run).
|
||||
type ActionGroupRunRecord struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
GroupID string `json:"group_id"`
|
||||
GroupName string `json:"group_name"`
|
||||
Trigger ActionRunTrigger `json:"trigger"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt time.Time `json:"finished_at"`
|
||||
Actions []ActionItemRunResult `json:"actions"`
|
||||
}
|
||||
|
||||
// ActionLogFile is the JSON payload for one node/group log file on disk.
|
||||
type ActionLogFile struct {
|
||||
NodeID string `json:"node_id"`
|
||||
GroupID string `json:"group_id"`
|
||||
GroupName string `json:"group_name"`
|
||||
Runs []ActionGroupRunRecord `json:"runs"`
|
||||
}
|
||||
|
||||
// ActionLogFileInfo is metadata for listing log files without reading full content.
|
||||
type ActionLogFileInfo struct {
|
||||
Filename string `json:"filename"`
|
||||
GroupName string `json:"group_name"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
ModTime time.Time `json:"mod_time"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// NodeKeyEntry holds private key material for one node, keyed by node UUID.
|
||||
type NodeKeyEntry struct {
|
||||
NodeID string `json:"node_id"`
|
||||
|
||||
+221
-5
@@ -422,24 +422,60 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(min(100%, 18rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.node-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.node-list-item {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in srgb, var(--ctp-surface0) 55%, transparent);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.node-list-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.node-list-heading-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.node-edit {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 6px;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-edit:hover {
|
||||
border-color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.node-id {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ctp-subtext0);
|
||||
@@ -1359,3 +1395,183 @@
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.node-detail-back {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.node-detail-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.node-detail-header h2 {
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.node-detail-overview {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.node-detail-overview dt {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.node-detail-overview dd {
|
||||
margin: 0.15rem 0 0;
|
||||
}
|
||||
|
||||
.node-action-group-create,
|
||||
.node-action-add-row,
|
||||
.node-action-group-heading,
|
||||
.node-action-group-toolbar,
|
||||
.node-action-item-heading,
|
||||
.node-action-item-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-action-group-list {
|
||||
list-style: none;
|
||||
margin: 1rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.node-action-group-card {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: color-mix(in srgb, var(--ctp-surface0) 70%, transparent);
|
||||
}
|
||||
|
||||
.node-action-schedule {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.75rem;
|
||||
margin: 0.75rem 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.node-action-item-list {
|
||||
margin: 0.5rem 0 1rem;
|
||||
padding-left: 1.25rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.node-action-item-edit,
|
||||
.node-action-add-custom,
|
||||
.node-action-adders {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.schedule-times-editor,
|
||||
.schedule-time-row,
|
||||
.schedule-weekdays {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.node-action-run-result {
|
||||
margin-top: 1.25rem;
|
||||
border-top: 1px solid var(--ctp-surface1);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.action-log-pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.8rem;
|
||||
background: var(--ctp-mantle);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.65rem;
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.action-log-stderr {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.node-action-logs-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 16rem) 1fr;
|
||||
gap: 1rem;
|
||||
min-height: 18rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.node-action-logs-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.node-action-logs-list {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.5rem;
|
||||
overflow: auto;
|
||||
max-height: 28rem;
|
||||
}
|
||||
|
||||
.node-action-logs-list ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.log-file-button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.log-file-button-active,
|
||||
.log-file-button:hover {
|
||||
border-color: var(--ctp-surface2);
|
||||
background: color-mix(in srgb, var(--ctp-surface1) 60%, transparent);
|
||||
}
|
||||
|
||||
.node-action-logs-content {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow: auto;
|
||||
max-height: 28rem;
|
||||
}
|
||||
|
||||
.action-log-run {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
type LogsPageSize,
|
||||
} from './logsPreferences'
|
||||
import { LoginPage } from './LoginPage'
|
||||
import { NodeDetailPanel } from './NodeDetailPanel'
|
||||
import {
|
||||
countAuthFailuresToday,
|
||||
countNodesBySection,
|
||||
@@ -86,6 +87,7 @@ import {
|
||||
pathFor,
|
||||
subscribeToLocation,
|
||||
type ConfigTabId,
|
||||
type NodeDetailTabId,
|
||||
type ResourceSectionId,
|
||||
type ResourceTabId,
|
||||
type SectionId,
|
||||
@@ -2546,9 +2548,22 @@ function ResourceOverviewTab({
|
||||
return (
|
||||
<li key={node.id} className="node-list-item">
|
||||
<div className="node-list-heading">
|
||||
<div className="node-list-heading-main">
|
||||
<strong>{node.name}</strong>
|
||||
<code className="node-id">{node.id}</code>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="node-edit"
|
||||
onClick={() => {
|
||||
navigateTo(
|
||||
pathFor(section, 'overview', 'overview', node.id, 'actions'),
|
||||
)
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<p className="config-hint">
|
||||
{node.host_ip} · {node.username} · group {node.group_name} ·{' '}
|
||||
{node.key_algo}
|
||||
@@ -2919,10 +2934,14 @@ function ResourceSectionPanel({
|
||||
section,
|
||||
activeResourceTab,
|
||||
onResourceTabChange,
|
||||
nodeId,
|
||||
nodeDetailTab,
|
||||
}: {
|
||||
section: ResourceSectionId
|
||||
activeResourceTab: ResourceTabId
|
||||
onResourceTabChange: (tab: ResourceTabId) => void
|
||||
nodeId: string | null
|
||||
nodeDetailTab: NodeDetailTabId
|
||||
}) {
|
||||
const [me, setMe] = useState<MeResponse | null>(null)
|
||||
const [overviewKey, setOverviewKey] = useState(0)
|
||||
@@ -2955,6 +2974,16 @@ function ResourceSectionPanel({
|
||||
const canDelete = Boolean(me?.permissions?.includes('nodes.delete'))
|
||||
const tabs = resourceTabsFor(section)
|
||||
|
||||
if (nodeId) {
|
||||
return (
|
||||
<NodeDetailPanel
|
||||
section={section}
|
||||
nodeId={nodeId}
|
||||
activeTab={nodeDetailTab}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="config-panel">
|
||||
<div
|
||||
@@ -3743,6 +3772,11 @@ function App() {
|
||||
const [activeResourceTab, setActiveResourceTab] = useState<ResourceTabId>(
|
||||
initialLocation.resourceTab,
|
||||
)
|
||||
const [activeNodeId, setActiveNodeId] = useState<string | null>(
|
||||
initialLocation.nodeId,
|
||||
)
|
||||
const [activeNodeDetailTab, setActiveNodeDetailTab] =
|
||||
useState<NodeDetailTabId>(initialLocation.nodeDetailTab)
|
||||
const [profileDisplayName, setProfileDisplayName] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
@@ -3764,6 +3798,8 @@ function App() {
|
||||
setActiveSection(location.section)
|
||||
setActiveConfigTab(location.configTab)
|
||||
setActiveResourceTab(location.resourceTab)
|
||||
setActiveNodeId(location.nodeId)
|
||||
setActiveNodeDetailTab(location.nodeDetailTab)
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -3916,6 +3952,8 @@ function App() {
|
||||
return
|
||||
}
|
||||
setActiveSection(section)
|
||||
setActiveNodeId(null)
|
||||
setActiveNodeDetailTab('overview')
|
||||
if (section === 'configuration') {
|
||||
navigateTo(pathFor(section, activeConfigTab))
|
||||
return
|
||||
@@ -3932,6 +3970,7 @@ function App() {
|
||||
function goToConfigTab(tab: ConfigTabId) {
|
||||
setActiveSection('configuration')
|
||||
setActiveConfigTab(tab)
|
||||
setActiveNodeId(null)
|
||||
navigateTo(pathFor('configuration', tab))
|
||||
}
|
||||
|
||||
@@ -3940,6 +3979,8 @@ function App() {
|
||||
return
|
||||
}
|
||||
setActiveResourceTab(tab)
|
||||
setActiveNodeId(null)
|
||||
setActiveNodeDetailTab('overview')
|
||||
navigateTo(pathFor(activeSection, 'overview', tab))
|
||||
}
|
||||
|
||||
@@ -4150,6 +4191,8 @@ function App() {
|
||||
section={activeSection}
|
||||
activeResourceTab={activeResourceTab}
|
||||
onResourceTabChange={goToResourceTab}
|
||||
nodeId={activeNodeId}
|
||||
nodeDetailTab={activeNodeDetailTab}
|
||||
/>
|
||||
) : (
|
||||
<p>Coming soon</p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -932,3 +932,342 @@ export async function deleteAction(
|
||||
}
|
||||
return (await response.json()) as ActionsResponse
|
||||
}
|
||||
|
||||
export type ActionItemSource = 'library' | 'local'
|
||||
export type ActionGroupScheduleKind = 'interval' | 'times'
|
||||
export type ActionRunTrigger = 'manual' | 'schedule'
|
||||
|
||||
export type ActionGroupTimeSpec = {
|
||||
time: string
|
||||
days_of_week?: ReadonlyArray<number>
|
||||
}
|
||||
|
||||
export type ActionGroupSchedule = {
|
||||
enabled: boolean
|
||||
kind: ActionGroupScheduleKind
|
||||
every_seconds?: number
|
||||
times?: ReadonlyArray<ActionGroupTimeSpec>
|
||||
}
|
||||
|
||||
export type NodeActionItem = {
|
||||
id: string
|
||||
source: ActionItemSource
|
||||
library_action_id?: string
|
||||
name?: string
|
||||
description?: string
|
||||
kind?: ActionKind
|
||||
body?: string
|
||||
env?: ReadonlyArray<ActionEnvVar>
|
||||
}
|
||||
|
||||
export type NodeActionGroup = {
|
||||
id: string
|
||||
node_id: string
|
||||
name: string
|
||||
schedule: ActionGroupSchedule
|
||||
items: ReadonlyArray<NodeActionItem>
|
||||
last_run_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ActionGroupsResponse = {
|
||||
groups: ReadonlyArray<NodeActionGroup>
|
||||
}
|
||||
|
||||
export type ActionGroupResponse = {
|
||||
group: NodeActionGroup
|
||||
}
|
||||
|
||||
export type ActionItemRunResult = {
|
||||
item_id: string
|
||||
action_name: string
|
||||
source: string
|
||||
exit_code: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
error?: string
|
||||
started_at: string
|
||||
finished_at: string
|
||||
}
|
||||
|
||||
export type ActionGroupRunRecord = {
|
||||
id: string
|
||||
node_id: string
|
||||
group_id: string
|
||||
group_name: string
|
||||
trigger: ActionRunTrigger
|
||||
actor?: string
|
||||
started_at: string
|
||||
finished_at: string
|
||||
actions: ReadonlyArray<ActionItemRunResult>
|
||||
}
|
||||
|
||||
export type ActionRunResponse = {
|
||||
run: ActionGroupRunRecord
|
||||
}
|
||||
|
||||
export type ActionLogFileInfo = {
|
||||
filename: string
|
||||
group_name: string
|
||||
group_id?: string
|
||||
mod_time: string
|
||||
size_bytes: number
|
||||
}
|
||||
|
||||
export type ActionLogFile = {
|
||||
node_id: string
|
||||
group_id: string
|
||||
group_name: string
|
||||
runs: ReadonlyArray<ActionGroupRunRecord>
|
||||
}
|
||||
|
||||
export type ActionLogsListResponse = {
|
||||
files: ReadonlyArray<ActionLogFileInfo>
|
||||
}
|
||||
|
||||
export type ActionLogFileResponse = {
|
||||
log: ActionLogFile
|
||||
}
|
||||
|
||||
export type CreateActionGroupRequest = {
|
||||
name: string
|
||||
schedule?: ActionGroupSchedule
|
||||
}
|
||||
|
||||
export type PatchActionGroupRequest = {
|
||||
name?: string
|
||||
schedule?: ActionGroupSchedule
|
||||
}
|
||||
|
||||
export type CreateActionGroupItemRequest = {
|
||||
source: ActionItemSource
|
||||
library_action_id?: string
|
||||
name?: string
|
||||
description?: string
|
||||
kind?: ActionKind
|
||||
body?: string
|
||||
env?: ReadonlyArray<ActionEnvVar>
|
||||
}
|
||||
|
||||
export type PatchActionGroupItemRequest = {
|
||||
name?: string
|
||||
description?: string
|
||||
kind?: ActionKind
|
||||
body?: string
|
||||
env?: ReadonlyArray<ActionEnvVar>
|
||||
}
|
||||
|
||||
export async function fetchActionGroups(
|
||||
nodeId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupsResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups`,
|
||||
{},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupsResponse
|
||||
}
|
||||
|
||||
export async function createActionGroup(
|
||||
nodeId: string,
|
||||
body: CreateActionGroupRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups`,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function patchActionGroup(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
body: PatchActionGroupRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}`,
|
||||
{ method: 'PATCH', body: JSON.stringify(body) },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function deleteActionGroup(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupsResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}`,
|
||||
{ method: 'DELETE' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupsResponse
|
||||
}
|
||||
|
||||
export async function createActionGroupItem(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
body: CreateActionGroupItemRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items`,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function patchActionGroupItem(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
itemId: string,
|
||||
body: PatchActionGroupItemRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/${encodeURIComponent(itemId)}`,
|
||||
{ method: 'PATCH', body: JSON.stringify(body) },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function cloneActionGroupItem(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
itemId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/${encodeURIComponent(itemId)}/clone`,
|
||||
{ method: 'POST' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function deleteActionGroupItem(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
itemId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/${encodeURIComponent(itemId)}`,
|
||||
{ method: 'DELETE' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function reorderActionGroupItems(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
itemIds: ReadonlyArray<string>,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/order`,
|
||||
{ method: 'PUT', body: JSON.stringify({ item_ids: itemIds }) },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function runActionGroup(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionRunResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/run`,
|
||||
{ method: 'POST' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionRunResponse
|
||||
}
|
||||
|
||||
export async function runActionGroupItem(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
itemId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionRunResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/${encodeURIComponent(itemId)}/run`,
|
||||
{ method: 'POST' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionRunResponse
|
||||
}
|
||||
|
||||
export async function fetchActionLogs(
|
||||
nodeId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionLogsListResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-logs`,
|
||||
{},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionLogsListResponse
|
||||
}
|
||||
|
||||
export async function fetchActionLogFile(
|
||||
nodeId: string,
|
||||
filename: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionLogFileResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-logs/${encodeURIComponent(filename)}`,
|
||||
{},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionLogFileResponse
|
||||
}
|
||||
|
||||
@@ -20,6 +20,18 @@ describe('pathFor', () => {
|
||||
expect(pathFor('configuration', 'actions')).toBe('/configuration/actions')
|
||||
expect(pathFor('configuration', 'roles')).toBe('/configuration/roles')
|
||||
})
|
||||
|
||||
it('maps node detail paths', () => {
|
||||
expect(pathFor('containers', 'overview', 'overview', 'node-1')).toBe(
|
||||
'/containers/node-1',
|
||||
)
|
||||
expect(
|
||||
pathFor('containers', 'overview', 'overview', 'node-1', 'actions'),
|
||||
).toBe('/containers/node-1/actions')
|
||||
expect(pathFor('vms', 'overview', 'overview', 'abc', 'logs')).toBe(
|
||||
'/vms/abc/logs',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseLocation', () => {
|
||||
@@ -45,10 +57,19 @@ describe('parseLocation', () => {
|
||||
'/configuration/integrations',
|
||||
'/configuration/network',
|
||||
'/configuration/advanced',
|
||||
'/containers/node-1',
|
||||
'/containers/node-1/actions',
|
||||
'/vms/node-2/logs',
|
||||
]) {
|
||||
const location = parseLocation(path)
|
||||
expect(
|
||||
pathFor(location.section, location.configTab, location.resourceTab),
|
||||
pathFor(
|
||||
location.section,
|
||||
location.configTab,
|
||||
location.resourceTab,
|
||||
location.nodeId,
|
||||
location.nodeDetailTab,
|
||||
),
|
||||
).toBe(path === '/docker/overview' ? '/docker' : path)
|
||||
}
|
||||
})
|
||||
@@ -58,21 +79,29 @@ describe('parseLocation', () => {
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
nodeId: null,
|
||||
nodeDetailTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/configuration/unknown')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
nodeId: null,
|
||||
nodeDetailTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/vms/extra')).toEqual({
|
||||
section: 'overview',
|
||||
section: 'vms',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
nodeId: 'extra',
|
||||
nodeDetailTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/docker/extra')).toEqual({
|
||||
expect(parseLocation('/docker/extra/nope')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
nodeId: null,
|
||||
nodeDetailTab: 'overview',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,11 +110,8 @@ describe('parseLocation', () => {
|
||||
section: 'configuration',
|
||||
configTab: 'groups',
|
||||
resourceTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/containers/add/')).toEqual({
|
||||
section: 'containers',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'add',
|
||||
nodeId: null,
|
||||
nodeDetailTab: 'overview',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+54
-10
@@ -22,12 +22,16 @@ export type ConfigTabId =
|
||||
|
||||
export type ResourceTabId = 'overview' | 'security' | 'add'
|
||||
|
||||
export type NodeDetailTabId = 'overview' | 'actions' | 'logs'
|
||||
|
||||
export type ResourceSectionId = 'containers' | 'vms' | 'docker'
|
||||
|
||||
export type AppLocation = {
|
||||
section: SectionId
|
||||
configTab: ConfigTabId
|
||||
resourceTab: ResourceTabId
|
||||
nodeId: string | null
|
||||
nodeDetailTab: NodeDetailTabId
|
||||
}
|
||||
|
||||
const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
|
||||
@@ -48,6 +52,12 @@ const RESOURCE_TAB_IDS: ReadonlySet<string> = new Set([
|
||||
'add',
|
||||
])
|
||||
|
||||
const NODE_DETAIL_TAB_IDS: ReadonlySet<string> = new Set([
|
||||
'overview',
|
||||
'actions',
|
||||
'logs',
|
||||
])
|
||||
|
||||
const RESOURCE_SECTION_IDS: ReadonlySet<string> = new Set([
|
||||
'containers',
|
||||
'vms',
|
||||
@@ -58,6 +68,8 @@ const DEFAULT_LOCATION: AppLocation = {
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
nodeId: null,
|
||||
nodeDetailTab: 'overview',
|
||||
}
|
||||
|
||||
function isConfigTabId(value: string): value is ConfigTabId {
|
||||
@@ -68,6 +80,10 @@ function isResourceTabId(value: string): value is ResourceTabId {
|
||||
return RESOURCE_TAB_IDS.has(value)
|
||||
}
|
||||
|
||||
function isNodeDetailTabId(value: string): value is NodeDetailTabId {
|
||||
return NODE_DETAIL_TAB_IDS.has(value)
|
||||
}
|
||||
|
||||
export function isResourceSectionId(value: SectionId): value is ResourceSectionId {
|
||||
return RESOURCE_SECTION_IDS.has(value)
|
||||
}
|
||||
@@ -81,31 +97,52 @@ export function parseLocation(pathname: string): AppLocation {
|
||||
return { ...DEFAULT_LOCATION }
|
||||
}
|
||||
|
||||
const [first, second] = segments
|
||||
const [first, second, third] = segments
|
||||
|
||||
if (first === 'setup' && segments.length === 1) {
|
||||
return { section: 'setup', configTab: 'overview', resourceTab: 'overview' }
|
||||
return { ...DEFAULT_LOCATION, section: 'setup' }
|
||||
}
|
||||
|
||||
if (first === 'login' && segments.length === 1) {
|
||||
return { section: 'login', configTab: 'overview', resourceTab: 'overview' }
|
||||
return { ...DEFAULT_LOCATION, section: 'login' }
|
||||
}
|
||||
|
||||
if (first === 'profile' && segments.length === 1) {
|
||||
return { section: 'profile', configTab: 'overview', resourceTab: 'overview' }
|
||||
return { ...DEFAULT_LOCATION, section: 'profile' }
|
||||
}
|
||||
|
||||
if (first === 'logs' && segments.length === 1) {
|
||||
return { section: 'logs', configTab: 'overview', resourceTab: 'overview' }
|
||||
return { ...DEFAULT_LOCATION, section: 'logs' }
|
||||
}
|
||||
|
||||
if (RESOURCE_SECTION_IDS.has(first)) {
|
||||
const section = first as ResourceSectionId
|
||||
if (segments.length === 1) {
|
||||
return { section, configTab: 'overview', resourceTab: 'overview' }
|
||||
return { ...DEFAULT_LOCATION, section }
|
||||
}
|
||||
if (segments.length === 2 && isResourceTabId(second)) {
|
||||
return { section, configTab: 'overview', resourceTab: second }
|
||||
return {
|
||||
...DEFAULT_LOCATION,
|
||||
section,
|
||||
resourceTab: second,
|
||||
}
|
||||
}
|
||||
// /containers/:nodeId or /containers/:nodeId/:detailTab
|
||||
if (segments.length === 2 && !isResourceTabId(second)) {
|
||||
return {
|
||||
...DEFAULT_LOCATION,
|
||||
section,
|
||||
nodeId: second,
|
||||
nodeDetailTab: 'overview',
|
||||
}
|
||||
}
|
||||
if (segments.length === 3 && !isResourceTabId(second) && isNodeDetailTabId(third)) {
|
||||
return {
|
||||
...DEFAULT_LOCATION,
|
||||
section,
|
||||
nodeId: second,
|
||||
nodeDetailTab: third,
|
||||
}
|
||||
}
|
||||
return { ...DEFAULT_LOCATION }
|
||||
}
|
||||
@@ -113,16 +150,15 @@ export function parseLocation(pathname: string): AppLocation {
|
||||
if (first === 'configuration') {
|
||||
if (segments.length === 1) {
|
||||
return {
|
||||
...DEFAULT_LOCATION,
|
||||
section: 'configuration',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
}
|
||||
}
|
||||
if (segments.length === 2 && isConfigTabId(second)) {
|
||||
return {
|
||||
...DEFAULT_LOCATION,
|
||||
section: 'configuration',
|
||||
configTab: second,
|
||||
resourceTab: 'overview',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +171,8 @@ export function pathFor(
|
||||
section: SectionId,
|
||||
configTab: ConfigTabId = 'overview',
|
||||
resourceTab: ResourceTabId = 'overview',
|
||||
nodeId: string | null = null,
|
||||
nodeDetailTab: NodeDetailTabId = 'overview',
|
||||
): string {
|
||||
if (section === 'overview') {
|
||||
return '/'
|
||||
@@ -152,6 +190,12 @@ export function pathFor(
|
||||
return '/logs'
|
||||
}
|
||||
if (isResourceSectionId(section)) {
|
||||
if (nodeId) {
|
||||
if (nodeDetailTab === 'overview') {
|
||||
return `/${section}/${nodeId}`
|
||||
}
|
||||
return `/${section}/${nodeId}/${nodeDetailTab}`
|
||||
}
|
||||
if (resourceTab === 'overview') {
|
||||
return `/${section}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user