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
Tomáš Dvořák 2 weeks ago committed by GitHub
parent 4018d04a5f
commit 8febe344ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -191,9 +191,10 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] PR #144 — task `creator_ip` capture with CIDR-capable admin filter (#115 OSS half), group remote-download quotas per count + per volume (#16), yt-dlp downloader provider (#88), progressive image preview (#113), v3 migrator `DatabaseURL` passthrough (#42) - [x] PR #144 — task `creator_ip` capture with CIDR-capable admin filter (#115 OSS half), group remote-download quotas per count + per volume (#16), yt-dlp downloader provider (#88), progressive image preview (#113), v3 migrator `DatabaseURL` passthrough (#42)
- [x] `activity_event` entity (immutable, tx-aware, actor+IP+CID) + per-file Activity dialog + admin `/admin/event` feed + per-type enablement + retention cron (#184) - [x] `activity_event` entity (immutable, tx-aware, actor+IP+CID) + per-file Activity dialog + admin `/admin/event` feed + per-type enablement + retention cron (#184)
- [x] Coverage wave 2: email/user-activated/token-refresh/share-viewed/version/metadata/view/thumb/live-photo/copy-from/webdav/profile+security/oauth/admin-ops/import (1bbaddf) - [x] Coverage wave 2: email/user-activated/token-refresh/share-viewed/version/metadata/view/thumb/live-photo/copy-from/webdav/profile+security/oauth/admin-ops/import (1bbaddf)
- [ ] Event coverage remainder (needs unbuilt features): payment_*, link/unlink_account, membership_unsubscribe, report_abuse, mount, quota-notify - [ ] Event coverage remainder (needs unbuilt features): payment_*, link/unlink_account, membership_unsubscribe, mount, quota-notify
- [x] site announcement: `announcement` setting (markdown) + post-login modal + per-user dismissal re-triggering on content change (#184) - [x] site announcement: `announcement` setting (markdown) + post-login modal + per-user dismissal re-triggering on content change (#184)
- [ ] group `allowed_nodes` + task `target_node`; `abuse_report` + admin queue + share context-menu Report entry - [x] `abuse_report` entity + public `POST /abuse/report` (IP rate-limit + `abuse_captcha` gate) + admin `/admin/abuse` queue (resolve/dismiss + reversible share block) + share-menu Report entry (#185)
- [x] group `allowed_nodes` pool + `allow_select_node` + task `target_node` dispatch (persisted in task state, weighted LB within pool); group admin multi-select + task-dialog node picker
## 5. Phase C — security + quality ## 5. Phase C — security + quality

@ -66,6 +66,8 @@ export interface GroupSetting {
redirected_source?: boolean; redirected_source?: boolean;
login_ip_whitelist?: string[]; login_ip_whitelist?: string[];
default_pinned?: number[]; default_pinned?: number[];
allowed_nodes?: number[];
allow_select_node?: boolean;
} }
export interface AdminListGroupResponse { export interface AdminListGroupResponse {

@ -34,6 +34,8 @@ export interface SiteConfig {
sso_auto_redirect?: boolean; sso_auto_redirect?: boolean;
download_cdn_routes?: { name: string; url: string }[]; download_cdn_routes?: { name: string; url: string }[];
abuse_captcha?: boolean; abuse_captcha?: boolean;
allow_select_node?: boolean;
task_nodes?: { id: string; name: string }[];
logo?: string; logo?: string;
logo_light?: string; logo_light?: string;
tos_url?: string; tos_url?: string;

@ -6,6 +6,7 @@ export interface ArchiveWorkflowService {
encoding?: string; encoding?: string;
password?: string; password?: string;
file_mask?: string[]; file_mask?: string[];
target_node?: string;
} }
export interface TaskListResponse { export interface TaskListResponse {
@ -106,6 +107,7 @@ export interface DownloadWorkflowService {
password?: string; password?: string;
headers?: string[]; headers?: string[];
provider?: string; provider?: string;
target_node?: string;
} }
export interface ImportWorkflowService { export interface ImportWorkflowService {

@ -391,14 +391,32 @@ const FileManagementSection = () => {
</SettingForm> </SettingForm>
<SettingForm title={t("group.allowedNodes")} lgWidth={5}> <SettingForm title={t("group.allowedNodes")} lgWidth={5}>
<FormControl fullWidth> <FormControl fullWidth>
<MultipleNodeSelectionInput /> <MultipleNodeSelectionInput
value={values.settings?.allowed_nodes ?? []}
onChange={(v) =>
setGroup((p: GroupEnt) => ({
...p,
settings: { ...p.settings, allowed_nodes: v.length > 0 ? v : undefined },
}))
}
/>
<NoMarginHelperText>{t("group.allowedNodesDes")}</NoMarginHelperText> <NoMarginHelperText>{t("group.allowedNodesDes")}</NoMarginHelperText>
</FormControl> </FormControl>
</SettingForm> </SettingForm>
<SettingForm lgWidth={5}> <SettingForm lgWidth={5}>
<FormControl fullWidth> <FormControl fullWidth>
<FormControlLabel <FormControlLabel
control={<Switch checked={false} />} control={
<Switch
checked={!!values.settings?.allow_select_node}
onChange={(e) =>
setGroup((p: GroupEnt) => ({
...p,
settings: { ...p.settings, allow_select_node: e.target.checked ? true : undefined },
}))
}
/>
}
label={ label={
<Box sx={{ display: "flex", alignItems: "center" }}> <Box sx={{ display: "flex", alignItems: "center" }}>
{t("group.allowSelectNode")} {t("group.allowSelectNode")}

@ -1,40 +1,92 @@
import { ListItemText } from "@mui/material"; import { Box, Checkbox, FormControl, ListItemText, SelectChangeEvent } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getNodeList } from "../../../../api/api";
import { Node } from "../../../../api/dashboard";
import { useAppDispatch } from "../../../../redux/hooks"; import { useAppDispatch } from "../../../../redux/hooks";
import { DenseSelect } from "../../../Common/StyledComponents"; import { DenseSelect, SquareChip } from "../../../Common/StyledComponents";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu";
const MultipleNodeSelectionInput = () => { export interface MultipleNodeSelectionInputProps {
value: number[];
onChange: (value: number[]) => void;
}
// MultipleNodeSelectionInput picks the pool of nodes a group's tasks may run
// on. Empty means all nodes are eligible.
const MultipleNodeSelectionInput = ({ value, onChange }: MultipleNodeSelectionInputProps) => {
const { t } = useTranslation("dashboard"); const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [nodes, setNodes] = useState<Node[]>([]);
const [loading, setLoading] = useState(false);
const [nodeMap, setNodeMap] = useState<Record<number, Node>>({});
const handleChange = (event: SelectChangeEvent<unknown>) => {
const {
target: { value: v },
} = event;
onChange(typeof v === "string" ? v.split(",").map((x) => parseInt(x)) : (v as number[]));
};
useEffect(() => {
setLoading(true);
dispatch(getNodeList({ page: 1, page_size: 1000, order_by: "id", order_direction: "asc" }))
.then((res) => {
setNodes(res.nodes);
setNodeMap(
res.nodes.reduce(
(acc, n) => {
acc[n.id] = n;
return acc;
},
{} as Record<number, Node>,
),
);
})
.finally(() => {
setLoading(false);
});
}, []);
return ( return (
<FormControl fullWidth>
<DenseSelect <DenseSelect
multiple multiple
value={value}
onChange={handleChange}
displayEmpty displayEmpty
sx={{ sx={{
minHeight: 39, minHeight: 39,
}} }}
disabled={loading}
MenuProps={{ MenuProps={{
PaperProps: { sx: { maxWidth: 230 } }, PaperProps: { sx: { maxWidth: 300 } },
MenuListProps: {
sx: {
"& .MuiMenuItem-root": {
whiteSpace: "normal",
},
},
},
}} }}
renderValue={(selected) => { renderValue={(selected) =>
return ( (selected as number[]).length === 0 ? (
<ListItemText <ListItemText
primary={<em>{t("group.allNodes")}</em>} primary={<em>{t("group.allNodes")}</em>}
slotProps={{ slotProps={{
primary: { color: "textSecondary", variant: "body2" }, primary: { color: "textSecondary", variant: "body2" },
}} }}
/> />
); ) : (
}} <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, py: 0.5 }}>
></DenseSelect> {(selected as number[]).map((id) => (
<SquareChip key={id} label={nodeMap[id]?.name ?? id} size="small" />
))}
</Box>
)
}
>
{nodes.map((n) => (
<SquareMenuItem key={n.id} value={n.id}>
<Checkbox size="small" checked={value.indexOf(n.id) > -1} />
<ListItemText primary={n.name} secondary={`#${n.id} · ${n.type ?? ""}`} />
</SquareMenuItem>
))}
</DenseSelect>
</FormControl>
); );
}; };

@ -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;

@ -9,6 +9,7 @@ import { getFileLinkedUri } from "../../../util";
import CrUri from "../../../util/uri.ts"; import CrUri from "../../../util/uri.ts";
import { OutlineIconTextField } from "../../Common/Form/OutlineIconTextField.tsx"; import { OutlineIconTextField } from "../../Common/Form/OutlineIconTextField.tsx";
import { PathSelectorForm } from "../../Common/Form/PathSelectorForm.tsx"; import { PathSelectorForm } from "../../Common/Form/PathSelectorForm.tsx";
import TargetNodeSelect from "../../Common/Form/TargetNodeSelect.tsx";
import { ViewTaskAction } from "../../Common/Snackbar/snackbar.tsx"; import { ViewTaskAction } from "../../Common/Snackbar/snackbar.tsx";
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx"; import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
import Archive from "../../Icons/Archive.tsx"; import Archive from "../../Icons/Archive.tsx";
@ -24,6 +25,7 @@ const CreateArchive = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fileName, setFileName] = useState("archive.zip"); const [fileName, setFileName] = useState("archive.zip");
const [path, setPath] = useState(""); const [path, setPath] = useState("");
const [targetNode, setTargetNode] = useState("");
const open = useAppSelector((state) => state.globalState.createArchiveDialogOpen); const open = useAppSelector((state) => state.globalState.createArchiveDialogOpen);
const targets = useAppSelector((state) => state.globalState.createArchiveDialogFiles); const targets = useAppSelector((state) => state.globalState.createArchiveDialogFiles);
@ -32,6 +34,7 @@ const CreateArchive = () => {
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setPath(current ?? ""); setPath(current ?? "");
setTargetNode("");
} }
}, [open]); }, [open]);
@ -50,6 +53,7 @@ const CreateArchive = () => {
sendCreateArchive({ sendCreateArchive({
src: targets?.map((t) => getFileLinkedUri(t)), src: targets?.map((t) => getFileLinkedUri(t)),
dst: dst.join(fileName).toString(), dst: dst.join(fileName).toString(),
target_node: targetNode || undefined,
}), }),
) )
.then(() => { .then(() => {
@ -63,7 +67,7 @@ const CreateArchive = () => {
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
}); });
}, [targets, fileName, path]); }, [targets, fileName, path, targetNode]);
return ( return (
<DraggableDialog <DraggableDialog
@ -94,6 +98,7 @@ const CreateArchive = () => {
<Stack spacing={3} direction={isMobile ? "column" : "row"}> <Stack spacing={3} direction={isMobile ? "column" : "row"}>
<PathSelectorForm onChange={setPath} path={path} label={t("modals.saveToTitle")} /> <PathSelectorForm onChange={setPath} path={path} label={t("modals.saveToTitle")} />
</Stack> </Stack>
<TargetNodeSelect value={targetNode} onChange={setTargetNode} />
</Stack> </Stack>
</DialogContent> </DialogContent>
</DraggableDialog> </DraggableDialog>

@ -11,6 +11,7 @@ import CrUri, { Filesystem } from "../../../util/uri.ts";
import { FileDisplayForm } from "../../Common/Form/FileDisplayForm.tsx"; import { FileDisplayForm } from "../../Common/Form/FileDisplayForm.tsx";
import { OutlineIconTextField } from "../../Common/Form/OutlineIconTextField.tsx"; import { OutlineIconTextField } from "../../Common/Form/OutlineIconTextField.tsx";
import { PathSelectorForm } from "../../Common/Form/PathSelectorForm.tsx"; import { PathSelectorForm } from "../../Common/Form/PathSelectorForm.tsx";
import TargetNodeSelect from "../../Common/Form/TargetNodeSelect.tsx";
import { ViewTaskAction } from "../../Common/Snackbar/snackbar.tsx"; import { ViewTaskAction } from "../../Common/Snackbar/snackbar.tsx";
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx"; import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
import Edit from "../../Icons/Edit.tsx"; import Edit from "../../Icons/Edit.tsx";
@ -41,6 +42,7 @@ const CreateRemoteDownload = () => {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [headers, setHeaders] = useState(""); const [headers, setHeaders] = useState("");
const [provider, setProvider] = useState(""); const [provider, setProvider] = useState("");
const [targetNode, setTargetNode] = useState("");
const providers = useAppSelector((state) => state.siteConfig.explorer?.config?.remote_download_providers); const providers = useAppSelector((state) => state.siteConfig.explorer?.config?.remote_download_providers);
const open = useAppSelector((state) => state.globalState.remoteDownloadDialogOpen); const open = useAppSelector((state) => state.globalState.remoteDownloadDialogOpen);
@ -58,6 +60,7 @@ const CreateRemoteDownload = () => {
setPassword(""); setPassword("");
setHeaders(""); setHeaders("");
setProvider(""); setProvider("");
setTargetNode("");
} }
}, [open]); }, [open]);
@ -81,6 +84,7 @@ const CreateRemoteDownload = () => {
password: password || undefined, password: password || undefined,
headers: headers ? headers.split("\n").filter((h) => h.trim()) : undefined, headers: headers ? headers.split("\n").filter((h) => h.trim()) : undefined,
provider: provider || undefined, provider: provider || undefined,
target_node: targetNode || undefined,
}), }),
) )
.then(() => { .then(() => {
@ -94,7 +98,7 @@ const CreateRemoteDownload = () => {
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
}); });
}, [target, url, path, fileName, username, password, headers, provider]); }, [target, url, path, fileName, username, password, headers, provider, targetNode]);
return ( return (
<DraggableDialog <DraggableDialog
@ -148,6 +152,7 @@ const CreateRemoteDownload = () => {
fullWidth fullWidth
/> />
</Stack> </Stack>
<TargetNodeSelect value={targetNode} onChange={setTargetNode} />
{providers && providers.length > 1 && ( {providers && providers.length > 1 && (
<Stack spacing={3} direction={isMobile ? "column" : "row"}> <Stack spacing={3} direction={isMobile ? "column" : "row"}>
<FormControl variant="outlined" fullWidth> <FormControl variant="outlined" fullWidth>

@ -9,6 +9,7 @@ import { fileExtension, getFileLinkedUri } from "../../../util";
import EncodingSelector, { defaultEncodingValue } from "../../Common/Form/EncodingSelector.tsx"; import EncodingSelector, { defaultEncodingValue } from "../../Common/Form/EncodingSelector.tsx";
import { FileDisplayForm } from "../../Common/Form/FileDisplayForm.tsx"; import { FileDisplayForm } from "../../Common/Form/FileDisplayForm.tsx";
import { PathSelectorForm } from "../../Common/Form/PathSelectorForm.tsx"; import { PathSelectorForm } from "../../Common/Form/PathSelectorForm.tsx";
import TargetNodeSelect, { useShowTargetNodeSelect } from "../../Common/Form/TargetNodeSelect.tsx";
import { ViewTaskAction } from "../../Common/Snackbar/snackbar.tsx"; import { ViewTaskAction } from "../../Common/Snackbar/snackbar.tsx";
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx"; import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
import Password from "../../Icons/Password.tsx"; import Password from "../../Icons/Password.tsx";
@ -26,6 +27,7 @@ const ExtractArchive = () => {
const [encoding, setEncoding] = useState(defaultEncodingValue); const [encoding, setEncoding] = useState(defaultEncodingValue);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [targetNode, setTargetNode] = useState("");
const open = useAppSelector((state) => state.globalState.extractArchiveDialogOpen); const open = useAppSelector((state) => state.globalState.extractArchiveDialogOpen);
const target = useAppSelector((state) => state.globalState.extractArchiveDialogFile); const target = useAppSelector((state) => state.globalState.extractArchiveDialogFile);
@ -33,6 +35,7 @@ const ExtractArchive = () => {
const current = useAppSelector((state) => state.fileManager[FileManagerIndex.main].pure_path); const current = useAppSelector((state) => state.fileManager[FileManagerIndex.main].pure_path);
const mask = useAppSelector((state) => state.globalState.extractArchiveDialogMask); const mask = useAppSelector((state) => state.globalState.extractArchiveDialogMask);
const predefinedEncoding = useAppSelector((state) => state.globalState.extractArchiveDialogEncoding); const predefinedEncoding = useAppSelector((state) => state.globalState.extractArchiveDialogEncoding);
const showNodeSelect = useShowTargetNodeSelect();
useEffect(() => { useEffect(() => {
setEncoding(predefinedEncoding ?? defaultEncodingValue); setEncoding(predefinedEncoding ?? defaultEncodingValue);
@ -51,6 +54,7 @@ const ExtractArchive = () => {
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setPath(current ?? ""); setPath(current ?? "");
setTargetNode("");
} }
}, [open]); }, [open]);
@ -71,6 +75,7 @@ const ExtractArchive = () => {
encoding: showEncodingOption && encoding != defaultEncodingValue ? encoding : undefined, encoding: showEncodingOption && encoding != defaultEncodingValue ? encoding : undefined,
password: showPasswordOption && password ? password : undefined, password: showPasswordOption && password ? password : undefined,
file_mask: mask ?? undefined, file_mask: mask ?? undefined,
target_node: targetNode || undefined,
}), }),
) )
.then(() => { .then(() => {
@ -84,7 +89,7 @@ const ExtractArchive = () => {
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
}); });
}, [target, targets, encoding, path, showPasswordOption, showEncodingOption, password, mask]); }, [target, targets, encoding, path, showPasswordOption, showEncodingOption, password, mask, targetNode]);
return ( return (
<DraggableDialog <DraggableDialog
@ -136,6 +141,15 @@ const ExtractArchive = () => {
> >
<PathSelectorForm onChange={setPath} path={path} variant={"extractTo"} label={t("modals.decompressTo")} /> <PathSelectorForm onChange={setPath} path={path} variant={"extractTo"} label={t("modals.decompressTo")} />
</Grid2> </Grid2>
{showNodeSelect && (
<Grid2
size={{
xs: 12,
}}
>
<TargetNodeSelect value={targetNode} onChange={setTargetNode} />
</Grid2>
)}
{showPasswordOption && ( {showPasswordOption && (
<Grid2 <Grid2
size={{ size={{

@ -71,6 +71,12 @@ type (
// DefaultPinned is a list of share entity IDs seeded as share // DefaultPinned is a list of share entity IDs seeded as share
// shortcuts for members of this group on file-system init. // shortcuts for members of this group on file-system init.
DefaultPinned []int `json:"default_pinned,omitempty"` DefaultPinned []int `json:"default_pinned,omitempty"`
// AllowedNodes restricts which nodes this group's tasks may run on.
// Empty means all nodes are eligible.
AllowedNodes []int `json:"allowed_nodes,omitempty"`
// AllowSelectNode lets members pick a preferred node when creating
// tasks (remote download, archive create/extract).
AllowSelectNode bool `json:"allow_select_node,omitempty"`
} }
// PolicySetting 非公有的存储策略属性 // PolicySetting 非公有的存储策略属性

@ -63,7 +63,7 @@ func SlaveRPCSignRequired() gin.HandlerFunc {
return return
} }
slaveNode, err := np.Get(c, types.NodeCapabilityNone, nodeId) slaveNode, err := np.Get(c, types.NodeCapabilityNone, nodeId, nil)
if slaveNode == nil || slaveNode.IsMaster() { if slaveNode == nil || slaveNode.IsMaster() {
c.JSON(200, serializer.ParamErr(c, "Unknown node ID", err)) c.JSON(200, serializer.ParamErr(c, "Unknown node ID", err))
c.Abort() c.Abort()

@ -20,7 +20,7 @@ type NodePool interface {
Upsert(ctx context.Context, node *ent.Node) Upsert(ctx context.Context, node *ent.Node)
// Get returns a node with the given capability and preferred node id. `allowed` is a list of allowed node ids. // Get returns a node with the given capability and preferred node id. `allowed` is a list of allowed node ids.
// If `allowed` is empty, all nodes with the capability are considered. // If `allowed` is empty, all nodes with the capability are considered.
Get(ctx context.Context, capability types.NodeCapability, preferred int) (Node, error) Get(ctx context.Context, capability types.NodeCapability, preferred int, allowed []int) (Node, error)
} }
type ( type (
@ -85,7 +85,7 @@ func NewNodePool(ctx context.Context, l logging.Logger, config conf.ConfigProvid
return pool, nil return pool, nil
} }
func (p *weightedNodePool) Get(ctx context.Context, capability types.NodeCapability, preferred int) (Node, error) { func (p *weightedNodePool) Get(ctx context.Context, capability types.NodeCapability, preferred int, allowed []int) (Node, error) {
l := logging.FromContext(ctx) l := logging.FromContext(ctx)
p.lock.Lock() p.lock.Lock()
defer p.lock.Unlock() defer p.lock.Unlock()
@ -95,6 +95,18 @@ func (p *weightedNodePool) Get(ctx context.Context, capability types.NodeCapabil
return nil, fmt.Errorf("no node found with capability %d: %w", capability, ErrNoAvailableNode) return nil, fmt.Errorf("no node found with capability %d: %w", capability, ErrNoAvailableNode)
} }
if len(allowed) > 0 {
allowedSet := lo.SliceToMap(allowed, func(id int) (int, struct{}) { return id, struct{}{} })
filtered := lo.Filter(nodes, func(item *nodeItem, _ int) bool {
_, ok := allowedSet[item.node.ID()]
return ok
})
if len(filtered) == 0 {
return nil, fmt.Errorf("no allowed node found with capability %d: %w", capability, ErrNoAvailableNode)
}
nodes = filtered
}
var selected *nodeItem var selected *nodeItem
if preferred > 0 { if preferred > 0 {
@ -198,6 +210,6 @@ func NewSlaveDummyNodePool(ctx context.Context, config conf.ConfigProvider, sett
func (s *slaveDummyNodePool) Upsert(ctx context.Context, node *ent.Node) { func (s *slaveDummyNodePool) Upsert(ctx context.Context, node *ent.Node) {
} }
func (s *slaveDummyNodePool) Get(ctx context.Context, capability types.NodeCapability, preferred int) (Node, error) { func (s *slaveDummyNodePool) Get(ctx context.Context, capability types.NodeCapability, preferred int, allowed []int) (Node, error) {
return s.masterNode, nil return s.masterNode, nil
} }

@ -65,24 +65,60 @@ func TestWeightedNodePoolGet(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Capability with no matching nodes errors. // Capability with no matching nodes errors.
_, err = pool.Get(ctx, types.NodeCapabilityCreateArchive, 0) _, err = pool.Get(ctx, types.NodeCapabilityCreateArchive, 0, nil)
a.True(errors.Is(err, ErrNoAvailableNode)) a.True(errors.Is(err, ErrNoAvailableNode))
// Preferred node wins regardless of weight. // Preferred node wins regardless of weight.
selected, err := pool.Get(ctx, types.NodeCapabilityRemoteDownload, n1.ID) selected, err := pool.Get(ctx, types.NodeCapabilityRemoteDownload, n1.ID, nil)
require.NoError(t, err) require.NoError(t, err)
a.Equal(n1.ID, selected.ID()) a.Equal(n1.ID, selected.ID())
// Without preference, the heavier node wins the first pick. // Without preference, the heavier node wins the first pick.
selected, err = pool.Get(ctx, types.NodeCapabilityRemoteDownload, 0) selected, err = pool.Get(ctx, types.NodeCapabilityRemoteDownload, 0, nil)
require.NoError(t, err) require.NoError(t, err)
a.Equal(n1.ID, selected.ID()) a.Equal(n1.ID, selected.ID())
} }
func TestWeightedNodePoolGetAllowed(t *testing.T) {
a := assert.New(t)
client, pool, ctx := newPoolFixture(t)
n1 := client.Node.Create().
SetName("n1").SetType(node.TypeMaster).SetStatus(node.StatusActive).
SetServer("http://n1").SetCapabilities(capsOf(types.NodeCapabilityRemoteDownload)).
SetWeight(10).SaveX(ctx)
n2 := client.Node.Create().
SetName("n2").SetType(node.TypeMaster).SetStatus(node.StatusActive).
SetServer("http://n2").SetCapabilities(capsOf(types.NodeCapabilityRemoteDownload)).
SetWeight(1).SaveX(ctx)
pool, err := NewNodePool(ctx, logging.NewConsoleLogger(logging.LevelError), pool.(*weightedNodePool).conf, &stubSettings{}, inventory.NewNodeClient(client))
require.NoError(t, err)
// Allowed list narrows dispatch: heavier n1 excluded, n2 wins.
selected, err := pool.Get(ctx, types.NodeCapabilityRemoteDownload, 0, []int{n2.ID})
require.NoError(t, err)
a.Equal(n2.ID, selected.ID())
// Preferred node outside the allowed set falls back within the set.
selected, err = pool.Get(ctx, types.NodeCapabilityRemoteDownload, n1.ID, []int{n2.ID})
require.NoError(t, err)
a.Equal(n2.ID, selected.ID())
// Preferred node inside the allowed set wins.
selected, err = pool.Get(ctx, types.NodeCapabilityRemoteDownload, n1.ID, []int{n1.ID, n2.ID})
require.NoError(t, err)
a.Equal(n1.ID, selected.ID())
// Empty allowed intersection errors.
_, err = pool.Get(ctx, types.NodeCapabilityRemoteDownload, 0, []int{9999})
a.True(errors.Is(err, ErrNoAvailableNode))
}
func TestWeightedNodePoolGetUnknownCapability(t *testing.T) { func TestWeightedNodePoolGetUnknownCapability(t *testing.T) {
a := assert.New(t) a := assert.New(t)
_, pool, ctx := newPoolFixture(t) _, pool, ctx := newPoolFixture(t)
_, err := pool.Get(ctx, types.NodeCapabilityRemoteDownload, 0) _, err := pool.Get(ctx, types.NodeCapabilityRemoteDownload, 0, nil)
a.True(errors.Is(err, ErrNoAvailableNode)) a.True(errors.Is(err, ErrNoAvailableNode))
} }

@ -75,11 +75,11 @@ func init() {
} }
// NewCreateArchiveTask creates a new CreateArchiveTask // NewCreateArchiveTask creates a new CreateArchiveTask
func NewCreateArchiveTask(ctx context.Context, src []string, dst string) (queue.Task, error) { func NewCreateArchiveTask(ctx context.Context, src []string, dst string, sel NodeSelection) (queue.Task, error) {
state := &CreateArchiveTaskState{ state := &CreateArchiveTaskState{
Uris: src, Uris: src,
Dst: dst, Dst: dst,
NodeState: NodeState{}, NodeState: sel.state(),
} }
stateBytes, err := json.Marshal(state) stateBytes, err := json.Marshal(state)
if err != nil { if err != nil {

@ -76,12 +76,12 @@ func init() {
// NewExtractArchiveTask creates a new ExtractArchiveTask. volumes optionally // NewExtractArchiveTask creates a new ExtractArchiveTask. volumes optionally
// lists the URIs of all volumes of a multi-volume archive selected together. // lists the URIs of all volumes of a multi-volume archive selected together.
func NewExtractArchiveTask(ctx context.Context, src, dst, encoding, password string, mask []string, volumes []string) (queue.Task, error) { func NewExtractArchiveTask(ctx context.Context, src, dst, encoding, password string, mask []string, volumes []string, sel NodeSelection) (queue.Task, error) {
state := &ExtractArchiveTaskState{ state := &ExtractArchiveTaskState{
Uri: src, Uri: src,
Dst: dst, Dst: dst,
Encoding: encoding, Encoding: encoding,
NodeState: NodeState{}, NodeState: sel.state(),
Password: password, Password: password,
FileMask: mask, FileMask: mask,
VolumeUris: volumes, VolumeUris: volumes,
@ -698,7 +698,7 @@ func (m *SlaveExtractArchiveTask) Do(ctx context.Context) (task.Status, error) {
return task.StatusError, fmt.Errorf("failed to get node pool: %w", err) return task.StatusError, fmt.Errorf("failed to get node pool: %w", err)
} }
m.node, err = np.Get(ctx, types.NodeCapabilityNone, 0) m.node, err = np.Get(ctx, types.NodeCapabilityNone, 0, nil)
if err != nil || !m.node.IsMaster() { if err != nil || !m.node.IsMaster() {
return task.StatusError, fmt.Errorf("failed to get master node: %w", err) return task.StatusError, fmt.Errorf("failed to get master node: %w", err)
} }

@ -103,6 +103,8 @@ type RemoteDownloadTaskOption struct {
HTTPUsername string HTTPUsername string
HTTPPassword string HTTPPassword string
HTTPHeaders []string HTTPHeaders []string
// NodeSel carries the user/group node constraints for dispatch.
NodeSel NodeSelection
} }
// NewRemoteDownloadTask creates a new RemoteDownloadTask // NewRemoteDownloadTask creates a new RemoteDownloadTask
@ -119,6 +121,7 @@ func NewRemoteDownloadTask(ctx context.Context, src string, srcFile, dst string,
state.HTTPUsername = opts.HTTPUsername state.HTTPUsername = opts.HTTPUsername
state.HTTPPassword = opts.HTTPPassword state.HTTPPassword = opts.HTTPPassword
state.HTTPHeaders = opts.HTTPHeaders state.HTTPHeaders = opts.HTTPHeaders
state.NodeState = opts.NodeSel.state()
} }
stateBytes, err := json.Marshal(state) stateBytes, err := json.Marshal(state)
if err != nil { if err != nil {

@ -76,7 +76,7 @@ func (t *SlaveUploadTask) Do(ctx context.Context) (task.Status, error) {
return task.StatusError, fmt.Errorf("failed to get node pool: %w", err) return task.StatusError, fmt.Errorf("failed to get node pool: %w", err)
} }
t.node, err = np.Get(ctx, types.NodeCapabilityNone, 0) t.node, err = np.Get(ctx, types.NodeCapabilityNone, 0, nil)
if err != nil || !t.node.IsMaster() { if err != nil || !t.node.IsMaster() {
return task.StatusError, fmt.Errorf("failed to get master node: %w", err) return task.StatusError, fmt.Errorf("failed to get master node: %w", err)
} }

@ -25,10 +25,25 @@ const (
type NodeState struct { type NodeState struct {
NodeID int `json:"node_id"` NodeID int `json:"node_id"`
// AllowedNodes restricts auto-dispatch to the group's node pool, captured
// at task creation so later group edits don't reroute queued tasks.
AllowedNodes []int `json:"allowed_nodes,omitempty"`
progress queue.Progresses progress queue.Progresses
} }
// NodeSelection carries the caller-side node constraints for a new task:
// TargetNodeID is an explicit user pick (0 = auto), AllowedNodes is the
// group's eligible pool (empty = all).
type NodeSelection struct {
TargetNodeID int
AllowedNodes []int
}
func (s NodeSelection) state() NodeState {
return NodeState{NodeID: s.TargetNodeID, AllowedNodes: s.AllowedNodes}
}
// allocateNode allocates a node for the task. // allocateNode allocates a node for the task.
func allocateNode(ctx context.Context, dep dependency.Dep, state *NodeState, capability types.NodeCapability) (cluster.Node, error) { func allocateNode(ctx context.Context, dep dependency.Dep, state *NodeState, capability types.NodeCapability) (cluster.Node, error) {
np, err := dep.NodePool(ctx) np, err := dep.NodePool(ctx)
@ -36,7 +51,7 @@ func allocateNode(ctx context.Context, dep dependency.Dep, state *NodeState, cap
return nil, fmt.Errorf("failed to get node pool: %w", err) return nil, fmt.Errorf("failed to get node pool: %w", err)
} }
node, err := np.Get(ctx, capability, state.NodeID) node, err := np.Get(ctx, capability, state.NodeID, state.AllowedNodes)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get node: %w", err) return nil, fmt.Errorf("failed to get node: %w", err)
} }

@ -1,12 +1,15 @@
package basic package basic
import ( import (
"slices"
"sort" "sort"
"strings" "strings"
"github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory" "github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/setting" "github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/thumb" "github.com/cloudreve/Cloudreve/v4/pkg/thumb"
"github.com/cloudreve/Cloudreve/v4/service/user" "github.com/cloudreve/Cloudreve/v4/service/user"
@ -57,6 +60,12 @@ type SiteConfig struct {
// AbuseCaptcha controls whether the report-abuse dialog shows captcha. // AbuseCaptcha controls whether the report-abuse dialog shows captcha.
AbuseCaptcha bool `json:"abuse_captcha,omitempty"` AbuseCaptcha bool `json:"abuse_captcha,omitempty"`
// TaskNodes lists nodes the current user may target when creating tasks
// (remote download, archive ops); populated when the group allows node
// selection, filtered to the group's allowed pool.
TaskNodes []TaskNode `json:"task_nodes,omitempty"`
AllowSelectNode bool `json:"allow_select_node,omitempty"`
// Explorer section // Explorer section
Icons string `json:"icons,omitempty"` Icons string `json:"icons,omitempty"`
EmojiPreset string `json:"emoji_preset,omitempty"` EmojiPreset string `json:"emoji_preset,omitempty"`
@ -94,6 +103,12 @@ type SiteConfig struct {
//AppForumLink string `json:"app_forum"` //AppForumLink string `json:"app_forum"`
} }
// TaskNode is the minimal public node descriptor for task targeting.
type TaskNode struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ( type (
GetSettingService struct { GetSettingService struct {
Section string `uri:"section" binding:"required"` Section string `uri:"section" binding:"required"`
@ -217,6 +232,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
customNavItems := settings.CustomNavItems(c) customNavItems := settings.CustomNavItems(c)
customHTML := settings.CustomHTML(c) customHTML := settings.CustomHTML(c)
shareDefaults := settings.ShareDefaults(c) shareDefaults := settings.ShareDefaults(c)
taskNodes, allowSelect := taskNodesForUser(c, dep, u)
return &SiteConfig{ return &SiteConfig{
InstanceID: siteBasic.ID, InstanceID: siteBasic.ID,
SiteName: siteBasic.Name, SiteName: siteBasic.Name,
@ -238,9 +254,49 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
DefaultShareLinksInProfile: string(shareDefaults.LinksInProfile), DefaultShareLinksInProfile: string(shareDefaults.LinksInProfile),
DownloadCDNRoutes: settings.DownloadCDNRoutes(c), DownloadCDNRoutes: settings.DownloadCDNRoutes(c),
AbuseCaptcha: settings.AbuseCaptchaEnabled(c), AbuseCaptcha: settings.AbuseCaptchaEnabled(c),
TaskNodes: taskNodes,
AllowSelectNode: allowSelect,
}, nil }, nil
} }
// taskNodesForUser returns the nodes a user may target for tasks: active
// nodes with any task capability, intersected with the group's allowed pool.
// Returns nil when the group disallows selection.
func taskNodesForUser(c *gin.Context, dep dependency.Dep, u *ent.User) ([]TaskNode, bool) {
if u == nil || u.Edges.Group == nil || !u.Edges.Group.Settings.AllowSelectNode {
return nil, false
}
nodes, err := dep.NodeClient().ListActiveNodes(c, nil)
if err != nil {
return nil, true
}
allowed := u.Edges.Group.Settings.AllowedNodes
taskCaps := []types.NodeCapability{
types.NodeCapabilityCreateArchive,
types.NodeCapabilityExtractArchive,
types.NodeCapabilityRemoteDownload,
}
res := make([]TaskNode, 0, len(nodes))
for _, n := range nodes {
if len(allowed) > 0 && !slices.Contains(allowed, n.ID) {
continue
}
capable := false
for _, cap := range taskCaps {
if n.Capabilities != nil && n.Capabilities.Enabled(int(cap)) {
capable = true
break
}
}
if capable {
res = append(res, TaskNode{ID: hashid.EncodeNodeID(dep.HashIDEncoder(), n.ID), Name: n.Name})
}
}
return res, true
}
const ( const (
CaptchaSessionPrefix = "captcha_session_" CaptchaSessionPrefix = "captcha_session_"
CaptchaTTL = 1800 // 30 minutes CaptchaTTL = 1800 // 30 minutes

@ -10,6 +10,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent" "github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/node"
"github.com/cloudreve/Cloudreve/v4/ent/task" "github.com/cloudreve/Cloudreve/v4/ent/task"
"github.com/cloudreve/Cloudreve/v4/inventory" "github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/inventory/types"
@ -25,6 +26,7 @@ import (
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
"github.com/samber/lo" "github.com/samber/lo"
"math" "math"
"slices"
) )
// ItemMoveService 处理多文件/目录移动 // ItemMoveService 处理多文件/目录移动
@ -83,10 +85,45 @@ type (
Password string `json:"password" binding:"omitempty,max=255"` Password string `json:"password" binding:"omitempty,max=255"`
Headers []string `json:"headers" binding:"omitempty,max=32,dive,max=2048"` Headers []string `json:"headers" binding:"omitempty,max=32,dive,max=2048"`
Provider string `json:"provider" binding:"omitempty,max=64"` Provider string `json:"provider" binding:"omitempty,max=64"`
TargetNode string `json:"target_node" binding:"omitempty,max=64"`
} }
CreateDownloadParamCtx struct{} CreateDownloadParamCtx struct{}
) )
// resolveNodeSelection validates the caller's target_node pick against group
// constraints and returns the NodeSelection for task creation. An empty
// target means auto dispatch; the group's allowed pool always applies.
func resolveNodeSelection(c *gin.Context, dep dependency.Dep, target string, capability types.NodeCapability) (*workflows.NodeSelection, error) {
user := inventory.UserFromContext(c)
sel := &workflows.NodeSelection{AllowedNodes: user.Edges.Group.Settings.AllowedNodes}
if target == "" {
return sel, nil
}
if !user.Edges.Group.Settings.AllowSelectNode {
return nil, serializer.NewError(serializer.CodeGroupNotAllowed, "Group not allowed to select node", nil)
}
nodeID, err := dep.HashIDEncoder().Decode(target, hashid.NodeID)
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid target node", err)
}
if len(sel.AllowedNodes) > 0 && !slices.Contains(sel.AllowedNodes, nodeID) {
return nil, serializer.NewError(serializer.CodeParamErr, "Target node not allowed for this group", nil)
}
n, err := dep.NodeClient().GetNodeById(c, nodeID)
if err != nil || n.Status != node.StatusActive ||
n.Capabilities == nil || !n.Capabilities.Enabled(int(capability)) {
return nil, serializer.NewError(serializer.CodeParamErr, "Target node unavailable", err)
}
sel.TargetNodeID = nodeID
return sel, nil
}
func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*TaskResponse, error) { func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*TaskResponse, error) {
dep := dependency.FromContext(c) dep := dependency.FromContext(c)
user := inventory.UserFromContext(c) user := inventory.UserFromContext(c)
@ -169,6 +206,11 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid downloader provider", nil) return nil, serializer.NewError(serializer.CodeParamErr, "Invalid downloader provider", nil)
} }
nodeSel, err := resolveNodeSelection(c, dep, service.TargetNode, types.NodeCapabilityRemoteDownload)
if err != nil {
return nil, err
}
// Custom file name only applies to single-source tasks; HTTP credentials // Custom file name only applies to single-source tasks; HTTP credentials
// and headers only apply to plain HTTP(S) source URLs. // and headers only apply to plain HTTP(S) source URLs.
taskOpts := &workflows.RemoteDownloadTaskOption{ taskOpts := &workflows.RemoteDownloadTaskOption{
@ -176,6 +218,7 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T
HTTPUsername: service.Username, HTTPUsername: service.Username,
HTTPPassword: service.Password, HTTPPassword: service.Password,
HTTPHeaders: service.Headers, HTTPHeaders: service.Headers,
NodeSel: *nodeSel,
} }
if len(service.Src) <= 1 { if len(service.Src) <= 1 {
taskOpts.FileName = service.FileName taskOpts.FileName = service.FileName
@ -232,6 +275,7 @@ type (
Encoding string `json:"encoding"` Encoding string `json:"encoding"`
Password string `json:"password"` Password string `json:"password"`
FileMask []string `json:"file_mask"` FileMask []string `json:"file_mask"`
TargetNode string `json:"target_node" binding:"omitempty,max=64"`
} }
CreateArchiveParamCtx struct{} CreateArchiveParamCtx struct{}
) )
@ -270,8 +314,13 @@ func (service *ArchiveWorkflowService) CreateExtractTask(c *gin.Context) (*TaskR
volumes = service.Src volumes = service.Src
} }
nodeSel, err := resolveNodeSelection(c, dep, service.TargetNode, types.NodeCapabilityExtractArchive)
if err != nil {
return nil, err
}
// Create task // Create task
t, err := workflows.NewExtractArchiveTask(c, src, service.Dst, service.Encoding, service.Password, service.FileMask, volumes) t, err := workflows.NewExtractArchiveTask(c, src, service.Dst, service.Encoding, service.Password, service.FileMask, volumes, *nodeSel)
if err != nil { if err != nil {
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err) return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err)
} }
@ -316,8 +365,13 @@ func (service *ArchiveWorkflowService) CreateCompressTask(c *gin.Context) (*Task
} }
m.OnUploadFailed(c, session) m.OnUploadFailed(c, session)
nodeSel, err := resolveNodeSelection(c, dep, service.TargetNode, types.NodeCapabilityCreateArchive)
if err != nil {
return nil, err
}
// Create task // Create task
t, err := workflows.NewCreateArchiveTask(c, service.Src, service.Dst) t, err := workflows.NewCreateArchiveTask(c, service.Src, service.Dst, *nodeSel)
if err != nil { if err != nil {
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err) return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err)
} }

@ -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…
Cancel
Save