mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
92d974b13e
* fix: condition --volumes in downStack() on the removeVolumes option
ComposeService.downStack() hardcoded --volumes on every stack delete,
ignoring the "Also remove associated volumes" checkbox and destroying
volumes the operator asked to keep. The sibling Take-down path (runDown)
already conditions --volumes correctly.
- Add options?: { removeVolumes?: boolean } to downStack()
- Default to data-preserving (no --volumes when option absent)
- DeletedStackDeletionService reads the persisted intent flag
- Templates rollback passes removeVolumes: true (clean up failed deploy)
- Blueprint withdraw passes removeVolumes: false (volumes preserved)
* docs: update Delete row to reflect conditional volume removal
The Delete row now describes that volumes are removed only when the
operator opts in, matching the behavior introduced by the downStack fix.
* fix: add capability gate for delete pruneVolumes and fix QA findings
Four P0 issues found in live QA:
P0-1/P0-4 - No capability gate on delete's pruneVolumes:
Add stack-delete-prune-volumes capability so the frontend hides the
"Also remove associated volumes" checkbox on nodes that don't support
conditional volume removal on delete. Without this, an operator on an
old node sees a VOLUMES KEPT promise the old node silently breaks.
Frontend-only gate: no API or proxy gate because the old node's
fallback (always destroy) is correct for the checked case.
P0-2 - Checkbox state leaked across dialogs:
Reset pruneVolumes in onConfirm before calling the parent, so a
previously checked box doesn't appear pre-checked when the dialog
opens for a different stack.
P0-3 - Delete not bound to the active node:
Capture activeNode.id at delete time and pass it as an explicit
nodeId to apiFetch, matching the Take Down pattern. Without this,
switching the active node while the dialog is open silently deletes
the wrong stack on the wrong node.
* fix: update test assertions for nodeId binding and showVolumeOption gate
P0-3 added nodeId to apiFetch DELETE calls — two useStackActions tests
now expect the parameter. P0-1 gated the volume checkbox behind
showVolumeOption — the confirming test now passes the prop.
* fix: gate volume hint on showVolumeOption to prevent false promise
On nodes without stack-delete-prune-volumes, volumes are always
destroyed. Showing VOLUMES KEPT was a lie. Now the hint is hidden
entirely when the capability is absent.
* fix: gate delete against nodes that cannot guarantee volume preservation
Hiding the checkbox and the misleading hint stopped the false promise but
not the data loss: an unchecked delete against a node lacking
stack-delete-prune-volumes still reached that node and its downStack()
still destroyed volumes unconditionally, now with no warning at all.
- remoteNodeProxy.ts: block an unacknowledged DELETE /stacks/:name
(no pruneVolumes=true) to a remote lacking the capability, mirroring
the existing removeVolumes gate on the down route. An explicit
pruneVolumes=true always proxies through since that matches what an
unsupported remote does anyway.
- DeleteStackDialog: rework around a three-state model (supported /
unsupported / unknown) instead of a boolean. A node whose capabilities
have not been confirmed (meta not yet fetched, or a failed probe) is
now treated like a supported node, not forced onto the destructive
path just because its state is unresolved.
- Fix deleteStack's error toast, which surfaced the raw JSON response
body instead of the parsed error message.
- Fix CreateStackDialog's orphan-stack rollback (docker-run import),
which silently no-op'd against a node requiring acknowledgement.
- Update node-compatibility.mdx and stack-management.mdx to describe
the new gate.
* test: advertise stack-delete-prune-volumes on the scoped-evidence fixtures
These mock remotes simulate nodes capable enough to run scoped-stack-auth-evidence
RBAC and were pinned before stack-delete-prune-volumes existed, so the new delete
gate now blocked their unacknowledged DELETE calls before reaching the mock server,
failing the grant-tuple-cleanup assertions the tests actually check.
269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
import axios from 'axios';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import semver from 'semver';
|
|
import { SENCHO_VERSION } from '../generated/version';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
import type { ImagePinKind } from '../helpers/selfUpdateCompose';
|
|
|
|
const IMAGE_PIN_KINDS: readonly ImagePinKind[] = ['floating', 'semver', 'digest', 'unknown'];
|
|
|
|
/** Coerce an untrusted /api/meta value to a known pin kind, or null. */
|
|
function parseImagePinKind(value: unknown): ImagePinKind | null {
|
|
return typeof value === 'string' && (IMAGE_PIN_KINDS as readonly string[]).includes(value)
|
|
? (value as ImagePinKind)
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Static registry of capabilities supported by THIS Sencho instance.
|
|
* Append-only: when a new feature ships, add its capability string here.
|
|
* The frontend uses these flags (not semver comparisons) to gate features
|
|
* on nodes that may be running older versions.
|
|
*/
|
|
export const CAPABILITIES = [
|
|
'stacks',
|
|
'containers',
|
|
'resources',
|
|
'templates',
|
|
'global-logs',
|
|
'system-stats',
|
|
'fleet',
|
|
'auto-updates',
|
|
'labels',
|
|
'webhooks',
|
|
'network-topology',
|
|
'notifications',
|
|
'notification-routing',
|
|
'notification-suppression',
|
|
'notification-suppression-schedule',
|
|
'notification-suppression-replica-retraction',
|
|
'host-console',
|
|
'host-console-community',
|
|
'container-exec',
|
|
'audit-log',
|
|
'scheduled-ops',
|
|
'sso',
|
|
'authentication-mode',
|
|
'api-tokens',
|
|
'users',
|
|
'registries',
|
|
'self-update',
|
|
'vulnerability-scanning',
|
|
'compose-doctor',
|
|
'update-guard',
|
|
'compose-networking',
|
|
'env-inventory',
|
|
'container-label-inventory',
|
|
'project-env-files',
|
|
'compose-storage',
|
|
'cross-node-rbac',
|
|
'stack-down-remove-volumes',
|
|
'stack-delete-prune-volumes',
|
|
'guided-external-network-preflight',
|
|
'service-scoped-update',
|
|
'service-scoped-stack-alert',
|
|
'scoped-stack-auth-evidence',
|
|
] as const;
|
|
|
|
/**
|
|
* Advertised by instances that enforce the proxied actor's role (instead of
|
|
* treating every node-to-node request as admin) and honor the exact-stack
|
|
* allowlist on stop-by-label. The control instance refuses to forward a
|
|
* non-admin's request, or a confirmed stop, to a remote lacking this flag so a
|
|
* mixed-version fleet cannot escalate or over-stop on an un-upgraded node.
|
|
*/
|
|
export const CROSS_NODE_RBAC_CAPABILITY = 'cross-node-rbac';
|
|
|
|
export type Capability = (typeof CAPABILITIES)[number];
|
|
|
|
/** Legacy Host Console advertisement (Admiral hubs still accept this on remotes). */
|
|
export const HOST_CONSOLE_CAPABILITY = 'host-console' as const satisfies Capability;
|
|
|
|
/** Host Console works without a paid license on this node. */
|
|
export const HOST_CONSOLE_COMMUNITY_CAPABILITY = 'host-console-community' as const satisfies Capability;
|
|
|
|
/** Remotes that evaluate weekly maintenance windows on mute/suppression replicas. */
|
|
export const NOTIFICATION_SUPPRESSION_SCHEDULE_CAPABILITY =
|
|
'notification-suppression-schedule' as const satisfies Capability;
|
|
|
|
/**
|
|
* Remotes that accept hub-authored `{ kind, source_updated_at }` on replica DELETE
|
|
* and persist versioned tombstones. Without this, hubs must not send recoverable
|
|
* soft-cleanup DELETEs (pre-tombstone remotes would bare-delete with no guard).
|
|
*/
|
|
export const NOTIFICATION_SUPPRESSION_REPLICA_RETRACTION_CAPABILITY =
|
|
'notification-suppression-replica-retraction' as const satisfies Capability;
|
|
|
|
/** Capability for optional `?removeVolumes=true` on POST /stacks/:name/down. */
|
|
export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability;
|
|
|
|
/** Capability for honoring `?pruneVolumes` on DELETE /stacks/:name. Nodes without this
|
|
* capability always destroy volumes on delete (pre-existing behavior); nodes with it
|
|
* honor the operator's checkbox choice. */
|
|
export const STACK_DELETE_PRUNE_VOLUMES_CAPABILITY = 'stack-delete-prune-volumes' as const satisfies Capability;
|
|
|
|
/** Capability for the nested per-service update/restore routes and the `effective-services` model they read. */
|
|
export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const satisfies Capability;
|
|
|
|
/** Capability for nullable `service_name` on stack alert rules and per-service cooldown evaluation. */
|
|
export const SERVICE_SCOPED_STACK_ALERT_CAPABILITY =
|
|
'service-scoped-stack-alert' as const satisfies Capability;
|
|
|
|
/**
|
|
* Remotes that consume hub-bound scoped stack auth evidence headers
|
|
* (`x-sencho-scoped-stack-name` / `x-sencho-scoped-stack-actions`) under
|
|
* machine auth. Hubs fail closed when scoped elevation is needed and the
|
|
* remote lacks this flag.
|
|
*/
|
|
export const SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY =
|
|
'scoped-stack-auth-evidence' as const satisfies Capability;
|
|
|
|
/** Returns true when the string is a usable semver version. */
|
|
export function isValidVersion(v: string | null | undefined): v is string {
|
|
return !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.valid(v);
|
|
}
|
|
|
|
// Resolved once per process at import time, then cached.
|
|
function resolveVersion(): string | null {
|
|
// Primary: walk up to find the root package.json (always authoritative).
|
|
// The generated SENCHO_VERSION constant can be stale when a branch falls
|
|
// behind a release-please version bump, so we prefer the live value.
|
|
let dir = __dirname;
|
|
for (let i = 0; i < 5; i++) {
|
|
const candidate = path.join(dir, 'package.json');
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
if (pkg.name === 'sencho') return pkg.version;
|
|
} catch { /* not found, keep walking */ }
|
|
dir = path.dirname(dir);
|
|
}
|
|
// Fallback: build-time constant (may be stale in dev, but correct in Docker)
|
|
if (SENCHO_VERSION !== '0.0.0-dev') return SENCHO_VERSION;
|
|
console.warn('[CapabilityRegistry] Could not resolve Sencho version from any source');
|
|
return null;
|
|
}
|
|
|
|
const cachedVersion = resolveVersion();
|
|
|
|
export function getSenchoVersion(): string | null {
|
|
return cachedVersion;
|
|
}
|
|
|
|
export interface RemoteMeta {
|
|
version: string | null;
|
|
capabilities: string[];
|
|
startedAt: number | null;
|
|
/** Error message from a failed self-update attempt on the remote node. */
|
|
updateError: string | null;
|
|
/** True when the /api/meta request succeeded (node is reachable). */
|
|
online: boolean;
|
|
/**
|
|
* How the remote pins its Sencho image, when it advertises it. Null for an
|
|
* older remote that predates this field or one that could not classify its
|
|
* pin. The hub uses only this safe subset (no full image ref) for remote rows.
|
|
*/
|
|
imagePinKind: ImagePinKind | null;
|
|
/** True when the remote reports its update is blocked (digest/unknown pin). */
|
|
updateBlocked: boolean;
|
|
/**
|
|
* Coarse image channel from the remote public meta. Null when the remote is
|
|
* older than this field or offline. Safe to expose (no private repository path).
|
|
*/
|
|
imageChannel: 'community' | 'hardened' | 'unknown' | null;
|
|
}
|
|
|
|
// Runtime capability overrides; services call disableCapability() during init.
|
|
const disabledCapabilities = new Set<Capability>();
|
|
|
|
export function disableCapability(c: Capability): void {
|
|
disabledCapabilities.add(c);
|
|
}
|
|
|
|
export function enableCapability(c: Capability): void {
|
|
disabledCapabilities.delete(c);
|
|
}
|
|
|
|
/** Returns capabilities this instance actually supports at runtime. */
|
|
export function getActiveCapabilities(): readonly string[] {
|
|
if (disabledCapabilities.size === 0) return CAPABILITIES;
|
|
return CAPABILITIES.filter(c => !disabledCapabilities.has(c));
|
|
}
|
|
|
|
/**
|
|
* Capabilities a pilot-agent process should hide from its own /api/meta because
|
|
* the central->pilot path for them is not yet wired through the reverse tunnel.
|
|
* Surfacing them would let the frontend offer a tab whose click silently falls
|
|
* through to central's local handler.
|
|
*
|
|
* `self-update` is intentionally NOT here: a pilot deployed via Docker Compose
|
|
* picks up the compose labels SelfUpdateService.initialize() needs and toggles
|
|
* the capability on locally; the Fleet Update flow then routes through
|
|
* NodeRegistry.getProxyTarget() so the tunnel carries the trigger.
|
|
*/
|
|
const PILOT_DISABLED_CAPABILITIES: readonly Capability[] = [
|
|
'host-console',
|
|
'host-console-community',
|
|
];
|
|
|
|
/** Disable capabilities that require a central->pilot path that is not yet wired. */
|
|
export function applyPilotModeCapabilityFilter(): void {
|
|
for (const cap of PILOT_DISABLED_CAPABILITIES) disableCapability(cap);
|
|
}
|
|
|
|
/** Shared offline shape returned when a remote node is unreachable. */
|
|
export const OFFLINE_META: RemoteMeta = {
|
|
version: null,
|
|
capabilities: [],
|
|
startedAt: null,
|
|
updateError: null,
|
|
online: false,
|
|
imagePinKind: null,
|
|
updateBlocked: false,
|
|
imageChannel: null,
|
|
};
|
|
|
|
function parseImageChannel(value: unknown): RemoteMeta['imageChannel'] {
|
|
if (value === 'community' || value === 'hardened' || value === 'unknown') return value;
|
|
return null;
|
|
}
|
|
|
|
/** Strip any `user:pass@` userinfo from a URL so credentials never reach the logs. */
|
|
function redactUrlCredentials(url: string): string {
|
|
return url.replace(/(\/\/)[^/@]*@/, '$1');
|
|
}
|
|
|
|
/** Fetch /api/meta from a remote Sencho instance. Returns empty data on failure. */
|
|
export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promise<RemoteMeta> {
|
|
const safeUrl = redactUrlCredentials(baseUrl);
|
|
try {
|
|
const res = await axios.get(`${baseUrl.replace(/\/$/, '')}/api/meta`, {
|
|
headers: apiToken ? { Authorization: `Bearer ${apiToken}` } : {},
|
|
timeout: 5000,
|
|
});
|
|
const rawVersion: string | undefined = res.data.version;
|
|
const meta: RemoteMeta = {
|
|
version: isValidVersion(rawVersion) ? rawVersion : null,
|
|
capabilities: Array.isArray(res.data.capabilities) ? res.data.capabilities : [],
|
|
startedAt: typeof res.data.startedAt === 'number' ? res.data.startedAt : null,
|
|
updateError: typeof res.data.updateError === 'string' ? res.data.updateError : null,
|
|
online: true,
|
|
imagePinKind: parseImagePinKind(res.data.imagePinKind),
|
|
updateBlocked: res.data.updateBlocked === true,
|
|
imageChannel: parseImageChannel(res.data.imageChannel),
|
|
};
|
|
if (isDebugEnabled()) {
|
|
// Diagnostic aid for "why is this feature gated?": log the resolved version
|
|
// and capability count (not the full list) at the one boundary that decides
|
|
// gating. The URL is logged with any userinfo credentials stripped.
|
|
console.log(
|
|
`[CapabilityRegistry:diag] meta ok from ${safeUrl}: version=${meta.version ?? 'null'} capabilities=${meta.capabilities.length}`,
|
|
);
|
|
}
|
|
return meta;
|
|
} catch (err) {
|
|
console.warn(`[CapabilityRegistry] Failed to fetch meta from ${safeUrl}:`, (err as Error).message);
|
|
return { ...OFFLINE_META };
|
|
}
|
|
}
|