mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
feat: add confirmed Take down stack action with optional volume removal (#1599)
* feat: add confirmed Take down stack action with optional volume removal Expose Take down in the stack header and sidebar with a confirmation dialog that runs compose down while keeping the stack definition on disk. Optional volume removal is gated by node capability and stack:deploy permission, with remote gateway preflight before proxying removeVolumes requests. Closes #1582 * fix: reset take-down volume checkbox when dialog closes * test: align getStackMenuVisibility assertions with showTakeDown key getStackMenuVisibility now returns a fifth lifecycle flag, showTakeDown, but three exhaustive toEqual assertions still listed only the prior four keys and failed. Add the expected showTakeDown value to each: true for the partial and exited running-stack cases, false for the self stack. * test: cover Take down visibility for running non-self stacks The getStackMenuVisibility assertions exercised the partial and exited branches and the self-stack guard, but not the raw === 'running' literal that drives showTakeDown for a normal running stack. Add a case so a regression dropping 'running' from that check is caught. * fix: drop Take down from header overflow and wire activity shortcut Remove duplicate Take down from More actions. Keep inline button when running, sidebar menu, and Cmd+ArrowDown. Record stack_taken_down in activity on successful POST /down.
This commit is contained in:
@@ -2,53 +2,51 @@ import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { CROSS_NODE_RBAC_CAPABILITY } from '../services/CapabilityRegistry';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
// In-flight probes deduped per node so a burst of concurrent gated requests
|
||||
// shares one /api/meta round-trip. The entry is dropped as soon as it settles,
|
||||
// so the NEXT request re-probes. The verdict is deliberately NOT cached across
|
||||
// requests: a remote can be replaced by older code at the same URL (a rollback
|
||||
// or image pin), and a stale "supported" verdict would reopen the cross-node
|
||||
// escalation, so each gated action re-verifies against the live remote.
|
||||
const inFlight = new Map<number, Promise<boolean>>();
|
||||
// In-flight probes deduped per node+capability so concurrent checks for
|
||||
// different capabilities on the same node cannot share the wrong boolean.
|
||||
const inFlight = new Map<string, Promise<boolean>>();
|
||||
|
||||
function probeKey(nodeId: number, capability: string): string {
|
||||
return `${nodeId}:${capability}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a remote node advertises that it enforces cross-node RBAC: the
|
||||
* forwarded actor role on HTTP requests and the exact-stack allowlist on
|
||||
* stop-by-label. Probes the remote's live /api/meta on every call (concurrent
|
||||
* calls for the same node share one probe).
|
||||
* Whether a remote node advertises a given capability. Probes the remote's live
|
||||
* /api/meta on every call (concurrent calls for the same node+capability share
|
||||
* one probe).
|
||||
*
|
||||
* Fails closed: a remote that does not advertise the capability, that cannot be
|
||||
* read (offline/unreachable, which yields empty capabilities), or that errors
|
||||
* is treated as unsupported, so the caller denies rather than risk escalating a
|
||||
* non-admin request or over-stopping. Because the probe is live, a remote
|
||||
* downgraded to older code is detected on the next gated action rather than
|
||||
* trusted until a cache expires.
|
||||
* Fails closed: unsupported, offline, or unreachable remotes return false.
|
||||
*/
|
||||
export async function remoteSupportsCrossNodeRbac(nodeId: number): Promise<boolean> {
|
||||
const existing = inFlight.get(nodeId);
|
||||
export async function remoteAdvertisesCapability(nodeId: number, capability: string): Promise<boolean> {
|
||||
const key = probeKey(nodeId, capability);
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const probe = (async (): Promise<boolean> => {
|
||||
try {
|
||||
const meta = await NodeRegistry.getInstance().fetchMetaForNode(nodeId);
|
||||
// Check the advertised capability directly. An offline/unreadable remote
|
||||
// yields OFFLINE_META with empty capabilities, so this already fails
|
||||
// closed; keying off the capability (not the version) also correctly
|
||||
// trusts a reachable remote whose version string is non-semver, e.g. a
|
||||
// 0.0.0-dev image, but that genuinely advertises the capability.
|
||||
return meta.capabilities.includes(CROSS_NODE_RBAC_CAPABILITY);
|
||||
return meta.capabilities.includes(capability);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[CrossNodeRBAC] Could not verify capability for node ${nodeId}; treating as unsupported:`,
|
||||
`[RemoteCapability] Could not verify "${capability}" for node ${nodeId}; treating as unsupported:`,
|
||||
getErrorMessage(err, 'unknown'),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
inFlight.set(nodeId, probe);
|
||||
inFlight.set(key, probe);
|
||||
try {
|
||||
return await probe;
|
||||
} finally {
|
||||
inFlight.delete(nodeId);
|
||||
inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a remote node advertises cross-node RBAC enforcement for proxied
|
||||
* requests. Thin wrapper over {@link remoteAdvertisesCapability}.
|
||||
*/
|
||||
export async function remoteSupportsCrossNodeRbac(nodeId: number): Promise<boolean> {
|
||||
return remoteAdvertisesCapability(nodeId, CROSS_NODE_RBAC_CAPABILITY);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user