feat: group-level task node pools and user node selection (#186)
Groups can restrict task dispatch to an allowed node pool and optionally let users pick a target node for remote downloads and archive ops. - GroupSetting: allowed_nodes (empty = all) + allow_select_node toggle - NodePool.Get filters candidates by the allowed pool; explicit picks outside the pool fall back to weighted selection within it - NodeState persists the pool so queued tasks keep their restrictions - resolveNodeSelection validates picks: group toggle, pool membership, active status, task capability; node hashids on the wire - Site config exposes task_nodes + allow_select_node for permitted groups - Admin group editor: functional node multi-select + allow-select switch - Task dialogs: shared TargetNodeSelect on remote download, archive create and extract Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>pull/3587/head
parent
4018d04a5f
commit
8febe344ac
@ -0,0 +1,48 @@
|
|||||||
|
import { FormControl, InputLabel, MenuItem, Select } from "@mui/material";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAppSelector } from "../../../redux/hooks.ts";
|
||||||
|
|
||||||
|
export interface TargetNodeSelectProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useShowTargetNodeSelect = () =>
|
||||||
|
useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
!!state.siteConfig.explorer?.config?.allow_select_node &&
|
||||||
|
(state.siteConfig.explorer?.config?.task_nodes?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const TargetNodeSelect = ({ value, onChange }: TargetNodeSelectProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const show = useShowTargetNodeSelect();
|
||||||
|
const taskNodes = useAppSelector((state) => state.siteConfig.explorer?.config?.task_nodes);
|
||||||
|
|
||||||
|
if (!show) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl variant="outlined" fullWidth>
|
||||||
|
<InputLabel>{t("application:modals.processNode")}</InputLabel>
|
||||||
|
<Select
|
||||||
|
variant="outlined"
|
||||||
|
label={t("application:modals.processNode")}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value as string)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>{t("application:modals.remoteDownloadNodeAuto")}</em>
|
||||||
|
</MenuItem>
|
||||||
|
{(taskNodes ?? []).map((n) => (
|
||||||
|
<MenuItem key={n.id} value={n.id}>
|
||||||
|
{n.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TargetNodeSelect;
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
package explorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||||
|
entnode "github.com/cloudreve/Cloudreve/v4/ent/node"
|
||||||
|
entuser "github.com/cloudreve/Cloudreve/v4/ent/user"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nodeSelDepStub exposes only the dependencies resolveNodeSelection touches.
|
||||||
|
type nodeSelDepStub struct {
|
||||||
|
dependency.Dep
|
||||||
|
nodeClient inventory.NodeClient
|
||||||
|
hasher hashid.Encoder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *nodeSelDepStub) NodeClient() inventory.NodeClient { return d.nodeClient }
|
||||||
|
func (d *nodeSelDepStub) HashIDEncoder() hashid.Encoder { return d.hasher }
|
||||||
|
|
||||||
|
func enableCaps(flags ...int) *boolset.BooleanSet {
|
||||||
|
b := boolset.BooleanSet(make([]byte, 4))
|
||||||
|
for _, f := range flags {
|
||||||
|
b[f/8] |= 1 << uint(f%8)
|
||||||
|
}
|
||||||
|
return &b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveNodeSelection(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mk := func(name string, status entnode.Status, caps *boolset.BooleanSet) *ent.Node {
|
||||||
|
return client.Node.Create().SetName(name).SetStatus(status).SetType(entnode.TypeSlave).
|
||||||
|
SetCapabilities(caps).SaveX(ctx)
|
||||||
|
}
|
||||||
|
downloadCapable := mk("dl", entnode.StatusActive, enableCaps(int(types.NodeCapabilityRemoteDownload)))
|
||||||
|
archiveOnly := mk("zip", entnode.StatusActive, enableCaps(int(types.NodeCapabilityCreateArchive)))
|
||||||
|
suspended := mk("off", entnode.StatusSuspended, enableCaps(int(types.NodeCapabilityRemoteDownload)))
|
||||||
|
|
||||||
|
seq := 0
|
||||||
|
newUser := func(settings *types.GroupSetting) *ent.User {
|
||||||
|
seq++
|
||||||
|
g := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).
|
||||||
|
SetSettings(settings).SaveX(ctx)
|
||||||
|
u := client.User.Create().SetEmail(fmt.Sprintf("u%d@example.com", seq)).SetNick("u").SetStatus("active").SetGroup(g).SaveX(ctx)
|
||||||
|
return client.User.Query().WithGroup().Where(entuser.ID(u.ID)).OnlyX(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher, err := hashid.New("node-sel-test-salt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
dep := &nodeSelDepStub{nodeClient: inventory.NewNodeClient(client), hasher: hasher}
|
||||||
|
|
||||||
|
newCtx := func(u *ent.User) *gin.Context {
|
||||||
|
engine := gin.New()
|
||||||
|
engine.ContextWithFallback = true
|
||||||
|
c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
|
||||||
|
c.Request = httptest.NewRequest("POST", "/", nil)
|
||||||
|
util.WithValue(c, dependency.DepCtx{}, dep)
|
||||||
|
util.WithValue(c, inventory.UserCtx{}, u)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("empty target returns allowed pool for auto dispatch", func(t *testing.T) {
|
||||||
|
u := newUser(&types.GroupSetting{AllowedNodes: []int{downloadCapable.ID}})
|
||||||
|
sel, err := resolveNodeSelection(newCtx(u), dep, "", types.NodeCapabilityRemoteDownload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, sel.TargetNodeID)
|
||||||
|
require.Equal(t, []int{downloadCapable.ID}, sel.AllowedNodes)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("explicit pick within allowed pool wins", func(t *testing.T) {
|
||||||
|
u := newUser(&types.GroupSetting{AllowedNodes: []int{downloadCapable.ID}, AllowSelectNode: true})
|
||||||
|
sel, err := resolveNodeSelection(newCtx(u), dep, hashid.EncodeNodeID(hasher, downloadCapable.ID),
|
||||||
|
types.NodeCapabilityRemoteDownload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, downloadCapable.ID, sel.TargetNodeID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pick rejected when group disallows selection", func(t *testing.T) {
|
||||||
|
u := newUser(&types.GroupSetting{AllowSelectNode: false})
|
||||||
|
_, err := resolveNodeSelection(newCtx(u), dep, hashid.EncodeNodeID(hasher, downloadCapable.ID),
|
||||||
|
types.NodeCapabilityRemoteDownload)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pick outside allowed pool is rejected", func(t *testing.T) {
|
||||||
|
u := newUser(&types.GroupSetting{AllowedNodes: []int{archiveOnly.ID}, AllowSelectNode: true})
|
||||||
|
_, err := resolveNodeSelection(newCtx(u), dep, hashid.EncodeNodeID(hasher, downloadCapable.ID),
|
||||||
|
types.NodeCapabilityRemoteDownload)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("node without the task capability is rejected", func(t *testing.T) {
|
||||||
|
u := newUser(&types.GroupSetting{AllowSelectNode: true})
|
||||||
|
_, err := resolveNodeSelection(newCtx(u), dep, hashid.EncodeNodeID(hasher, archiveOnly.ID),
|
||||||
|
types.NodeCapabilityRemoteDownload)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("suspended node is rejected", func(t *testing.T) {
|
||||||
|
u := newUser(&types.GroupSetting{AllowSelectNode: true})
|
||||||
|
_, err := resolveNodeSelection(newCtx(u), dep, hashid.EncodeNodeID(hasher, suspended.ID),
|
||||||
|
types.NodeCapabilityRemoteDownload)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid node hashid is rejected", func(t *testing.T) {
|
||||||
|
u := newUser(&types.GroupSetting{AllowSelectNode: true})
|
||||||
|
_, err := resolveNodeSelection(newCtx(u), dep, "!!!", types.NodeCapabilityRemoteDownload)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in new issue