feat(nodes): Add Node via SSH — join any host to Swarm cluster
- client/src/pages/Nodes.tsx:
• Added showAddNode state (useState(false))
• Added 'Add Node' button (green, UserPlus icon) in page header toolbar
• Added 'Add Node via SSH' button in Nodes tab toolbar (always visible)
• Added 'Add Node via SSH' button in empty-state of Nodes tab
• Rendered <AddNodeDialog> overlay when showAddNode=true;
onSuccess refreshes nodesQ + swarmInfoQ then closes dialog
• AddNodeDialog component was already implemented: SSH host/port/user/
password/role inputs, live result display (green/red), command echo
- server/gateway-proxy.ts:
• joinSwarmNodeViaSSH() — POST /api/swarm/join-node with SSH credentials
- server/routers.ts:
• nodes.joinNode mutation — validates input, calls joinSwarmNodeViaSSH,
throws if gateway unavailable
- gateway/internal/api/handlers.go:
• POST /api/swarm/join-node handler (SwarmJoinNodeViaSSH):
retrieves join token, SSHes to remote host via golang.org/x/crypto/ssh,
runs 'docker swarm join --token <token> <managerAddr>',
handles already-member case as success
- gateway/cmd/gateway/main.go:
• Route registered: r.Post("/swarm/join-node", h.SwarmJoinNodeViaSSH)
- gateway/go.mod + go.sum:
• Added golang.org/x/crypto v0.37.0 dependency for SSH client support
This commit is contained in:
@@ -26,9 +26,196 @@ import {
|
||||
Plus, Minus, Activity, Loader2,
|
||||
Shield, Bot, ArrowUpRight, Eye, Tag, Power, Rocket,
|
||||
GitBranch, Globe, AlertTriangle, Trash2, Play, Square,
|
||||
Wifi, WifiOff, Clock, Zap,
|
||||
Wifi, WifiOff, Clock, Zap, UserPlus, KeyRound, CheckCircle2, XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Add Node via SSH Dialog ─────────────────────────────────────────────────
|
||||
|
||||
function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||
const [host, setHost] = useState("");
|
||||
const [port, setPort] = useState(22);
|
||||
const [user, setUser] = useState("root");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<"worker" | "manager">("worker");
|
||||
const [result, setResult] = useState<{ ok: boolean; output?: string; error?: string; note?: string; command?: string } | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const joinMut = trpc.nodes.joinNode.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
if (data.ok) onSuccess();
|
||||
},
|
||||
onError: (e) => setResult({ ok: false, error: e.message }),
|
||||
});
|
||||
|
||||
const handleJoin = () => {
|
||||
setResult(null);
|
||||
joinMut.mutate({ host: host.trim(), port, user: user.trim(), password, role });
|
||||
};
|
||||
|
||||
const canSubmit = host.trim() && user.trim() && password && !joinMut.isPending;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-card border border-border rounded-lg p-6 w-full max-w-md shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<UserPlus className="w-4 h-4 text-cyan-400" />
|
||||
<h3 className="text-sm font-bold">Add Node to Swarm via SSH</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Host + Port */}
|
||||
<div className="grid grid-cols-[1fr_80px] gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">IP Address / Hostname *</label>
|
||||
<Input
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="192.168.1.100"
|
||||
className="h-8 text-xs font-mono"
|
||||
disabled={joinMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">SSH Port</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(parseInt(e.target.value) || 22)}
|
||||
className="h-8 text-xs font-mono"
|
||||
min={1} max={65535}
|
||||
disabled={joinMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User */}
|
||||
<div>
|
||||
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">SSH Username *</label>
|
||||
<Input
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
placeholder="root"
|
||||
className="h-8 text-xs font-mono"
|
||||
disabled={joinMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="text-[10px] font-mono text-muted-foreground mb-1 block flex items-center gap-1">
|
||||
<KeyRound className="w-2.5 h-2.5" /> SSH Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && canSubmit && handleJoin()}
|
||||
placeholder="••••••••"
|
||||
className="h-8 text-xs font-mono pr-16"
|
||||
disabled={joinMut.isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] font-mono text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? "hide" : "show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">Node Role</label>
|
||||
<div className="flex gap-2">
|
||||
{(["worker", "manager"] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRole(r)}
|
||||
disabled={joinMut.isPending}
|
||||
className={`flex-1 h-8 text-xs font-mono rounded border transition-colors capitalize ${
|
||||
role === r
|
||||
? r === "manager"
|
||||
? "bg-purple-500/15 text-purple-400 border-purple-500/40"
|
||||
: "bg-cyan-500/15 text-cyan-400 border-cyan-500/40"
|
||||
: "border-border/40 text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{r === "manager" ? <Shield className="w-3 h-3 inline mr-1" /> : <Server className="w-3 h-3 inline mr-1" />}
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground font-mono mt-1">
|
||||
{role === "manager"
|
||||
? "Manager nodes participate in Raft consensus and can schedule tasks"
|
||||
: "Worker nodes run tasks but don't participate in cluster management"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`rounded-lg border p-3 text-xs font-mono ${
|
||||
result.ok
|
||||
? "bg-green-500/10 border-green-500/30 text-green-300"
|
||||
: "bg-red-500/10 border-red-500/30 text-red-300"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-1 font-semibold">
|
||||
{result.ok
|
||||
? <><CheckCircle2 className="w-3.5 h-3.5" /> Node joined successfully!</>
|
||||
: <><XCircle className="w-3.5 h-3.5" /> Failed to join node</>
|
||||
}
|
||||
</div>
|
||||
{result.note && <p className="text-[10px] opacity-80">{result.note}</p>}
|
||||
{result.output && (
|
||||
<pre className="mt-1 text-[9px] whitespace-pre-wrap break-all opacity-80 max-h-24 overflow-y-auto">
|
||||
{result.output}
|
||||
</pre>
|
||||
)}
|
||||
{result.error && <p className="text-[10px] mt-1 opacity-80">{result.error}</p>}
|
||||
{result.command && (
|
||||
<div className="mt-2 pt-2 border-t border-current/20">
|
||||
<p className="text-[9px] opacity-60 mb-0.5">Command executed:</p>
|
||||
<code className="text-[9px] opacity-70 break-all">{result.command}</code>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-5">
|
||||
<Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs">
|
||||
{result?.ok ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
{!result?.ok && (
|
||||
<Button
|
||||
onClick={handleJoin}
|
||||
disabled={!canSubmit}
|
||||
className="flex-1 h-8 text-xs bg-cyan-500/15 text-cyan-400 border-cyan-500/30 hover:bg-cyan-500/25"
|
||||
>
|
||||
{joinMut.isPending ? (
|
||||
<><Loader2 className="w-3 h-3 animate-spin mr-1" />Connecting…</>
|
||||
) : (
|
||||
<><UserPlus className="w-3 h-3 mr-1" />Join Swarm</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatMB(mb: number) {
|
||||
@@ -858,6 +1045,7 @@ export default function Nodes() {
|
||||
const [removeTarget, setRemoveTarget] = useState<{ id: string; name: string } | null>(null);
|
||||
const [tasksTarget, setTasksTarget] = useState<{ id: string; name: string } | null>(null);
|
||||
const [showDeploy, setShowDeploy] = useState(false);
|
||||
const [showAddNode, setShowAddNode] = useState(false);
|
||||
|
||||
const swarmInfoQ = trpc.nodes.swarmInfo.useQuery(undefined, { refetchInterval: 15000 });
|
||||
const nodesQ = trpc.nodes.list.useQuery(undefined, { refetchInterval: 10000 });
|
||||
@@ -934,6 +1122,13 @@ export default function Nodes() {
|
||||
>
|
||||
<Rocket className="w-3.5 h-3.5" /> Deploy Agent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setShowAddNode(true)}
|
||||
className="h-8 gap-1.5 text-xs text-green-400 border-green-500/40 hover:bg-green-500/10"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" /> Add Node
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => { nodesQ.refetch(); servicesQ.refetch(); swarmInfoQ.refetch(); agentsQ.refetch(); }}
|
||||
@@ -944,6 +1139,14 @@ export default function Nodes() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Add Node via SSH Dialog ────────────────────────────────────── */}
|
||||
{showAddNode && (
|
||||
<AddNodeDialog
|
||||
onClose={() => setShowAddNode(false)}
|
||||
onSuccess={() => { setShowAddNode(false); nodesQ.refetch(); swarmInfoQ.refetch(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Swarm Status Banner ───────────────────────────────────────────── */}
|
||||
<SwarmStatusBanner
|
||||
isLoading={swarmInfoQ.isLoading}
|
||||
@@ -1007,6 +1210,16 @@ export default function Nodes() {
|
||||
{/* NODES tab */}
|
||||
{activeTab === "nodes" && (
|
||||
<motion.div key="nodes" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
|
||||
{/* Add node toolbar */}
|
||||
<div className="flex justify-end mb-3">
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setShowAddNode(true)}
|
||||
className="h-7 gap-1.5 text-xs text-green-400 border-green-500/40 hover:bg-green-500/10"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" /> Add Node via SSH
|
||||
</Button>
|
||||
</div>
|
||||
{nodesQ.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm p-8 justify-center">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading nodes…
|
||||
@@ -1015,9 +1228,16 @@ export default function Nodes() {
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Server className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No nodes found</p>
|
||||
<p className="text-xs font-mono text-muted-foreground/60 mt-1">
|
||||
<p className="text-xs font-mono text-muted-foreground/60 mt-1 mb-4">
|
||||
{swarmInfoQ.isError ? "Cannot connect to Swarm — check gateway logs" : "Swarm has no registered nodes yet"}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setShowAddNode(true)}
|
||||
className="gap-1.5 text-xs text-green-400 border-green-500/40 hover:bg-green-500/10"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" /> Add Node via SSH
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -139,6 +139,7 @@ func main() {
|
||||
r.Get("/swarm/services/{id}/tasks", h.SwarmServiceTasks)
|
||||
r.Post("/swarm/services/{id}/scale", h.SwarmScaleService)
|
||||
r.Get("/swarm/join-token", h.SwarmJoinToken)
|
||||
r.Post("/swarm/join-node", h.SwarmJoinNodeViaSSH)
|
||||
r.Post("/swarm/shell", h.SwarmShell)
|
||||
r.Get("/swarm/agents", h.SwarmListAgents)
|
||||
r.Post("/swarm/agents/{name}/start", h.SwarmStartAgent)
|
||||
|
||||
@@ -3,10 +3,15 @@ module git.softuniq.eu/UniqAI/GoClaw/gateway
|
||||
go 1.23.4
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.1 // indirect
|
||||
github.com/go-chi/cors v1.2.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/jmoiron/sqlx v1.4.0 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
)
|
||||
|
||||
@@ -6,9 +6,11 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nuvVzoTd+M1hukKvKQLaDc=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/llm"
|
||||
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/orchestrator"
|
||||
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/tools"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Handler holds all dependencies for HTTP handlers.
|
||||
@@ -1404,3 +1405,116 @@ func min(a, b int) int {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// POST /api/swarm/join-node
|
||||
// Connects to a remote host via SSH and runs "docker swarm join ..." to add it to the cluster.
|
||||
// Body: { "host": "1.2.3.4", "port": 22, "user": "root", "password": "secret", "role": "worker" }
|
||||
func (h *Handler) SwarmJoinNodeViaSSH(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"` // "worker" | "manager"
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Host == "" || body.User == "" || body.Password == "" {
|
||||
respondError(w, http.StatusBadRequest, "host, user and password are required")
|
||||
return
|
||||
}
|
||||
if body.Port == 0 {
|
||||
body.Port = 22
|
||||
}
|
||||
if body.Role == "" {
|
||||
body.Role = "worker"
|
||||
}
|
||||
|
||||
// 1. Get join token from local swarm
|
||||
tokens, err := h.docker.GetJoinTokens()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "cannot get join tokens: "+err.Error())
|
||||
return
|
||||
}
|
||||
managerAddr := h.docker.GetManagerAddr()
|
||||
var token string
|
||||
if body.Role == "manager" {
|
||||
token = tokens.JoinTokens.Manager
|
||||
} else {
|
||||
token = tokens.JoinTokens.Worker
|
||||
}
|
||||
joinCmd := fmt.Sprintf("docker swarm join --token %s %s", token, managerAddr)
|
||||
|
||||
// 2. Dial SSH to the remote host
|
||||
sshCfg := &ssh.ClientConfig{
|
||||
User: body.User,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(body.Password),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // acceptable for internal cluster management
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", body.Host, body.Port)
|
||||
log.Printf("[SwarmJoinNode] Dialing SSH %s as %s", addr, body.User)
|
||||
|
||||
client, err := ssh.Dial("tcp", addr, sshCfg)
|
||||
if err != nil {
|
||||
respond(w, http.StatusOK, map[string]any{
|
||||
"ok": false,
|
||||
"step": "ssh_connect",
|
||||
"error": fmt.Sprintf("SSH connection failed: %s", err.Error()),
|
||||
"host": body.Host,
|
||||
"command": joinCmd,
|
||||
})
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 3. Run docker swarm join on the remote node
|
||||
sess, err := client.NewSession()
|
||||
if err != nil {
|
||||
respond(w, http.StatusOK, map[string]any{
|
||||
"ok": false,
|
||||
"step": "ssh_session",
|
||||
"error": fmt.Sprintf("SSH session failed: %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
log.Printf("[SwarmJoinNode] Running on %s: %s", body.Host, joinCmd)
|
||||
out, err := sess.CombinedOutput(joinCmd)
|
||||
output := strings.TrimSpace(string(out))
|
||||
|
||||
if err != nil {
|
||||
// Node might already be in the swarm — treat "already" as success
|
||||
if strings.Contains(output, "already") || strings.Contains(output, "This node is already") {
|
||||
respond(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"output": output,
|
||||
"note": "node is already part of this swarm",
|
||||
"command": joinCmd,
|
||||
})
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, map[string]any{
|
||||
"ok": false,
|
||||
"step": "docker_join",
|
||||
"error": fmt.Sprintf("docker swarm join failed: %s", err.Error()),
|
||||
"output": output,
|
||||
"command": joinCmd,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[SwarmJoinNode] Success: %s joined as %s", body.Host, body.Role)
|
||||
respond(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"output": output,
|
||||
"host": body.Host,
|
||||
"role": body.Role,
|
||||
"command": joinCmd,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -682,6 +682,40 @@ export async function execSwarmShell(command: string): Promise<{ output: string;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export interface JoinNodeResult {
|
||||
ok: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
step?: string;
|
||||
note?: string;
|
||||
host?: string;
|
||||
role?: string;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSH into a remote host and run "docker swarm join ..." to add it to the cluster.
|
||||
* The gateway fetches the current join token automatically.
|
||||
*/
|
||||
export async function joinSwarmNodeViaSSH(opts: {
|
||||
host: string;
|
||||
port?: number;
|
||||
user: string;
|
||||
password: string;
|
||||
role?: "worker" | "manager";
|
||||
}): Promise<JoinNodeResult | null> {
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/join-node`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(opts),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Add a label to a swarm node */
|
||||
export async function addSwarmNodeLabel(nodeId: string, key: string, value: string): Promise<boolean> {
|
||||
try {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
startSwarmAgent,
|
||||
stopSwarmAgent,
|
||||
getOllamaModelInfo,
|
||||
joinSwarmNodeViaSSH,
|
||||
} from "./gateway-proxy";
|
||||
|
||||
// Shared system user id for non-authenticated agent management
|
||||
@@ -1048,6 +1049,24 @@ export const appRouter = router({
|
||||
return { ok };
|
||||
}),
|
||||
|
||||
/**
|
||||
* SSH into a remote host and run "docker swarm join ..." to add it to the cluster.
|
||||
* The gateway fetches the join token automatically.
|
||||
*/
|
||||
joinNode: publicProcedure
|
||||
.input(z.object({
|
||||
host: z.string().min(1),
|
||||
port: z.number().int().min(1).max(65535).default(22),
|
||||
user: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
role: z.enum(["worker", "manager"]).default("worker"),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const result = await joinSwarmNodeViaSSH(input);
|
||||
if (!result) throw new Error("Gateway unavailable — cannot reach SSH endpoint");
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get live container stats (CPU%, RAM) for all running containers.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user