mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
feat(mesh): replace host-mode with shared sencho_mesh Docker network (#1009)
* feat(mesh): replace host-mode with shared sencho_mesh Docker network Phase D of the mesh redesign: drop the operator's `network_mode: host` requirement and the `host-gateway` extra_hosts pattern that did not work on cloud iptables-restrictive distros (OCI, etc.) or Docker Desktop. Each Sencho creates a shared `sencho_mesh` Docker bridge network on boot (default subnet 172.30.0.0/24, override via SENCHO_MESH_SUBNET), pins itself at `<network>+2`, and attaches every meshed user service to the same bridge. Compose overrides now emit IP-based `extra_hosts` plus a top-level `networks` block declaring `sencho_mesh` external. Override delivery: central renders for local stacks; for remote stacks it sends the fleet alias list to the remote's new `PUT /api/mesh/local- override/:stackName` endpoint, which renders against the remote's OWN local senchoIp and writes under its OWN DATA_DIR. Each node may use a different subnet without coordination beyond the env var. Opt-in / opt-out now trigger an automatic redeploy of the affected stack via the existing deploy code path (local: ComposeService; remote: HTTP POST through proxyFetch). The frontend opt-in sheet shows a confirmation modal (ConfirmModal) before the mutation. Failed redeploys emit both a mesh activity event and a durable audit-log row. Hardening: - Reserve port 1852 at opt-in (prevents user containers from racing the Sencho API listener). - ensureMeshNetwork refuses to continue if `sencho_mesh` exists with a mismatched subnet rather than silently routing to the wrong IP. - Idempotent network connect/disconnect helpers in DockerController. - optInStack rolls back the DB row if the just-inserted stack's override push fails (no half-states surviving across calls). - regenerateOverridesForNode runs in parallel and skips the just- pushed stack on opt-in. Operator template: drop `network_mode: host`, restore `ports: ["1852:1852"]`. Mesh now works identically on Linux LAN, OCI, and Docker Desktop without firewall changes. Docs: rewrite docs/features/sencho-mesh.mdx around the shared bridge network, document SENCHO_MESH_SUBNET, surface the host-network-service opt-in restriction, and cross-link with the Pilot Agent docs. BREAKING CHANGE: the operator's `docker-compose.yml` no longer uses `network_mode: host`. After upgrading, redeploy any meshed stacks once so they pick up the new IP-based override and join `sencho_mesh`. * fix(mesh): wrap stackName with path.basename in local-override fs ops CodeQL flagged js/path-injection on the new applyLocalOverride and removeLocalOverride methods because they are publicly reachable and its data-flow model does not recognize isValidStackName / isPathWithinBase as sanitizers. The validation IS sufficient (the allowlist regex blocks path separators, the path-prefix check blocks escape), but path.basename is a model CodeQL recognizes and is purely defensive: for any input that already passes isValidStackName, basename is the identity.
This commit is contained in:
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { SystemSheet } from '@/components/ui/system-sheet';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import type { MeshStackEntry } from '@/types/mesh';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
@@ -19,6 +20,7 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingStack, setPendingStack] = useState<string | null>(null);
|
||||
const [confirmStack, setConfirmStack] = useState<MeshStackEntry | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -40,7 +42,7 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
|
||||
return () => { cancelled = true; };
|
||||
}, [open, nodeId]);
|
||||
|
||||
const toggle = async (stack: MeshStackEntry) => {
|
||||
const performToggle = async (stack: MeshStackEntry) => {
|
||||
setPendingStack(stack.name);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -54,10 +56,17 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
|
||||
setError(body.error || 'Port already claimed by another mesh stack');
|
||||
return;
|
||||
}
|
||||
if (res.status === 503) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
setError(body.error || 'Mesh data plane unavailable on this node');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
setStacks((prev) => prev.map((s) => s.name === stack.name ? { ...s, optedIn: !stack.optedIn } : s));
|
||||
onChanged();
|
||||
toast.success(stack.optedIn ? 'Stack removed from mesh' : 'Stack added to mesh');
|
||||
toast.success(stack.optedIn
|
||||
? `${stack.name} removed from mesh, redeploying`
|
||||
: `${stack.name} added to mesh, redeploying`);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
toast.error('Mesh update failed');
|
||||
@@ -70,54 +79,85 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
|
||||
const meta = `${inMeshCount} of ${stacks.length} in mesh`;
|
||||
|
||||
return (
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
crumb={['Fleet', 'Mesh', nodeName]}
|
||||
name={nodeName}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-stat-subtitle leading-snug">
|
||||
Adding a stack lets its services be reached from other meshed stacks by hostname.
|
||||
Toggling a stack redeploys it to refresh hostnames.
|
||||
</p>
|
||||
<>
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
crumb={['Fleet', 'Mesh', nodeName]}
|
||||
name={nodeName}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-stat-subtitle leading-snug">
|
||||
Adding a stack lets its services be reached from other meshed stacks by hostname.
|
||||
Toggling a stack triggers a redeploy on its node so the routing override applies.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-stat-subtitle text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading stacks…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded border border-destructive/30 bg-destructive/10 p-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && stacks.length === 0 && (
|
||||
<div className="text-sm text-stat-subtitle">No stacks deployed on this node yet.</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{stacks.map((stack) => (
|
||||
<div key={stack.name} className="flex items-center justify-between rounded border border-card-border bg-card px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`mesh-stack-${stack.name}`}
|
||||
checked={stack.optedIn}
|
||||
disabled={pendingStack === stack.name}
|
||||
onCheckedChange={() => { void toggle(stack); }}
|
||||
/>
|
||||
<label htmlFor={`mesh-stack-${stack.name}`} className="text-sm font-mono">{stack.name}</label>
|
||||
</div>
|
||||
{pendingStack === stack.name && <Loader2 className="w-3 h-3 animate-spin text-stat-subtitle" />}
|
||||
{stack.optedIn && pendingStack !== stack.name && (
|
||||
<span className="text-[10px] leading-3 tracking-[0.18em] uppercase text-success/80 font-mono">in mesh</span>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-stat-subtitle text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading stacks…
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded border border-destructive/30 bg-destructive/10 p-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && stacks.length === 0 && (
|
||||
<div className="text-sm text-stat-subtitle">No stacks deployed on this node yet.</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{stacks.map((stack) => (
|
||||
<div key={stack.name} className="flex items-center justify-between rounded border border-card-border bg-card px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-mono">{stack.name}</span>
|
||||
{stack.optedIn && pendingStack !== stack.name && (
|
||||
<span className="text-[10px] leading-3 tracking-[0.18em] uppercase text-success/80 font-mono">in mesh</span>
|
||||
)}
|
||||
</div>
|
||||
{pendingStack === stack.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-stat-subtitle" />
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={stack.optedIn ? 'outline' : 'default'}
|
||||
onClick={() => setConfirmStack(stack)}
|
||||
>
|
||||
{stack.optedIn ? 'Remove from mesh' : 'Add to mesh'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SystemSheet>
|
||||
</SystemSheet>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmStack}
|
||||
onOpenChange={(o) => { if (!o) setConfirmStack(null); }}
|
||||
variant={confirmStack?.optedIn ? 'destructive' : 'default'}
|
||||
kicker={`Mesh / ${nodeName}`}
|
||||
title={
|
||||
confirmStack?.optedIn
|
||||
? `Remove ${confirmStack.name} from mesh?`
|
||||
: `Add ${confirmStack?.name ?? ''} to mesh?`
|
||||
}
|
||||
description={
|
||||
confirmStack?.optedIn
|
||||
? `${confirmStack.name} will be redeployed on ${nodeName} so its containers drop the mesh routing entries from /etc/hosts.`
|
||||
: confirmStack
|
||||
? `${confirmStack.name} will be redeployed on ${nodeName} so its containers pick up the mesh routing entries.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel={confirmStack?.optedIn ? 'Remove and redeploy' : 'Add and redeploy'}
|
||||
onConfirm={() => {
|
||||
if (confirmStack) void performToggle(confirmStack);
|
||||
setConfirmStack(null);
|
||||
}}
|
||||
onCancel={() => setConfirmStack(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user