mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
This commit is contained in:
1 parent
d2ff5fc78f
commit
3b2c849514
59 files changed
+31807
-2977
No files matched your search
@@ -161,7 +161,7 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
expires off;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-6OrZHOmlg8SaaMdXOWFOMsBrosTVDr0JC5JXhs2mSls='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-xKFwPOSyQDCFj701DTxJU9nyOtY2cN1n+z8W2Q5rnUw='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||||
add_header X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|||||||
+1
-1
@@ -142,7 +142,7 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
expires off;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-6OrZHOmlg8SaaMdXOWFOMsBrosTVDr0JC5JXhs2mSls='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-xKFwPOSyQDCFj701DTxJU9nyOtY2cN1n+z8W2Q5rnUw='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||||
add_header X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- dropped, see plugin adoption (fleets plugin, migrations/mysql/0001_adopt_fleets.sql)
|
||||||
|
SELECT 1;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -239,6 +239,13 @@
|
|||||||
"when": 1790169398523,
|
"when": 1790169398523,
|
||||||
"tag": "0033_clear_anthem",
|
"tag": "0033_clear_anthem",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 34,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1790176259240,
|
||||||
|
"tag": "0034_green_vector",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- dropped, see plugin adoption (fleets plugin, migrations/postgres/0001_adopt_fleets.sql)
|
||||||
|
SELECT 1;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -246,6 +246,13 @@
|
|||||||
"when": 1790169398038,
|
"when": 1790169398038,
|
||||||
"tag": "0034_free_smiling_tiger",
|
"tag": "0034_free_smiling_tiger",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 35,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790176258755,
|
||||||
|
"tag": "0035_ambitious_klaw",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- dropped, see plugin adoption (fleets plugin, migrations/sqlite/0001_adopt_fleets.sql)
|
||||||
|
SELECT 1;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -218,6 +218,13 @@
|
|||||||
"when": 1790169397528,
|
"when": 1790169397528,
|
||||||
"tag": "0030_quick_felicia_hardy",
|
"tag": "0030_quick_felicia_hardy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 31,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790176258239,
|
||||||
|
"tag": "0031_smooth_scarlet_witch",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -26,18 +26,19 @@ This is not a sandbox. See [What this protects](#what-this-protects-and-what-it-
|
|||||||
A plugin imports `@termix/plugin-sdk`, its own files and its own npm
|
A plugin imports `@termix/plugin-sdk`, its own files and its own npm
|
||||||
dependencies. Entry points:
|
dependencies. Entry points:
|
||||||
|
|
||||||
| Entry | Contents |
|
| Entry | Contents |
|
||||||
| ---------------------------------- | ---------------------------------------------------------- |
|
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
|
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
|
||||||
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
|
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
|
||||||
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
|
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
|
||||||
| `@termix/plugin-sdk/table-builder` | `buildTable()`, a definition as a queryable Drizzle table |
|
| `@termix/plugin-sdk/table-builder` | `buildTable()`, a definition as a queryable Drizzle table |
|
||||||
| `@termix/plugin-sdk/frontend` | The `app` object types, the hooks, `invokeAction()` |
|
| `@termix/plugin-sdk/frontend` | The `app` object types, the hooks, `invokeAction()` |
|
||||||
| `@termix/plugin-sdk/ui` | Core's shared components, a fixed public list (see UI) |
|
| `@termix/plugin-sdk/ui` | Core's shared components, a fixed public list (see UI) |
|
||||||
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
|
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
|
||||||
| `@termix/plugin-sdk/capabilities` | The capability catalog |
|
| `@termix/plugin-sdk/capabilities` | The capability catalog |
|
||||||
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
|
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
|
||||||
| `@termix/plugin-sdk/testing` | Test helpers: `createMockCtx()`, `createTestDb()` and more |
|
| `@termix/plugin-sdk/host-commands` | Platform detection, package manager commands, sudo elevation - pure helpers over an ssh2 `Client` a plugin got from `ctx.ssh` |
|
||||||
|
| `@termix/plugin-sdk/testing` | Test helpers: `createMockCtx()`, `createTestDb()` and more |
|
||||||
|
|
||||||
Plugins never import from `src/` or `@/`, and core never imports from
|
Plugins never import from `src/` or `@/`, and core never imports from
|
||||||
`plugins/`. Anything a plugin needs from core becomes a typed, documented SDK
|
`plugins/`. Anything a plugin needs from core becomes a typed, documented SDK
|
||||||
@@ -1045,7 +1046,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
|||||||
| `ctx.sync.registerEntity` | none | **A3** |
|
| `ctx.sync.registerEntity` | none | **A3** |
|
||||||
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
|
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
|
||||||
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
|
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
|
||||||
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B |
|
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4** |
|
||||||
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
|
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
|
||||||
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
|
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
|
||||||
| `ctx.notify.*` | `notify:send` | A6 |
|
| `ctx.notify.*` | `notify:send` | A6 |
|
||||||
@@ -1073,6 +1074,32 @@ connect pipeline instead of importing ssh2 helpers from core:
|
|||||||
Each new connection is audited and runs as the current actor; pooled reuse is
|
Each new connection is audited and runs as the current actor; pooled reuse is
|
||||||
not. Connections and pool entries are closed on deactivate.
|
not. Connections and pool entries are closed on deactivate.
|
||||||
|
|
||||||
|
**B4** added `ctx.hosts`, for a plugin that groups or acts across hosts it
|
||||||
|
does not own the way fleets does:
|
||||||
|
|
||||||
|
- `list()` / `get(hostId)` return `PluginHostSummary` (id, userId, name, ip,
|
||||||
|
port, username, tags, folder, authType - no secrets) for the acting user's
|
||||||
|
own and shared hosts. Needs `hosts:read`.
|
||||||
|
- `checkAccess(hostId, level)` mirrors `PermissionManager.canAccessHost`,
|
||||||
|
naming the level ("connect" | "view" | "edit" | "manage") rather than a
|
||||||
|
bare yes/no, because a plugin fanning out across a fleet needs to know
|
||||||
|
whether it can only connect, or also manage. Needs `hosts:read`.
|
||||||
|
- `share(hostId, targets, permissionLevel, durationHours)` grants access the
|
||||||
|
same way the host editor's own share action does, snapshotting shared
|
||||||
|
secrets per target through core's `SharedHostSecretsManager`. Refuses a
|
||||||
|
host the caller does not hold "manage" on. Needs `hosts:write`, because
|
||||||
|
granting access to a host is a write on that host even when the plugin
|
||||||
|
owns neither the host nor the grant.
|
||||||
|
- `listUsers()` / `listRoles()` return minimal `{ id, username }` /
|
||||||
|
`{ id, name, displayName }` lists (roles filtered to non-system) for a
|
||||||
|
share-target picker UI, without exposing the full admin user/role
|
||||||
|
management surface. Needs `hosts:write`, matching `share`.
|
||||||
|
|
||||||
|
`ctx.hosts.get` returns the same non-secret projection as `list`; a plugin
|
||||||
|
that needs to actually connect uses `ctx.ssh.connect(hostId)` or
|
||||||
|
`withConnection(hostId, ...)`, which resolve the full host (secrets included)
|
||||||
|
through the one connect pipeline. fleets never holds a `PluginSshHost` itself.
|
||||||
|
|
||||||
### The actor
|
### The actor
|
||||||
|
|
||||||
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
|
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ it's done.
|
|||||||
- **B3 (tailscale):** `plugins/tailscale/src/backend/host-metrics-manager.ts`
|
- **B3 (tailscale):** `plugins/tailscale/src/backend/host-metrics-manager.ts`
|
||||||
and `routes.ts` still resolve hosts and access through core's
|
and `routes.ts` still resolve hosts and access through core's
|
||||||
`PermissionManager`/`resolveHostById`/`DataCrypto` by relative import
|
`PermissionManager`/`resolveHostById`/`DataCrypto` by relative import
|
||||||
instead of a `ctx.hosts`-style SDK API, because `ctx.hosts` is not built yet
|
instead of `ctx.hosts`, which **B4** built. Same debt every other
|
||||||
(status **B** in the ctx surface table). Same debt every other host-metrics
|
host-metrics manager already carries; not new, but tailscale now carries it
|
||||||
manager already carries; not new, but tailscale now carries it too. Owner:
|
too. Owner: host-metrics's own Phase B step (it and every manager plugin
|
||||||
whichever step builds `ctx.hosts`.
|
convert together).
|
||||||
|
- **B4 (fleets/host-metrics):** `src/backend/hosts/metrics-shared/` (platform
|
||||||
|
detection, package commands, exec-elevated) is still core's own copy, used
|
||||||
|
by host-metrics, which has not converted yet. `@termix/plugin-sdk/host-commands`
|
||||||
|
is the SDK copy fleets now uses; core's copy was left in place rather than
|
||||||
|
turned into a re-export, since host-metrics's own conversion step is where
|
||||||
|
its imports should move wholesale. Owner: host-metrics's Phase B step.
|
||||||
@@ -52,6 +52,10 @@
|
|||||||
"types": "./dist/vitest-preset.d.ts",
|
"types": "./dist/vitest-preset.d.ts",
|
||||||
"default": "./dist/vitest-preset.js"
|
"default": "./dist/vitest-preset.js"
|
||||||
},
|
},
|
||||||
|
"./host-commands": {
|
||||||
|
"types": "./dist/host-commands.d.ts",
|
||||||
|
"default": "./dist/host-commands.js"
|
||||||
|
},
|
||||||
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
|
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
|
||||||
"./schema/manifest.schema.json": "./schema/manifest.schema.json"
|
"./schema/manifest.schema.json": "./schema/manifest.schema.json"
|
||||||
},
|
},
|
||||||
@@ -71,11 +75,15 @@
|
|||||||
"tar": "^7.5.22"
|
"tar": "^7.5.22"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"drizzle-orm": "*"
|
"drizzle-orm": "*",
|
||||||
|
"ssh2": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"drizzle-orm": {
|
"drizzle-orm": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"ssh2": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,6 +395,90 @@ export interface PluginSettings {
|
|||||||
readCore: (key: string) => Promise<string | null>;
|
readCore: (key: string) => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A host's non-secret fields, for listing, tag matching and display. */
|
||||||
|
export interface PluginHostSummary {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
name: string | null;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
/** Comma-separated, as ssh_data stores them. Split on "," to match tags. */
|
||||||
|
tags: string | null;
|
||||||
|
folder: string | null;
|
||||||
|
authType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same shape canAccessHost returns internally, without secrets. */
|
||||||
|
export interface PluginHostAccess {
|
||||||
|
hasAccess: boolean;
|
||||||
|
isOwner: boolean;
|
||||||
|
isShared: boolean;
|
||||||
|
permissionLevel?: "connect" | "view" | "edit" | "manage";
|
||||||
|
expiresAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginHostShareLevel = "connect" | "view" | "edit" | "manage";
|
||||||
|
|
||||||
|
export interface PluginShareTarget {
|
||||||
|
type: "user" | "role";
|
||||||
|
id: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginHostShareResult {
|
||||||
|
hostId: number;
|
||||||
|
shared: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A user or role a plugin can offer as a share target. No secrets, no roles' permission lists. */
|
||||||
|
export interface PluginShareableUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginShareableRole {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
displayName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts the acting user can see, and the RBAC + sharing operations a plugin
|
||||||
|
* needs to build a feature (like fleets) on top of hosts it does not own.
|
||||||
|
*
|
||||||
|
* list/get/checkAccess need hosts:read. share, and the user/role pickers a
|
||||||
|
* share UI needs, need hosts:write: sharing changes who can reach a host,
|
||||||
|
* which is a write on that host's access, even though the plugin owns
|
||||||
|
* neither the host nor the grant.
|
||||||
|
*/
|
||||||
|
export interface PluginHosts {
|
||||||
|
/** Every host the acting user owns or was shared, without secrets. */
|
||||||
|
list: () => Promise<PluginHostSummary[]>;
|
||||||
|
/** One host the acting user can see, or null if it does not exist or they cannot. */
|
||||||
|
get: (hostId: number) => Promise<PluginHostSummary | null>;
|
||||||
|
/** The same access rule ctx.ssh.connect enforces, but naming the level, not just yes/no. */
|
||||||
|
checkAccess: (
|
||||||
|
hostId: number,
|
||||||
|
level: PluginHostShareLevel,
|
||||||
|
) => Promise<PluginHostAccess>;
|
||||||
|
/**
|
||||||
|
* Grants access to a host the caller manages, snapshotting shared secrets
|
||||||
|
* for each target the same way the host editor's own share action does.
|
||||||
|
* Refuses a host the caller does not hold "manage" on.
|
||||||
|
*/
|
||||||
|
share: (
|
||||||
|
hostId: number,
|
||||||
|
targets: PluginShareTarget[],
|
||||||
|
permissionLevel: PluginHostShareLevel,
|
||||||
|
durationHours?: number,
|
||||||
|
) => Promise<PluginHostShareResult>;
|
||||||
|
/** Users the caller may pick as a share target, for building a picker UI. */
|
||||||
|
listUsers: () => Promise<PluginShareableUser[]>;
|
||||||
|
/** Non-system roles the caller may pick as a share target. */
|
||||||
|
listRoles: () => Promise<PluginShareableRole[]>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A host as the SSH pipeline reads it: resolved by core, with secrets filled
|
* A host as the SSH pipeline reads it: resolved by core, with secrets filled
|
||||||
* in for the acting user. Plugins get one from core and hand it back; they
|
* in for the acting user. Plugins get one from core and hand it back; they
|
||||||
@@ -814,6 +898,8 @@ export interface PluginContext {
|
|||||||
readonly rbac: PluginRbac;
|
readonly rbac: PluginRbac;
|
||||||
readonly settings: PluginSettings;
|
readonly settings: PluginSettings;
|
||||||
readonly disposables: PluginDisposables;
|
readonly disposables: PluginDisposables;
|
||||||
|
/** Hosts the actor can see, and host-sharing operations. Needs hosts:read / hosts:write. */
|
||||||
|
readonly hosts: PluginHosts;
|
||||||
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
|
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
|
||||||
readonly ssh: PluginSsh;
|
readonly ssh: PluginSsh;
|
||||||
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
|
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
/**
|
||||||
|
* Generic host command helpers: platform detection, package manager actions
|
||||||
|
* and the shell-argument validators that guard them.
|
||||||
|
*
|
||||||
|
* Pure functions and thin ssh2 wrappers, no core imports. fleets and
|
||||||
|
* host-metrics both build their platform/package features on this instead of
|
||||||
|
* duplicating it or reaching into core.
|
||||||
|
*
|
||||||
|
* A plugin passes the `Client` it got from ctx.ssh.connect/withConnection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type { Client, ClientChannel } from "ssh2";
|
||||||
|
import type { Client, ClientChannel } from "ssh2";
|
||||||
|
|
||||||
|
export interface ExecResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
code: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs one non-interactive command over an existing ssh2 client. */
|
||||||
|
export function execCommand(
|
||||||
|
client: Client,
|
||||||
|
command: string,
|
||||||
|
timeoutMs = 30000,
|
||||||
|
): Promise<ExecResult> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
let stream: ClientChannel | null = null;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (stream) {
|
||||||
|
try {
|
||||||
|
stream.removeAllListeners();
|
||||||
|
if (stream.stderr) {
|
||||||
|
stream.stderr.removeAllListeners();
|
||||||
|
}
|
||||||
|
stream.destroy();
|
||||||
|
} catch {
|
||||||
|
// expected - cleanup errors ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
client.exec(command, { pty: false }, (err, _stream) => {
|
||||||
|
if (err) {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream = _stream;
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let exitCode: number | null = null;
|
||||||
|
|
||||||
|
stream
|
||||||
|
.on("close", (code: number | undefined) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
exitCode = typeof code === "number" ? code : null;
|
||||||
|
cleanup();
|
||||||
|
resolve({ stdout, stderr, code: exitCode });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("data", (data: Buffer) => {
|
||||||
|
stdout += data.toString("utf8");
|
||||||
|
})
|
||||||
|
.on("error", (streamErr: Error) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(streamErr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stream.stderr) {
|
||||||
|
stream.stderr
|
||||||
|
.on("data", (data: Buffer) => {
|
||||||
|
stderr += data.toString("utf8");
|
||||||
|
})
|
||||||
|
.on("error", (stderrErr: Error) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(stderrErr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
|
||||||
|
|
||||||
|
export interface PlatformInfo {
|
||||||
|
hasSystemd: boolean;
|
||||||
|
pkg: PackageManager;
|
||||||
|
hasCertbot: boolean;
|
||||||
|
hasAcmeSh: boolean;
|
||||||
|
hasDocker: boolean;
|
||||||
|
osPrettyName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
||||||
|
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
||||||
|
* Extend PATH with their common install locations before probing.
|
||||||
|
*/
|
||||||
|
export const EXTRA_PATH_DIRS =
|
||||||
|
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
|
||||||
|
|
||||||
|
const DOCKER_PROBE =
|
||||||
|
`command -v docker >/dev/null 2>&1` +
|
||||||
|
` || [ -S /var/run/docker.sock ]` +
|
||||||
|
` || [ -S "$HOME/.orbstack/run/docker.sock" ]`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single probe that reports which tooling is available. Each line is
|
||||||
|
* "key=value" so the parser is trivial and order-independent.
|
||||||
|
*/
|
||||||
|
export const PLATFORM_PROBE_COMMAND = [
|
||||||
|
`PATH="${EXTRA_PATH_DIRS}:$PATH"`,
|
||||||
|
"echo systemd=$(command -v systemctl >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||||
|
"echo apt=$(command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||||
|
"echo dnf=$(command -v dnf >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||||
|
"echo yum=$(command -v yum >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||||
|
"echo pacman=$(command -v pacman >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||||
|
"echo certbot=$(command -v certbot >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||||
|
'echo acmesh=$( { command -v acme.sh >/dev/null 2>&1 || [ -x "$HOME/.acme.sh/acme.sh" ]; } && echo 1 || echo 0)',
|
||||||
|
`echo docker=$(${DOCKER_PROBE} && echo 1 || echo 0)`,
|
||||||
|
'echo os=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")',
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
|
export function parsePlatformProbe(output: string): PlatformInfo {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
const idx = line.indexOf("=");
|
||||||
|
if (idx === -1) continue;
|
||||||
|
map.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
|
||||||
|
}
|
||||||
|
const on = (k: string) => map.get(k) === "1";
|
||||||
|
|
||||||
|
// Prefer dnf over yum when both exist (dnf is the modern front-end).
|
||||||
|
let pkg: PackageManager = null;
|
||||||
|
if (on("apt")) pkg = "apt";
|
||||||
|
else if (on("dnf")) pkg = "dnf";
|
||||||
|
else if (on("yum")) pkg = "yum";
|
||||||
|
else if (on("pacman")) pkg = "pacman";
|
||||||
|
|
||||||
|
const os = map.get("os");
|
||||||
|
return {
|
||||||
|
hasSystemd: on("systemd"),
|
||||||
|
pkg,
|
||||||
|
hasCertbot: on("certbot"),
|
||||||
|
hasAcmeSh: on("acmesh"),
|
||||||
|
hasDocker: on("docker"),
|
||||||
|
osPrettyName: os && os.length > 0 ? os : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectPlatform(client: Client): Promise<PlatformInfo> {
|
||||||
|
const { stdout } = await execCommand(client, PLATFORM_PROBE_COMMAND, 15000);
|
||||||
|
return parsePlatformProbe(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HostPlatform = "darwin" | "linux" | "windows" | "other";
|
||||||
|
|
||||||
|
// Windows OpenSSH's default shell is usually cmd.exe (occasionally
|
||||||
|
// PowerShell), so scripts are sent base64-encoded via -EncodedCommand -
|
||||||
|
// that avoids every cmd.exe quoting/escaping pitfall entirely, since the
|
||||||
|
// argument ends up being plain alphanumeric text with no shell metacharacters.
|
||||||
|
export function encodePowerShellCommand(script: string): string {
|
||||||
|
return Buffer.from(script, "utf16le").toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function execPowerShell(
|
||||||
|
client: Client,
|
||||||
|
script: string,
|
||||||
|
timeoutMs = 30000,
|
||||||
|
): Promise<ExecResult> {
|
||||||
|
const encoded = encodePowerShellCommand(script);
|
||||||
|
return execCommand(
|
||||||
|
client,
|
||||||
|
`powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectHostPlatform(
|
||||||
|
client: Client,
|
||||||
|
): Promise<HostPlatform> {
|
||||||
|
try {
|
||||||
|
const { stdout, code } = await execCommand(client, "uname -s", 10000);
|
||||||
|
const kernel = stdout.trim().toLowerCase();
|
||||||
|
if (kernel === "darwin") return "darwin";
|
||||||
|
if (kernel === "linux") return "linux";
|
||||||
|
if (code === 0 && kernel) return "other";
|
||||||
|
} catch {
|
||||||
|
// expected on hosts with no POSIX shell (e.g. Windows via cmd.exe)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execPowerShell(client, "'WIN_OK'", 10000);
|
||||||
|
if (stdout.includes("WIN_OK")) return "windows";
|
||||||
|
} catch {
|
||||||
|
// expected on hosts with no PowerShell available
|
||||||
|
}
|
||||||
|
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpgradablePackage {
|
||||||
|
name: string;
|
||||||
|
currentVersion?: string;
|
||||||
|
newVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildListUpgradableCommand(pkg: PackageManager): string | null {
|
||||||
|
switch (pkg) {
|
||||||
|
case "apt":
|
||||||
|
return "apt list --upgradable 2>/dev/null | tail -n +2";
|
||||||
|
case "dnf":
|
||||||
|
return "dnf -q check-update 2>/dev/null || true";
|
||||||
|
case "yum":
|
||||||
|
return "yum -q check-update 2>/dev/null || true";
|
||||||
|
case "pacman":
|
||||||
|
return "pacman -Qu 2>/dev/null || true";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUpgradable(
|
||||||
|
pkg: PackageManager,
|
||||||
|
output: string,
|
||||||
|
): UpgradablePackage[] {
|
||||||
|
const out: UpgradablePackage[] = [];
|
||||||
|
const lines = output
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (pkg === "apt") {
|
||||||
|
for (const line of lines) {
|
||||||
|
// name/suite newver arch [upgradable from: oldver]
|
||||||
|
const m = line.match(
|
||||||
|
/^([^/\s]+)\/\S+\s+(\S+)\s+\S+(?:\s+\[upgradable from:\s+(\S+)\])?/,
|
||||||
|
);
|
||||||
|
if (m) out.push({ name: m[1], newVersion: m[2], currentVersion: m[3] });
|
||||||
|
}
|
||||||
|
} else if (pkg === "dnf" || pkg === "yum") {
|
||||||
|
for (const line of lines) {
|
||||||
|
if (/^(Last metadata|Obsoleting|Security:)/i.test(line)) continue;
|
||||||
|
const m = line.match(/^(\S+)\s+(\S+)\s+\S+$/);
|
||||||
|
if (m && m[1].includes(".")) out.push({ name: m[1], newVersion: m[2] });
|
||||||
|
}
|
||||||
|
} else if (pkg === "pacman") {
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.match(/^(\S+)\s+(\S+)\s+->\s+(\S+)$/);
|
||||||
|
if (m) out.push({ name: m[1], currentVersion: m[2], newVersion: m[3] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PackageAction = "upgrade-all" | "install" | "upgrade";
|
||||||
|
|
||||||
|
export function buildPackageActionCommand(
|
||||||
|
pkg: PackageManager,
|
||||||
|
action: PackageAction,
|
||||||
|
name?: string,
|
||||||
|
): string | null {
|
||||||
|
const target = name ? ` ${name}` : "";
|
||||||
|
switch (pkg) {
|
||||||
|
case "apt":
|
||||||
|
if (action === "upgrade-all")
|
||||||
|
return "DEBIAN_FRONTEND=noninteractive apt-get -y upgrade";
|
||||||
|
return `DEBIAN_FRONTEND=noninteractive apt-get -y install${target}`;
|
||||||
|
case "dnf":
|
||||||
|
return action === "upgrade-all"
|
||||||
|
? "dnf -y upgrade"
|
||||||
|
: `dnf -y install${target}`;
|
||||||
|
case "yum":
|
||||||
|
return action === "upgrade-all"
|
||||||
|
? "yum -y update"
|
||||||
|
: `yum -y install${target}`;
|
||||||
|
case "pacman":
|
||||||
|
return action === "upgrade-all"
|
||||||
|
? "pacman -Syu --noconfirm"
|
||||||
|
: `pacman -S --noconfirm${target}`;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPackageRemoveCommand(
|
||||||
|
pkg: PackageManager,
|
||||||
|
name: string,
|
||||||
|
): string | null {
|
||||||
|
switch (pkg) {
|
||||||
|
case "apt":
|
||||||
|
return `DEBIAN_FRONTEND=noninteractive apt-get -y remove ${name}`;
|
||||||
|
case "dnf":
|
||||||
|
return `dnf -y remove ${name}`;
|
||||||
|
case "yum":
|
||||||
|
return `yum -y remove ${name}`;
|
||||||
|
case "pacman":
|
||||||
|
return `pacman -R --noconfirm ${name}`;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
|
||||||
|
|
||||||
|
export function isValidPackageName(pkg: unknown): pkg is string {
|
||||||
|
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ElevationErrorCode = "SUDO_REQUIRED" | "SUDO_FAILED" | "NOT_SUDOER";
|
||||||
|
|
||||||
|
export class ElevationError extends Error {
|
||||||
|
code: ElevationErrorCode;
|
||||||
|
constructor(code: ElevationErrorCode, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ElevationError";
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElevatedResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
code: number | null;
|
||||||
|
usedSudo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERMISSION_DENIED = [
|
||||||
|
"permission denied",
|
||||||
|
"operation not permitted",
|
||||||
|
"must be run as root",
|
||||||
|
"must be superuser",
|
||||||
|
"you need to be root",
|
||||||
|
"are you root",
|
||||||
|
"access denied",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phrases sudo itself prints to STDERR when authentication or authorization
|
||||||
|
* fails. Matched only against sudo's own stderr (never command output), so a
|
||||||
|
* command that happens to print "permission denied" on stdout is not mistaken
|
||||||
|
* for a sudo failure.
|
||||||
|
*/
|
||||||
|
const SUDO_AUTH_FAILED = [
|
||||||
|
"incorrect password",
|
||||||
|
"a password is required",
|
||||||
|
"a terminal is required",
|
||||||
|
"no tty present",
|
||||||
|
"sorry, try again",
|
||||||
|
"no password was provided",
|
||||||
|
"1 incorrect password attempt",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUDO_NOT_SUDOER = [
|
||||||
|
"is not in the sudoers file",
|
||||||
|
"not allowed to run sudo",
|
||||||
|
"not allowed to execute",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker printed only after sudo has successfully authenticated and started
|
||||||
|
* the inner shell. Its presence on stdout is the authoritative "elevation
|
||||||
|
* worked" signal; its absence (together with sudo stderr) means auth failed.
|
||||||
|
*/
|
||||||
|
const SUDO_OK_MARKER = "__TX_SUDO_OK__";
|
||||||
|
const SUDO_OK_LINE_RE = new RegExp(`^${SUDO_OK_MARKER}\\r?\\n?`);
|
||||||
|
|
||||||
|
/** Escape a value for single-quoted shell context. */
|
||||||
|
export function shellSingleQuote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the elevated command string:
|
||||||
|
* echo '<pw>' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; <command>'
|
||||||
|
*
|
||||||
|
* `-p ''` suppresses the prompt. stderr is NOT merged into stdout, so the
|
||||||
|
* command's own output stays clean and sudo's auth errors stay on stderr. The
|
||||||
|
* marker is echoed by the inner shell once sudo authenticates, letting us
|
||||||
|
* tell a genuine auth failure from command output that merely contains scary
|
||||||
|
* words.
|
||||||
|
*/
|
||||||
|
export function buildSudoCommand(
|
||||||
|
command: string,
|
||||||
|
sudoPassword: string,
|
||||||
|
): string {
|
||||||
|
const pw = shellSingleQuote(sudoPassword);
|
||||||
|
const inner = shellSingleQuote(`echo ${SUDO_OK_MARKER}; ${command}`);
|
||||||
|
return `echo ${pw} | sudo -S -p '' sh -c ${inner}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesAny(text: string, needles: string[]): boolean {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
return needles.some((n) => lower.includes(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikePermissionDenied(text: string): boolean {
|
||||||
|
return includesAny(text, PERMISSION_DENIED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip the success marker line from the front of stdout. */
|
||||||
|
function stripMarker(stdout: string): string {
|
||||||
|
return stdout.replace(SUDO_OK_LINE_RE, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a command on a client, elevating with the host's stored sudo password
|
||||||
|
* only when needed (or when forced). Throws a typed `ElevationError` when
|
||||||
|
* elevation is required but unavailable/incorrect.
|
||||||
|
*/
|
||||||
|
export async function execElevated(
|
||||||
|
client: Client,
|
||||||
|
command: string,
|
||||||
|
sudoPassword: string | undefined,
|
||||||
|
opts: { forceSudo?: boolean; timeoutMs?: number } = {},
|
||||||
|
): Promise<ElevatedResult> {
|
||||||
|
const timeoutMs = opts.timeoutMs ?? 30000;
|
||||||
|
|
||||||
|
if (!opts.forceSudo) {
|
||||||
|
const direct = await execCommand(client, command, timeoutMs);
|
||||||
|
if (direct.code === 0) {
|
||||||
|
return { ...direct, usedSudo: false };
|
||||||
|
}
|
||||||
|
// Only escalate when the failure looks like a privilege problem. A
|
||||||
|
// permission-denied phrase on stderr (not arbitrary stdout) is the signal.
|
||||||
|
if (!looksLikePermissionDenied(direct.stderr)) {
|
||||||
|
return { ...direct, usedSudo: false };
|
||||||
|
}
|
||||||
|
if (!sudoPassword) {
|
||||||
|
throw new ElevationError(
|
||||||
|
"SUDO_REQUIRED",
|
||||||
|
"This action requires elevated privileges. Set a sudo password for this host to continue.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (!sudoPassword) {
|
||||||
|
throw new ElevationError(
|
||||||
|
"SUDO_REQUIRED",
|
||||||
|
"This action requires elevated privileges. Set a sudo password for this host to continue.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sudoCmd = buildSudoCommand(command, sudoPassword as string);
|
||||||
|
const result = await execCommand(client, sudoCmd, timeoutMs);
|
||||||
|
const authenticated = result.stdout.includes(SUDO_OK_MARKER);
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
// Elevation never started: diagnose from sudo's stderr only.
|
||||||
|
const notSudoer = includesAny(result.stderr, SUDO_NOT_SUDOER);
|
||||||
|
const authFailed = includesAny(result.stderr, SUDO_AUTH_FAILED);
|
||||||
|
if (notSudoer) {
|
||||||
|
throw new ElevationError(
|
||||||
|
"NOT_SUDOER",
|
||||||
|
"The connected user is not permitted to use sudo on this host.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (authFailed) {
|
||||||
|
throw new ElevationError(
|
||||||
|
"SUDO_FAILED",
|
||||||
|
"Elevation failed. Check the host's sudo password.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// No marker and no recognizable sudo error: treat as a generic failure
|
||||||
|
// but keep the original output so the caller can surface it.
|
||||||
|
throw new ElevationError(
|
||||||
|
"SUDO_FAILED",
|
||||||
|
"Elevation failed. Check the host's sudo password.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: stripMarker(result.stdout),
|
||||||
|
stderr: result.stderr,
|
||||||
|
code: result.code,
|
||||||
|
usedSudo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,11 @@ import type {
|
|||||||
PluginSecondFactor,
|
PluginSecondFactor,
|
||||||
PluginSshAuthProvider,
|
PluginSshAuthProvider,
|
||||||
PluginSshHost,
|
PluginSshHost,
|
||||||
|
PluginHostSummary,
|
||||||
|
PluginHostAccess,
|
||||||
|
PluginHostShareResult,
|
||||||
|
PluginShareableUser,
|
||||||
|
PluginShareableRole,
|
||||||
} from "./backend.js";
|
} from "./backend.js";
|
||||||
import type { SyncEntityRegistration } from "./backend.js";
|
import type { SyncEntityRegistration } from "./backend.js";
|
||||||
import type { PluginDatabase } from "./backend.js";
|
import type { PluginDatabase } from "./backend.js";
|
||||||
@@ -48,6 +53,11 @@ export interface FakeContextOptions {
|
|||||||
* when omitted every check passes, so tests that do not care stay simple.
|
* when omitted every check passes, so tests that do not care stay simple.
|
||||||
*/
|
*/
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
|
/** Hosts ctx.hosts.list/get/checkAccess answer with. */
|
||||||
|
hosts?: PluginHostSummary[];
|
||||||
|
/** Users and roles ctx.hosts.listUsers/listRoles answer with. */
|
||||||
|
shareableUsers?: PluginShareableUser[];
|
||||||
|
shareableRoles?: PluginShareableRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FakeAuthRegistrations {
|
export interface FakeAuthRegistrations {
|
||||||
@@ -82,6 +92,13 @@ export interface FakePluginContext {
|
|||||||
coreSettings: Map<string, string>;
|
coreSettings: Map<string, string>;
|
||||||
/** Every ctx.ssh.connect and withConnection call, in order. */
|
/** Every ctx.ssh.connect and withConnection call, in order. */
|
||||||
sshConnections: Array<{ host: number | PluginSshHost; pool?: string }>;
|
sshConnections: Array<{ host: number | PluginSshHost; pool?: string }>;
|
||||||
|
/** Every ctx.hosts.share call, in order. */
|
||||||
|
hostShares: Array<{
|
||||||
|
hostId: number;
|
||||||
|
targets: unknown[];
|
||||||
|
permissionLevel: string;
|
||||||
|
durationHours?: number;
|
||||||
|
}>;
|
||||||
/** Everything registered through ctx.auth. */
|
/** Everything registered through ctx.auth. */
|
||||||
auth: FakeAuthRegistrations;
|
auth: FakeAuthRegistrations;
|
||||||
/** Changes the acting user, as core's request middleware would. */
|
/** Changes the acting user, as core's request middleware would. */
|
||||||
@@ -153,6 +170,10 @@ export function createFakeContext(
|
|||||||
);
|
);
|
||||||
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
|
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
|
||||||
const sshConnections: FakePluginContext["sshConnections"] = [];
|
const sshConnections: FakePluginContext["sshConnections"] = [];
|
||||||
|
const hostShares: FakePluginContext["hostShares"] = [];
|
||||||
|
const hostsById = new Map<number, PluginHostSummary>(
|
||||||
|
(options.hosts ?? []).map((h) => [h.id, h]),
|
||||||
|
);
|
||||||
const services = new Map<string, object>();
|
const services = new Map<string, object>();
|
||||||
const auth: FakeAuthRegistrations = {
|
const auth: FakeAuthRegistrations = {
|
||||||
sshAuthProviders: [],
|
sshAuthProviders: [],
|
||||||
@@ -385,6 +406,34 @@ export function createFakeContext(
|
|||||||
|
|
||||||
disposables,
|
disposables,
|
||||||
|
|
||||||
|
hosts: {
|
||||||
|
list: async () => [...hostsById.values()],
|
||||||
|
get: async (hostId) => hostsById.get(hostId) ?? null,
|
||||||
|
checkAccess: async (hostId): Promise<PluginHostAccess> => {
|
||||||
|
const found = hostsById.get(hostId);
|
||||||
|
if (!found)
|
||||||
|
return { hasAccess: false, isOwner: false, isShared: false };
|
||||||
|
const isOwner = found.userId === actor;
|
||||||
|
return {
|
||||||
|
hasAccess: true,
|
||||||
|
isOwner,
|
||||||
|
isShared: !isOwner,
|
||||||
|
permissionLevel: "manage",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
share: async (
|
||||||
|
hostId,
|
||||||
|
targets,
|
||||||
|
permissionLevel,
|
||||||
|
durationHours,
|
||||||
|
): Promise<PluginHostShareResult> => {
|
||||||
|
hostShares.push({ hostId, targets, permissionLevel, durationHours });
|
||||||
|
return { hostId, shared: true };
|
||||||
|
},
|
||||||
|
listUsers: async () => options.shareableUsers ?? [],
|
||||||
|
listRoles: async () => options.shareableRoles ?? [],
|
||||||
|
},
|
||||||
|
|
||||||
ssh: {
|
ssh: {
|
||||||
connect: async (host) => {
|
connect: async (host) => {
|
||||||
sshConnections.push({ host });
|
sshConnections.push({ host });
|
||||||
@@ -473,6 +522,7 @@ export function createFakeContext(
|
|||||||
settings,
|
settings,
|
||||||
coreSettings,
|
coreSettings,
|
||||||
sshConnections,
|
sshConnections,
|
||||||
|
hostShares,
|
||||||
auth,
|
auth,
|
||||||
setActor: (userId) => {
|
setActor: (userId) => {
|
||||||
actor = userId;
|
actor = userId;
|
||||||
@@ -643,6 +693,33 @@ export function createMockCtx(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hosts: {
|
||||||
|
list: async () => {
|
||||||
|
require("hosts:read");
|
||||||
|
return ctx.hosts.list();
|
||||||
|
},
|
||||||
|
get: async (hostId) => {
|
||||||
|
require("hosts:read");
|
||||||
|
return ctx.hosts.get(hostId);
|
||||||
|
},
|
||||||
|
checkAccess: async (hostId, level) => {
|
||||||
|
require("hosts:read");
|
||||||
|
return ctx.hosts.checkAccess(hostId, level);
|
||||||
|
},
|
||||||
|
share: async (hostId, targets, permissionLevel, durationHours) => {
|
||||||
|
require("hosts:write");
|
||||||
|
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
|
||||||
|
},
|
||||||
|
listUsers: async () => {
|
||||||
|
require("hosts:write");
|
||||||
|
return ctx.hosts.listUsers();
|
||||||
|
},
|
||||||
|
listRoles: async () => {
|
||||||
|
require("hosts:write");
|
||||||
|
return ctx.hosts.listRoles();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
ssh: {
|
ssh: {
|
||||||
...ctx.ssh,
|
...ctx.ssh,
|
||||||
connect: async (host, connectOptions) => {
|
connect: async (host, connectOptions) => {
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"workspaces": "^1.0.0",
|
"workspaces": "^1.0.0",
|
||||||
"network-topology": "^1.0.0",
|
"network-topology": "^1.0.0",
|
||||||
"snippets": "^1.0.0"
|
"snippets": "^1.0.0",
|
||||||
|
"fleets": "^1.0.0"
|
||||||
},
|
},
|
||||||
"requires": [
|
"requires": [
|
||||||
{
|
{
|
||||||
@@ -49,6 +50,11 @@
|
|||||||
"service": "snippets.access",
|
"service": "snippets.access",
|
||||||
"versionRange": "^1.0.0",
|
"versionRange": "^1.0.0",
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service": "fleets.access",
|
||||||
|
"versionRange": "^1.0.0",
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"providesSecret": [
|
"providesSecret": [
|
||||||
|
|||||||
@@ -52,6 +52,60 @@ export async function getNetworkTopology(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Another plugin's fleets, as ctx.services.get("fleets.access") returns them. */
|
||||||
|
interface FleetsAccess {
|
||||||
|
list: () => Promise<
|
||||||
|
Array<{ id: number; name: string; color: string | null }>
|
||||||
|
>;
|
||||||
|
create: (input: {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
}) => Promise<{ id: number; name: string }>;
|
||||||
|
addMember: (fleetId: number, hostId: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's fleets, or null when the fleets plugin is off or the user may
|
||||||
|
* not use it. Optional: the list_fleets tool disappears without it.
|
||||||
|
*/
|
||||||
|
export async function listFleets(
|
||||||
|
userId: string,
|
||||||
|
): Promise<Array<{ id: number; name: string; color: string | null }> | null> {
|
||||||
|
if (!current) return null;
|
||||||
|
try {
|
||||||
|
return await current.get<FleetsAccess>("fleets.access", { userId }).list();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires the fleets plugin, unlike listFleets above: a proposal apply must
|
||||||
|
* surface a failure as an error the user sees, not silently no-op.
|
||||||
|
*/
|
||||||
|
function requireFleetsAccess(userId: string): FleetsAccess {
|
||||||
|
if (!current) {
|
||||||
|
throw new Error("The fleets plugin is disabled");
|
||||||
|
}
|
||||||
|
return current.get<FleetsAccess>("fleets.access", { userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFleet(
|
||||||
|
userId: string,
|
||||||
|
input: { name: string; description?: string | null },
|
||||||
|
): Promise<{ id: number; name: string } | null> {
|
||||||
|
if (!current) return null;
|
||||||
|
return requireFleetsAccess(userId).create(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFleetMember(
|
||||||
|
userId: string,
|
||||||
|
fleetId: number,
|
||||||
|
hostId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await requireFleetsAccess(userId).addMember(fleetId, hostId);
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnippetSummary {
|
export interface SnippetSummary {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
createCurrentAutomationRepository,
|
createCurrentAutomationRepository,
|
||||||
createCurrentFleetRepository,
|
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||||
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
|
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
|
||||||
@@ -13,6 +12,8 @@ import {
|
|||||||
updateSnippet,
|
updateSnippet,
|
||||||
deleteSnippet,
|
deleteSnippet,
|
||||||
getSnippet,
|
getSnippet,
|
||||||
|
createFleet,
|
||||||
|
addFleetMember,
|
||||||
} from "../services.js";
|
} from "../services.js";
|
||||||
|
|
||||||
/** Approved commands get a bounded window rather than hanging the request. */
|
/** Approved commands get a bounded window rather than hanging the request. */
|
||||||
@@ -175,10 +176,11 @@ export async function applyProposal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "propose_create_fleet": {
|
case "propose_create_fleet": {
|
||||||
const fleet = await createCurrentFleetRepository().create(userId, {
|
const fleet = await createFleet(userId, {
|
||||||
name: requireString(payload.name, "name"),
|
name: requireString(payload.name, "name"),
|
||||||
description: optionalString(payload.description),
|
description: optionalString(payload.description),
|
||||||
} as any);
|
});
|
||||||
|
if (!fleet) throw new Error("The fleets plugin is disabled");
|
||||||
|
|
||||||
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
|
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
|
||||||
let added = 0;
|
let added = 0;
|
||||||
@@ -191,16 +193,13 @@ export async function applyProposal(
|
|||||||
hostId,
|
hostId,
|
||||||
);
|
);
|
||||||
if (!host) continue;
|
if (!host) continue;
|
||||||
await createCurrentFleetRepository().addMember(
|
await addFleetMember(userId, fleet.id, hostId);
|
||||||
(fleet as any).id,
|
|
||||||
hostId,
|
|
||||||
);
|
|
||||||
added += 1;
|
added += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
summary: `Created fleet ${(fleet as any).name} with ${added} host${added === 1 ? "" : "s"}`,
|
summary: `Created fleet ${fleet.name} with ${added} host${added === 1 ? "" : "s"}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
createCurrentNotificationChannelRepository,
|
createCurrentNotificationChannelRepository,
|
||||||
createCurrentAutomationRepository,
|
createCurrentAutomationRepository,
|
||||||
createCurrentCommandHistoryRepository,
|
createCurrentCommandHistoryRepository,
|
||||||
createCurrentFleetRepository,
|
|
||||||
createCurrentHomepageItemRepository,
|
createCurrentHomepageItemRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
listSavedWorkspaces,
|
listSavedWorkspaces,
|
||||||
getNetworkTopology,
|
getNetworkTopology,
|
||||||
listSnippets,
|
listSnippets,
|
||||||
|
listFleets,
|
||||||
} from "../services.js";
|
} from "../services.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,18 +87,16 @@ export const readTools: AiTool[] = [
|
|||||||
{
|
{
|
||||||
name: "list_fleets",
|
name: "list_fleets",
|
||||||
description:
|
description:
|
||||||
"List the user's fleets. Fleets group hosts for bulk operations and inventory.",
|
"List the user's fleets. Fleets group hosts for bulk operations and inventory. Unavailable if the fleets plugin is disabled.",
|
||||||
category: "read",
|
category: "read",
|
||||||
parameters: objectSchema({}),
|
parameters: objectSchema({}),
|
||||||
handler: async (_args, context) => {
|
handler: async (_args, context) => {
|
||||||
const fleets = await createCurrentFleetRepository().listByUser(
|
const fleets = await listFleets(context.userId);
|
||||||
context.userId,
|
if (fleets === null) return { unavailable: true, fleets: [] };
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
fleets: fleets.map((fleet: any) => ({
|
fleets: fleets.map((fleet) => ({
|
||||||
id: fleet.id,
|
id: fleet.id,
|
||||||
name: fleet.name,
|
name: fleet.name,
|
||||||
description: fleet.description ?? null,
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,13 +12,12 @@ const snippetRepository = {
|
|||||||
updateSnippet: vi.fn(),
|
updateSnippet: vi.fn(),
|
||||||
deleteSnippet: vi.fn(),
|
deleteSnippet: vi.fn(),
|
||||||
};
|
};
|
||||||
const fleetRepository = { create: vi.fn(), addMember: vi.fn() };
|
|
||||||
const automationRepository = { create: vi.fn() };
|
const automationRepository = { create: vi.fn() };
|
||||||
|
const fleetsAccess = { create: vi.fn(), addMember: vi.fn() };
|
||||||
|
|
||||||
vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({
|
vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({
|
||||||
createCurrentHostRepository: () => hostRepository,
|
createCurrentHostRepository: () => hostRepository,
|
||||||
createCurrentSnippetRepository: () => snippetRepository,
|
createCurrentSnippetRepository: () => snippetRepository,
|
||||||
createCurrentFleetRepository: () => fleetRepository,
|
|
||||||
createCurrentAutomationRepository: () => automationRepository,
|
createCurrentAutomationRepository: () => automationRepository,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -44,10 +43,12 @@ vi.mock("../../../src/backend/ssh.js", () => ({
|
|||||||
|
|
||||||
const { applyProposal } =
|
const { applyProposal } =
|
||||||
await import("../../../src/backend/tools/executor.js");
|
await import("../../../src/backend/tools/executor.js");
|
||||||
|
const { setPluginServices } = await import("../../../src/backend/services.js");
|
||||||
|
|
||||||
describe("applyProposal", () => {
|
describe("applyProposal", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
setPluginServices(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses a kind that is not a real tool", async () => {
|
it("refuses a kind that is not a real tool", async () => {
|
||||||
@@ -221,7 +222,15 @@ describe("applyProposal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("only adds fleet members the approving user owns", async () => {
|
it("only adds fleet members the approving user owns", async () => {
|
||||||
fleetRepository.create.mockResolvedValue({ id: 3, name: "prod" });
|
setPluginServices({
|
||||||
|
get: (service: string) => {
|
||||||
|
if (service !== "fleets.access")
|
||||||
|
throw new Error(`unexpected ${service}`);
|
||||||
|
return fleetsAccess as never;
|
||||||
|
},
|
||||||
|
provide: vi.fn(),
|
||||||
|
} as never);
|
||||||
|
fleetsAccess.create.mockResolvedValue({ id: 3, name: "prod" });
|
||||||
hostRepository.findByIdForUser.mockImplementation(
|
hostRepository.findByIdForUser.mockImplementation(
|
||||||
async (_userId: string, hostId: number) =>
|
async (_userId: string, hostId: number) =>
|
||||||
hostId === 1 ? { id: 1 } : null,
|
hostId === 1 ? { id: 1 } : null,
|
||||||
@@ -233,11 +242,17 @@ describe("applyProposal", () => {
|
|||||||
"user-1",
|
"user-1",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fleetRepository.addMember).toHaveBeenCalledTimes(1);
|
expect(fleetsAccess.addMember).toHaveBeenCalledTimes(1);
|
||||||
expect(fleetRepository.addMember).toHaveBeenCalledWith(3, 1);
|
expect(fleetsAccess.addMember).toHaveBeenCalledWith(3, 1);
|
||||||
expect(result.summary).toContain("1 host");
|
expect(result.summary).toContain("1 host");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fails clearly when the fleets plugin is disabled", async () => {
|
||||||
|
await expect(
|
||||||
|
applyProposal("propose_create_fleet", { name: "prod" }, "user-1"),
|
||||||
|
).rejects.toThrow("fleets plugin is disabled");
|
||||||
|
});
|
||||||
|
|
||||||
it("reports nothing to change on an empty update", async () => {
|
it("reports nothing to change on an empty update", async () => {
|
||||||
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
|
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
|
||||||
|
|
||||||
|
|||||||
@@ -27,13 +27,19 @@
|
|||||||
"ui:surface"
|
"ui:surface"
|
||||||
],
|
],
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"snippets": "^1.0.0"
|
"snippets": "^1.0.0",
|
||||||
|
"fleets": "^1.0.0"
|
||||||
},
|
},
|
||||||
"requires": [
|
"requires": [
|
||||||
{
|
{
|
||||||
"service": "snippets.access",
|
"service": "snippets.access",
|
||||||
"versionRange": "^1.0.0",
|
"versionRange": "^1.0.0",
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service": "fleets.access",
|
||||||
|
"versionRange": "^1.0.0",
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { HostSelector } from "../../../../../src/types/automations.js";
|
import type { HostSelector } from "../../../../../src/types/automations.js";
|
||||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||||
import { createCurrentFleetRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
|
||||||
import { PermissionManager } from "../../../../../src/backend/utils/permission-manager.js";
|
import { PermissionManager } from "../../../../../src/backend/utils/permission-manager.js";
|
||||||
|
import { fleetMemberIds } from "../snippets.js";
|
||||||
import type { StepExecutionContext } from "./types.js";
|
import type { StepExecutionContext } from "./types.js";
|
||||||
|
|
||||||
/** A host the caller is allowed to act on. `host` is always resolved. */
|
/** A host the caller is allowed to act on. `host` is always resolved. */
|
||||||
@@ -63,13 +63,7 @@ async function fleetHostIds(
|
|||||||
fleetId: number,
|
fleetId: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
try {
|
return fleetMemberIds(userId, fleetId);
|
||||||
const repository = createCurrentFleetRepository();
|
|
||||||
const members = await repository.listEffectiveMembers(userId, fleetId);
|
|
||||||
return members.map((member) => member.id);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function allAccessibleHostIds(userId: string): Promise<number[]> {
|
async function allAccessibleHostIds(userId: string): Promise<number[]> {
|
||||||
|
|||||||
@@ -62,3 +62,30 @@ export async function getSnippet(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Another plugin's fleets, as ctx.services.get("fleets.access") returns them. */
|
||||||
|
interface FleetsAccess {
|
||||||
|
members: (
|
||||||
|
fleetId: number,
|
||||||
|
) => Promise<Array<{ id: number; name: string | null; ip: string }>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fleet's effective member host ids, for a "fleet" target selector. Empty
|
||||||
|
* when the fleets plugin is off, the user may not use it, or the fleet does
|
||||||
|
* not exist - the step then runs against no hosts rather than failing.
|
||||||
|
*/
|
||||||
|
export async function fleetMemberIds(
|
||||||
|
userId: string,
|
||||||
|
fleetId: number,
|
||||||
|
): Promise<number[]> {
|
||||||
|
if (!current) return [];
|
||||||
|
try {
|
||||||
|
const members = await current
|
||||||
|
.get<FleetsAccess>("fleets.access", { userId })
|
||||||
|
.members(fleetId);
|
||||||
|
return members.map((member) => member.id);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,18 @@
|
|||||||
{
|
{
|
||||||
|
"permissions": {
|
||||||
|
"view": {
|
||||||
|
"title": "View fleets",
|
||||||
|
"description": "See fleets, their members and inventory."
|
||||||
|
},
|
||||||
|
"manage": {
|
||||||
|
"title": "Manage fleets",
|
||||||
|
"description": "Create, edit and delete fleets, change membership, and share fleets with other users or roles."
|
||||||
|
},
|
||||||
|
"execute": {
|
||||||
|
"title": "Run fleet actions",
|
||||||
|
"description": "Run commands and package actions across a fleet, and transfer files to and from its member hosts."
|
||||||
|
}
|
||||||
|
},
|
||||||
"newUi": {
|
"newUi": {
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"fleets": {
|
"fleets": {
|
||||||
|
|||||||
@@ -16,12 +16,20 @@
|
|||||||
},
|
},
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"hosts:read",
|
"hosts:read",
|
||||||
|
"hosts:write",
|
||||||
"credentials:use",
|
"credentials:use",
|
||||||
"ssh:connect",
|
"ssh:connect",
|
||||||
"db:own",
|
"db:own",
|
||||||
"network:serve",
|
"network:serve",
|
||||||
"ui:surface"
|
"ui:surface"
|
||||||
],
|
],
|
||||||
|
"provides": [
|
||||||
|
{
|
||||||
|
"service": "fleets.access",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"permission": "fleets.view"
|
||||||
|
}
|
||||||
|
],
|
||||||
"locales": "locales",
|
"locales": "locales",
|
||||||
"platforms": ["linux", "win32", "darwin"],
|
"platforms": ["linux", "win32", "darwin"],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
@@ -39,6 +47,26 @@
|
|||||||
"titleKey": "nav.fleets",
|
"titleKey": "nav.fleets",
|
||||||
"icon": "Boxes"
|
"icon": "Boxes"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"name": "view",
|
||||||
|
"titleKey": "permissions.view.title",
|
||||||
|
"descriptionKey": "permissions.view.description",
|
||||||
|
"defaultRoles": ["user"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "manage",
|
||||||
|
"titleKey": "permissions.manage.title",
|
||||||
|
"descriptionKey": "permissions.manage.description",
|
||||||
|
"defaultRoles": ["user"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "execute",
|
||||||
|
"titleKey": "permissions.execute.title",
|
||||||
|
"descriptionKey": "permissions.execute.description",
|
||||||
|
"defaultRoles": ["user"]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- fleets 0001: adopt_fleets
|
||||||
|
-- Generated by termix-plugin migrations. Review before committing.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `fleets` (
|
||||||
|
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`user_id` varchar(255) NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`color` text,
|
||||||
|
`icon` text,
|
||||||
|
`tag_rules` text,
|
||||||
|
`sync_id` varchar(255) UNIQUE,
|
||||||
|
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
RENAME TABLE `fleets` TO `p_fleets_fleets`;
|
||||||
|
CREATE TABLE IF NOT EXISTS `fleet_members` (
|
||||||
|
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`fleet_id` int NOT NULL,
|
||||||
|
`host_id` int NOT NULL,
|
||||||
|
`added_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
FOREIGN KEY (`fleet_id`) REFERENCES `p_fleets_fleets` (`id`) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
RENAME TABLE `fleet_members` TO `p_fleets_members`;
|
||||||
|
CREATE TABLE IF NOT EXISTS `fleet_inventory` (
|
||||||
|
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`host_id` int NOT NULL,
|
||||||
|
`user_id` varchar(255) NOT NULL,
|
||||||
|
`os_pretty_name` text,
|
||||||
|
`kernel` text,
|
||||||
|
`architecture` text,
|
||||||
|
`hostname` text,
|
||||||
|
`uptime_seconds` int,
|
||||||
|
`ip` text,
|
||||||
|
`package_manager` text,
|
||||||
|
`collected_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
RENAME TABLE `fleet_inventory` TO `p_fleets_inventory`;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- fleets 0001: adopt_fleets
|
||||||
|
-- Generated by termix-plugin migrations. Review before committing.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "fleets" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" varchar(255) NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"color" text,
|
||||||
|
"icon" text,
|
||||||
|
"tag_rules" text,
|
||||||
|
"sync_id" varchar(255) UNIQUE,
|
||||||
|
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";
|
||||||
|
CREATE TABLE IF NOT EXISTS "fleet_members" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"fleet_id" integer NOT NULL,
|
||||||
|
"host_id" integer NOT NULL,
|
||||||
|
"added_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY ("fleet_id") REFERENCES "p_fleets_fleets" ("id") ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_members_fleet_host" ON "fleet_members" ("fleet_id", "host_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_fleet_members_host" ON "fleet_members" ("host_id");
|
||||||
|
ALTER TABLE "fleet_members" RENAME TO "p_fleets_members";
|
||||||
|
CREATE TABLE IF NOT EXISTS "fleet_inventory" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"host_id" integer NOT NULL,
|
||||||
|
"user_id" varchar(255) NOT NULL,
|
||||||
|
"os_pretty_name" text,
|
||||||
|
"kernel" text,
|
||||||
|
"architecture" text,
|
||||||
|
"hostname" text,
|
||||||
|
"uptime_seconds" integer,
|
||||||
|
"ip" text,
|
||||||
|
"package_manager" text,
|
||||||
|
"collected_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_inventory_host" ON "fleet_inventory" ("host_id", "user_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_fleet_inventory_user" ON "fleet_inventory" ("user_id");
|
||||||
|
ALTER TABLE "fleet_inventory" RENAME TO "p_fleets_inventory";
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"tables": {
|
||||||
|
"fleets": {
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"type": "id",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"type": "refUser",
|
||||||
|
"length": 255,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"tagRules": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"syncId": {
|
||||||
|
"type": "text",
|
||||||
|
"unique": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultNow": true
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultNow": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": [],
|
||||||
|
"adopts": "fleets"
|
||||||
|
},
|
||||||
|
"members": {
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"type": "id",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"fleetId": {
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"hostId": {
|
||||||
|
"type": "refHost",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"addedAt": {
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultNow": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"name": "idx_fleet_members_fleet_host",
|
||||||
|
"columns": ["fleetId", "hostId"],
|
||||||
|
"unique": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_fleet_members_host",
|
||||||
|
"columns": ["hostId"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"adopts": "fleet_members"
|
||||||
|
},
|
||||||
|
"inventory": {
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"type": "id",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"hostId": {
|
||||||
|
"type": "refHost",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"type": "refUser",
|
||||||
|
"length": 255,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"osPrettyName": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"kernel": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"architecture": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"hostname": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"uptimeSeconds": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"ip": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"packageManager": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"collectedAt": {
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultNow": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"name": "idx_fleet_inventory_host",
|
||||||
|
"columns": ["hostId", "userId"],
|
||||||
|
"unique": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_fleet_inventory_user",
|
||||||
|
"columns": ["userId"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"adopts": "fleet_inventory"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- fleets 0001: adopt_fleets
|
||||||
|
-- Generated by termix-plugin migrations. Review before committing.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "fleets" (
|
||||||
|
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"color" text,
|
||||||
|
"icon" text,
|
||||||
|
"tag_rules" text,
|
||||||
|
"sync_id" text UNIQUE,
|
||||||
|
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";
|
||||||
|
CREATE TABLE IF NOT EXISTS "fleet_members" (
|
||||||
|
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"fleet_id" integer NOT NULL,
|
||||||
|
"host_id" integer NOT NULL,
|
||||||
|
"added_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY ("fleet_id") REFERENCES "p_fleets_fleets" ("id") ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_members_fleet_host" ON "fleet_members" ("fleet_id", "host_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_fleet_members_host" ON "fleet_members" ("host_id");
|
||||||
|
ALTER TABLE "fleet_members" RENAME TO "p_fleets_members";
|
||||||
|
CREATE TABLE IF NOT EXISTS "fleet_inventory" (
|
||||||
|
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"host_id" integer NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"os_pretty_name" text,
|
||||||
|
"kernel" text,
|
||||||
|
"architecture" text,
|
||||||
|
"hostname" text,
|
||||||
|
"uptime_seconds" integer,
|
||||||
|
"ip" text,
|
||||||
|
"package_manager" text,
|
||||||
|
"collected_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_inventory_host" ON "fleet_inventory" ("host_id", "user_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_fleet_inventory_user" ON "fleet_inventory" ("user_id");
|
||||||
|
ALTER TABLE "fleet_inventory" RENAME TO "p_fleets_inventory";
|
||||||
@@ -1,14 +1,75 @@
|
|||||||
|
import type { Router } from "express";
|
||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
import { setPluginSsh } from "./ssh.js";
|
import { fleets, fleetMembers, fleetInventory } from "./tables.js";
|
||||||
import { startFleetsService, stopFleetsService } from "./routes.js";
|
import { createFleetRepository } from "./repository.js";
|
||||||
|
import { registerFleetRoutes } from "./routes.js";
|
||||||
|
|
||||||
|
/** What other plugins get from ctx.services.get("fleets.access", {userId}). */
|
||||||
|
export interface FleetsService {
|
||||||
|
/** The calling user's fleets, without resolving membership. */
|
||||||
|
list: () => Promise<
|
||||||
|
Array<{ id: number; name: string; color: string | null }>
|
||||||
|
>;
|
||||||
|
/** A fleet's effective member hosts (static membership union tag matches). */
|
||||||
|
members: (
|
||||||
|
fleetId: number,
|
||||||
|
) => Promise<Array<{ id: number; name: string | null; ip: string }>>;
|
||||||
|
/** Creates a fleet owned by the calling user. */
|
||||||
|
create: (input: {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
}) => Promise<{ id: number; name: string }>;
|
||||||
|
/** Adds a host the calling user owns to a fleet's static membership. */
|
||||||
|
addMember: (fleetId: number, hostId: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
setPluginSsh(ctx.ssh);
|
const fleetsTable = await ctx.db.define(fleets);
|
||||||
ctx.disposables.add(() => setPluginSsh(null));
|
const membersTable = await ctx.db.define(fleetMembers);
|
||||||
startFleetsService(ctx.http.router());
|
const inventoryTable = await ctx.db.define(fleetInventory);
|
||||||
|
|
||||||
|
const repo = createFleetRepository(
|
||||||
|
ctx.db,
|
||||||
|
ctx.hosts,
|
||||||
|
fleetsTable,
|
||||||
|
membersTable,
|
||||||
|
inventoryTable,
|
||||||
|
);
|
||||||
|
|
||||||
|
registerFleetRoutes(ctx.http.router<Router>(), repo, ctx);
|
||||||
|
|
||||||
|
const service: FleetsService = {
|
||||||
|
list: async () => {
|
||||||
|
const userId = ctx.currentActor();
|
||||||
|
if (!userId) return [];
|
||||||
|
const rows = await repo.listByUser(userId);
|
||||||
|
return rows.map((f) => ({ id: f.id, name: f.name, color: f.color }));
|
||||||
|
},
|
||||||
|
members: async (fleetId) => {
|
||||||
|
const userId = ctx.currentActor();
|
||||||
|
if (!userId) return [];
|
||||||
|
const members = await repo.listEffectiveMembers(userId, fleetId);
|
||||||
|
return members.map((m) => ({ id: m.id, name: m.name, ip: m.ip }));
|
||||||
|
},
|
||||||
|
create: async (input) => {
|
||||||
|
const userId = ctx.currentActor();
|
||||||
|
if (!userId) throw new Error("fleets.access.create needs an actor");
|
||||||
|
const created = await repo.create(userId, input);
|
||||||
|
return { id: created.id, name: created.name };
|
||||||
|
},
|
||||||
|
addMember: async (fleetId, hostId) => {
|
||||||
|
const userId = ctx.currentActor();
|
||||||
|
if (!userId) throw new Error("fleets.access.addMember needs an actor");
|
||||||
|
const fleet = await repo.findById(userId, fleetId);
|
||||||
|
if (!fleet) throw new Error("Fleet not found");
|
||||||
|
await repo.addMember(fleetId, hostId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ctx.services.provide("fleets.access", service);
|
||||||
|
|
||||||
ctx.log.info("Fleets routes mounted at /plugin-api/fleets");
|
ctx.log.info("Fleets routes mounted at /plugin-api/fleets");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
stopFleetsService();
|
// Everything above was registered through ctx and is disposed by core.
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { PluginDatabase, PluginHosts } from "@termix/plugin-sdk/backend";
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
// The tables come from ctx.db.define, which the SDK hands back untyped, and
|
||||||
|
// the drizzle handle is the server's own. Typed at this module's edge instead.
|
||||||
|
type Table = any;
|
||||||
|
type Drizzle = any;
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
export interface FleetRecord {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
color: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
tagRules: string | null;
|
||||||
|
syncId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetCreateInput {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
tagRules?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetUpdateInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
tagRules?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetInventoryRecord {
|
||||||
|
id: number;
|
||||||
|
hostId: number;
|
||||||
|
userId: string;
|
||||||
|
osPrettyName: string | null;
|
||||||
|
kernel: string | null;
|
||||||
|
architecture: string | null;
|
||||||
|
hostname: string | null;
|
||||||
|
uptimeSeconds: number | null;
|
||||||
|
ip: string | null;
|
||||||
|
packageManager: string | null;
|
||||||
|
collectedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetInventoryInput {
|
||||||
|
osPrettyName: string | null;
|
||||||
|
kernel: string | null;
|
||||||
|
architecture: string | null;
|
||||||
|
hostname: string | null;
|
||||||
|
uptimeSeconds: number | null;
|
||||||
|
ip: string | null;
|
||||||
|
packageManager: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTagRules(raw: string | null): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.filter((t): t is string => typeof t === "string")
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FleetRepository = ReturnType<typeof createFleetRepository>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fleets, membership and inventory, written without RETURNING so it runs the
|
||||||
|
* same on all three engines: an insert is read back by its sync id, an
|
||||||
|
* update by its own id.
|
||||||
|
*/
|
||||||
|
export function createFleetRepository(
|
||||||
|
db: PluginDatabase,
|
||||||
|
hosts: PluginHosts,
|
||||||
|
fleetsTable: Table,
|
||||||
|
membersTable: Table,
|
||||||
|
inventoryTable: Table,
|
||||||
|
) {
|
||||||
|
const client = () => db.client<Drizzle>();
|
||||||
|
|
||||||
|
async function findById(
|
||||||
|
userId: string,
|
||||||
|
fleetId: number,
|
||||||
|
): Promise<FleetRecord | null> {
|
||||||
|
const drizzle = await client();
|
||||||
|
const rows = await drizzle
|
||||||
|
.select()
|
||||||
|
.from(fleetsTable)
|
||||||
|
.where(and(eq(fleetsTable.id, fleetId), eq(fleetsTable.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
return (rows[0] as FleetRecord) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findBySyncId(syncId: string): Promise<FleetRecord> {
|
||||||
|
const drizzle = await client();
|
||||||
|
const rows = await drizzle
|
||||||
|
.select()
|
||||||
|
.from(fleetsTable)
|
||||||
|
.where(eq(fleetsTable.syncId, syncId))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] as FleetRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listStaticMemberIds(fleetId: number): Promise<number[]> {
|
||||||
|
const drizzle = await client();
|
||||||
|
const rows = await drizzle
|
||||||
|
.select({ hostId: membersTable.hostId })
|
||||||
|
.from(membersTable)
|
||||||
|
.where(eq(membersTable.fleetId, fleetId));
|
||||||
|
return rows.map((r: { hostId: number }) => r.hostId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async listByUser(userId: string): Promise<FleetRecord[]> {
|
||||||
|
const drizzle = await client();
|
||||||
|
return drizzle
|
||||||
|
.select()
|
||||||
|
.from(fleetsTable)
|
||||||
|
.where(eq(fleetsTable.userId, userId));
|
||||||
|
},
|
||||||
|
|
||||||
|
findById,
|
||||||
|
listStaticMemberIds,
|
||||||
|
|
||||||
|
async create(
|
||||||
|
userId: string,
|
||||||
|
input: FleetCreateInput,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<FleetRecord> {
|
||||||
|
const syncId = randomUUID();
|
||||||
|
const drizzle = await client();
|
||||||
|
await drizzle.insert(fleetsTable).values({
|
||||||
|
userId,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? null,
|
||||||
|
color: input.color ?? null,
|
||||||
|
icon: input.icon ?? null,
|
||||||
|
tagRules: input.tagRules ? JSON.stringify(input.tagRules) : null,
|
||||||
|
syncId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
await db.persist();
|
||||||
|
return findBySyncId(syncId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
userId: string,
|
||||||
|
fleetId: number,
|
||||||
|
input: FleetUpdateInput,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<FleetRecord | null> {
|
||||||
|
const existing = await findById(userId, fleetId);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
const drizzle = await client();
|
||||||
|
await drizzle
|
||||||
|
.update(fleetsTable)
|
||||||
|
.set({
|
||||||
|
name: input.name ?? existing.name,
|
||||||
|
description:
|
||||||
|
input.description === undefined
|
||||||
|
? existing.description
|
||||||
|
: input.description,
|
||||||
|
color: input.color === undefined ? existing.color : input.color,
|
||||||
|
icon: input.icon === undefined ? existing.icon : input.icon,
|
||||||
|
tagRules:
|
||||||
|
input.tagRules === undefined
|
||||||
|
? existing.tagRules
|
||||||
|
: JSON.stringify(input.tagRules),
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(fleetsTable.id, fleetId), eq(fleetsTable.userId, userId)),
|
||||||
|
);
|
||||||
|
await db.persist();
|
||||||
|
return findById(userId, fleetId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(userId: string, fleetId: number): Promise<boolean> {
|
||||||
|
const existing = await findById(userId, fleetId);
|
||||||
|
if (!existing) return false;
|
||||||
|
const drizzle = await client();
|
||||||
|
await drizzle
|
||||||
|
.delete(fleetsTable)
|
||||||
|
.where(
|
||||||
|
and(eq(fleetsTable.id, fleetId), eq(fleetsTable.userId, userId)),
|
||||||
|
);
|
||||||
|
await db.persist();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async addMember(
|
||||||
|
fleetId: number,
|
||||||
|
hostId: number,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<void> {
|
||||||
|
const drizzle = await client();
|
||||||
|
const existing = await drizzle
|
||||||
|
.select({ id: membersTable.id })
|
||||||
|
.from(membersTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(membersTable.fleetId, fleetId),
|
||||||
|
eq(membersTable.hostId, hostId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (existing.length > 0) return;
|
||||||
|
|
||||||
|
await drizzle
|
||||||
|
.insert(membersTable)
|
||||||
|
.values({ fleetId, hostId, addedAt: now });
|
||||||
|
await db.persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeMember(fleetId: number, hostId: number): Promise<boolean> {
|
||||||
|
const drizzle = await client();
|
||||||
|
const existing = await drizzle
|
||||||
|
.select({ id: membersTable.id })
|
||||||
|
.from(membersTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(membersTable.fleetId, fleetId),
|
||||||
|
eq(membersTable.hostId, hostId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (existing.length === 0) return false;
|
||||||
|
|
||||||
|
await drizzle
|
||||||
|
.delete(membersTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(membersTable.fleetId, fleetId),
|
||||||
|
eq(membersTable.hostId, hostId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await db.persist();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective membership = static members rows union hosts whose
|
||||||
|
* comma-separated tags string intersects any tag in the fleet's
|
||||||
|
* tagRules. Both sets are scoped to hosts the fleet owner can see
|
||||||
|
* (ctx.hosts.list already resolves owner-only, since a fleet only holds
|
||||||
|
* one user's hosts) and deduplicated by id.
|
||||||
|
*/
|
||||||
|
async listEffectiveMembers(userId: string, fleetId: number) {
|
||||||
|
const fleet = await findById(userId, fleetId);
|
||||||
|
if (!fleet) return [];
|
||||||
|
|
||||||
|
const staticIds = await listStaticMemberIds(fleetId);
|
||||||
|
const tagRules = parseTagRules(fleet.tagRules);
|
||||||
|
|
||||||
|
const ownedHosts = (await hosts.list()).filter(
|
||||||
|
(host) => host.userId === userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const byId = new Map<number, (typeof ownedHosts)[number]>();
|
||||||
|
for (const host of ownedHosts) {
|
||||||
|
if (staticIds.includes(host.id)) {
|
||||||
|
byId.set(host.id, host);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tagRules.length === 0) continue;
|
||||||
|
const hostTags = (host.tags ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (hostTags.some((tag) => tagRules.includes(tag))) {
|
||||||
|
byId.set(host.id, host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byId.values());
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
|
const drizzle = await client();
|
||||||
|
const userFleets = await drizzle
|
||||||
|
.select({ id: fleetsTable.id })
|
||||||
|
.from(fleetsTable)
|
||||||
|
.where(eq(fleetsTable.userId, userId));
|
||||||
|
if (userFleets.length === 0) return 0;
|
||||||
|
await drizzle.delete(fleetsTable).where(eq(fleetsTable.userId, userId));
|
||||||
|
await db.persist();
|
||||||
|
return userFleets.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertInventory(
|
||||||
|
userId: string,
|
||||||
|
hostId: number,
|
||||||
|
input: FleetInventoryInput,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<FleetInventoryRecord> {
|
||||||
|
const drizzle = await client();
|
||||||
|
const existing = await drizzle
|
||||||
|
.select()
|
||||||
|
.from(inventoryTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(inventoryTable.hostId, hostId),
|
||||||
|
eq(inventoryTable.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing[0]) {
|
||||||
|
await drizzle
|
||||||
|
.update(inventoryTable)
|
||||||
|
.set({ ...input, collectedAt: now })
|
||||||
|
.where(eq(inventoryTable.id, existing[0].id));
|
||||||
|
await db.persist();
|
||||||
|
const [updated] = await drizzle
|
||||||
|
.select()
|
||||||
|
.from(inventoryTable)
|
||||||
|
.where(eq(inventoryTable.id, existing[0].id))
|
||||||
|
.limit(1);
|
||||||
|
return updated as FleetInventoryRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
await drizzle
|
||||||
|
.insert(inventoryTable)
|
||||||
|
.values({ userId, hostId, ...input, collectedAt: now });
|
||||||
|
await db.persist();
|
||||||
|
const [created] = await drizzle
|
||||||
|
.select()
|
||||||
|
.from(inventoryTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(inventoryTable.hostId, hostId),
|
||||||
|
eq(inventoryTable.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return created as FleetInventoryRecord;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listInventoryForHosts(
|
||||||
|
userId: string,
|
||||||
|
hostIds: number[],
|
||||||
|
): Promise<FleetInventoryRecord[]> {
|
||||||
|
if (hostIds.length === 0) return [];
|
||||||
|
const drizzle = await client();
|
||||||
|
return drizzle
|
||||||
|
.select()
|
||||||
|
.from(inventoryTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(inventoryTable.userId, userId),
|
||||||
|
inArray(inventoryTable.hostId, hostIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+1239
-1349
File diff suppressed because it is too large.
Load diff
@@ -1,36 +0,0 @@
|
|||||||
import type { Client } from "ssh2";
|
|
||||||
import type {
|
|
||||||
PluginSsh,
|
|
||||||
PluginSshConnection,
|
|
||||||
PluginSshConnectOptions,
|
|
||||||
PluginSshHost,
|
|
||||||
} from "@termix/plugin-sdk/backend";
|
|
||||||
|
|
||||||
let current: PluginSsh | null = null;
|
|
||||||
|
|
||||||
/** Set in activate, cleared on deactivate. */
|
|
||||||
export function setPluginSsh(ssh: PluginSsh | null): void {
|
|
||||||
current = ssh;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pluginSsh(): PluginSsh {
|
|
||||||
if (!current) throw new Error("The plugin is not active");
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** withConnection with ssh2's Client type filled in. */
|
|
||||||
export function withSshConnection<T>(
|
|
||||||
host: number | PluginSshHost,
|
|
||||||
options: PluginSshConnectOptions & { pool: string },
|
|
||||||
fn: (client: Client) => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
return pluginSsh().withConnection<T, Client>(host, options, fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** connect with ssh2's Client type filled in. */
|
|
||||||
export function connectSsh(
|
|
||||||
host: number | PluginSshHost,
|
|
||||||
options?: PluginSshConnectOptions,
|
|
||||||
): Promise<PluginSshConnection<Client>> {
|
|
||||||
return pluginSsh().connect<Client>(host, options);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
adoptLegacyTable,
|
||||||
|
defineTable,
|
||||||
|
id,
|
||||||
|
integer,
|
||||||
|
refHost,
|
||||||
|
refUser,
|
||||||
|
text,
|
||||||
|
} from "@termix/plugin-sdk/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fleet: a named group of hosts, by static membership or tag rules.
|
||||||
|
*
|
||||||
|
* Adopted from core's "fleets", so column and index names are the legacy
|
||||||
|
* ones: the rename carries the existing rows across.
|
||||||
|
*/
|
||||||
|
export const fleets = adoptLegacyTable(
|
||||||
|
"fleets",
|
||||||
|
defineTable("fleets", {
|
||||||
|
id: id(),
|
||||||
|
userId: refUser(),
|
||||||
|
name: text().notNull(),
|
||||||
|
description: text(),
|
||||||
|
color: text(),
|
||||||
|
icon: text(),
|
||||||
|
// JSON array of tag strings, unioned with static members at resolution
|
||||||
|
// time. Kept to tag-equality matching for v1.
|
||||||
|
tagRules: text(),
|
||||||
|
syncId: text().unique(),
|
||||||
|
createdAt: text().notNull().defaultNow(),
|
||||||
|
updatedAt: text().notNull().defaultNow(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static fleet membership. A host can also be an effective member through a
|
||||||
|
* tag rule match, resolved in the repository rather than stored here.
|
||||||
|
*
|
||||||
|
* fleetId has no declared refUser/refHost FK: the SDK's referenceable targets
|
||||||
|
* are core's users and ssh_data tables only, not another table this same
|
||||||
|
* plugin owns. The legacy foreign key to fleets(id) is preserved by the
|
||||||
|
* adoption migration's CREATE TABLE, which is why the migration file keeps
|
||||||
|
* the FOREIGN KEY clause the generator does not emit for this column.
|
||||||
|
*/
|
||||||
|
export const fleetMembers = adoptLegacyTable(
|
||||||
|
"fleet_members",
|
||||||
|
defineTable(
|
||||||
|
"members",
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
fleetId: integer().notNull(),
|
||||||
|
hostId: refHost(),
|
||||||
|
addedAt: text().notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// fleetId leads the unique pair, so listing a fleet's hosts is already
|
||||||
|
// served. Finding the fleets a host belongs to starts from hostId.
|
||||||
|
uniques: [
|
||||||
|
{
|
||||||
|
name: "idx_fleet_members_fleet_host",
|
||||||
|
columns: ["fleetId", "hostId"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [{ name: "idx_fleet_members_host", columns: ["hostId"] }],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Latest-only inventory snapshot per host, overwritten on each refresh - no
|
||||||
|
// historical log, matching the "latest snapshot only" scope decision.
|
||||||
|
export const fleetInventory = adoptLegacyTable(
|
||||||
|
"fleet_inventory",
|
||||||
|
defineTable(
|
||||||
|
"inventory",
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
hostId: refHost(),
|
||||||
|
userId: refUser(),
|
||||||
|
osPrettyName: text(),
|
||||||
|
kernel: text(),
|
||||||
|
architecture: text(),
|
||||||
|
hostname: text(),
|
||||||
|
uptimeSeconds: integer(),
|
||||||
|
ip: text(),
|
||||||
|
packageManager: text(),
|
||||||
|
collectedAt: text().notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// hostId leads the unique pair; a user's whole inventory reads by userId.
|
||||||
|
uniques: [
|
||||||
|
{ name: "idx_fleet_inventory_host", columns: ["hostId", "userId"] },
|
||||||
|
],
|
||||||
|
indexes: [{ name: "idx_fleet_inventory_user", columns: ["userId"] }],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const tables = [fleets, fleetMembers, fleetInventory];
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getErrorMessage } from "@/lib/error-message.js";
|
import { getErrorMessage, cn } from "./helpers.js";
|
||||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
|
import { Input, Button } from "@termix/plugin-sdk/ui";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -11,16 +12,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Server,
|
Server,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Input } from "@/components/input";
|
import type { FleetsApi, FleetRow, FleetInventoryEntry } from "./fleets-api.js";
|
||||||
import { Button } from "@/components/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
listFleets,
|
|
||||||
getFleetInventory,
|
|
||||||
refreshFleetInventory,
|
|
||||||
type FleetRow,
|
|
||||||
type FleetInventoryEntry,
|
|
||||||
} from "./fleets-api";
|
|
||||||
|
|
||||||
type SortKey =
|
type SortKey =
|
||||||
| "hostName"
|
| "hostName"
|
||||||
@@ -90,9 +82,11 @@ function SortHeader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FleetInventoryTab({
|
export function FleetInventoryTab({
|
||||||
|
api,
|
||||||
fleetId,
|
fleetId,
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
}: {
|
}: {
|
||||||
|
api: FleetsApi;
|
||||||
fleetId?: number;
|
fleetId?: number;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -113,10 +107,11 @@ export function FleetInventoryTab({
|
|||||||
}, [fleetId]);
|
}, [fleetId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listFleets()
|
api
|
||||||
|
.list()
|
||||||
.then(setFleets)
|
.then(setFleets)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, [api]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fleets.length) return;
|
if (!fleets.length) return;
|
||||||
@@ -129,7 +124,7 @@ export function FleetInventoryTab({
|
|||||||
if (selectedFleetId === undefined) return;
|
if (selectedFleetId === undefined) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getFleetInventory(selectedFleetId);
|
const data = await api.inventory(selectedFleetId);
|
||||||
setEntries(data);
|
setEntries(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error, "");
|
const message = getErrorMessage(error, "");
|
||||||
@@ -137,7 +132,7 @@ export function FleetInventoryTab({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedFleetId, t]);
|
}, [api, selectedFleetId, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) return;
|
if (!isVisible) return;
|
||||||
@@ -148,7 +143,7 @@ export function FleetInventoryTab({
|
|||||||
if (selectedFleetId === undefined) return;
|
if (selectedFleetId === undefined) return;
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
const { results } = await refreshFleetInventory(selectedFleetId);
|
const { results } = await api.refreshInventory(selectedFleetId);
|
||||||
const failed = results.filter((r) => !r.success).length;
|
const failed = results.filter((r) => !r.success).length;
|
||||||
if (failed > 0) {
|
if (failed > 0) {
|
||||||
toast.warning(
|
toast.warning(
|
||||||
|
|||||||
@@ -1,6 +1,33 @@
|
|||||||
import { getErrorMessage } from "@/lib/error-message.js";
|
import {
|
||||||
|
getErrorMessage,
|
||||||
|
extractSnippetInputs,
|
||||||
|
type SnippetInput,
|
||||||
|
} from "./helpers.js";
|
||||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import {
|
||||||
|
useTranslation,
|
||||||
|
useHosts,
|
||||||
|
type PluginHostRecord,
|
||||||
|
} from "@termix/plugin-sdk/frontend";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Textarea,
|
||||||
|
Checkbox,
|
||||||
|
Badge,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
FOLDER_COLORS,
|
||||||
|
Select2,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@termix/plugin-sdk/ui";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
@@ -23,61 +50,19 @@ import {
|
|||||||
User,
|
User,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/button";
|
import type {
|
||||||
import { Input } from "@/components/input";
|
FleetsApi,
|
||||||
import { Textarea } from "@/components/textarea";
|
FleetRow,
|
||||||
import { Checkbox } from "@/components/checkbox";
|
FleetMemberRow,
|
||||||
import { Badge } from "@/components/badge";
|
FleetHostResult,
|
||||||
import {
|
FleetInventoryEntry,
|
||||||
Dialog,
|
FleetPackageAction,
|
||||||
DialogContent,
|
FleetShareHostResult,
|
||||||
DialogFooter,
|
ShareableUser,
|
||||||
DialogHeader,
|
ShareableRole,
|
||||||
DialogTitle,
|
SharePermissionLevel,
|
||||||
DialogDescription,
|
ShareTarget,
|
||||||
} from "@/components/dialog";
|
} from "./fleets-api.js";
|
||||||
import { FOLDER_COLORS } from "@/lib/theme";
|
|
||||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
|
||||||
import { getUserList } from "@/api/user-management-api";
|
|
||||||
import {
|
|
||||||
getRoles,
|
|
||||||
type SharePermissionLevel,
|
|
||||||
type ShareTarget,
|
|
||||||
} from "@/api/rbac-api";
|
|
||||||
import { Select2 } from "@/components/select2";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/dropdown-menu";
|
|
||||||
import {
|
|
||||||
extractSnippetInputs,
|
|
||||||
type SnippetInput,
|
|
||||||
} from "@/lib/snippet-variables";
|
|
||||||
import {
|
|
||||||
listFleets,
|
|
||||||
createFleet,
|
|
||||||
updateFleet,
|
|
||||||
deleteFleet,
|
|
||||||
getFleetMembers,
|
|
||||||
addFleetMember,
|
|
||||||
removeFleetMember,
|
|
||||||
runFleetCommand,
|
|
||||||
pushFleetFile,
|
|
||||||
pullFleetFile,
|
|
||||||
getFleetInventory,
|
|
||||||
refreshFleetInventory,
|
|
||||||
runFleetPackageAction,
|
|
||||||
shareFleet,
|
|
||||||
type FleetRow,
|
|
||||||
type FleetMemberRow,
|
|
||||||
type FleetHostResult,
|
|
||||||
type FleetInventoryEntry,
|
|
||||||
type FleetPackageAction,
|
|
||||||
type FleetShareHostResult,
|
|
||||||
} from "./fleets-api";
|
|
||||||
import type { SSHHost } from "@/types/index";
|
|
||||||
|
|
||||||
const SHARE_PERMISSION_LEVELS: SharePermissionLevel[] = [
|
const SHARE_PERMISSION_LEVELS: SharePermissionLevel[] = [
|
||||||
"connect",
|
"connect",
|
||||||
@@ -144,11 +129,13 @@ function ResultsList({ results }: { results: FleetHostResult[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FleetFormDialog({
|
function FleetFormDialog({
|
||||||
|
api,
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
fleet,
|
fleet,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
|
api: FleetsApi;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
fleet: FleetRow | null;
|
fleet: FleetRow | null;
|
||||||
@@ -182,7 +169,7 @@ function FleetFormDialog({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (fleet) {
|
if (fleet) {
|
||||||
await updateFleet(fleet.id, {
|
await api.update(fleet.id, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || null,
|
description: description.trim() || null,
|
||||||
color,
|
color,
|
||||||
@@ -190,7 +177,7 @@ function FleetFormDialog({
|
|||||||
});
|
});
|
||||||
toast.success(t("newUi.sidebar.fleets.fleetUpdated"));
|
toast.success(t("newUi.sidebar.fleets.fleetUpdated"));
|
||||||
} else {
|
} else {
|
||||||
await createFleet({
|
await api.create({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || null,
|
description: description.trim() || null,
|
||||||
color,
|
color,
|
||||||
@@ -304,6 +291,7 @@ function FleetFormDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MemberPickerDialog({
|
function MemberPickerDialog({
|
||||||
|
api,
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
fleetId,
|
fleetId,
|
||||||
@@ -311,10 +299,11 @@ function MemberPickerDialog({
|
|||||||
memberIds,
|
memberIds,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
|
api: FleetsApi;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
fleetId: number;
|
fleetId: number;
|
||||||
allHosts: SSHHost[];
|
allHosts: PluginHostRecord[];
|
||||||
memberIds: Set<number>;
|
memberIds: Set<number>;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -330,13 +319,14 @@ function MemberPickerDialog({
|
|||||||
[allHosts, search],
|
[allHosts, search],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function toggleHost(host: SSHHost, isMember: boolean) {
|
async function toggleHost(host: PluginHostRecord, isMember: boolean) {
|
||||||
setBusyId(host.id);
|
const hostId = Number(host.id);
|
||||||
|
setBusyId(hostId);
|
||||||
try {
|
try {
|
||||||
if (isMember) {
|
if (isMember) {
|
||||||
await removeFleetMember(fleetId, host.id);
|
await api.removeMember(fleetId, hostId);
|
||||||
} else {
|
} else {
|
||||||
await addFleetMember(fleetId, host.id);
|
await api.addMember(fleetId, hostId);
|
||||||
}
|
}
|
||||||
onChanged();
|
onChanged();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -371,7 +361,8 @@ function MemberPickerDialog({
|
|||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
|
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
|
||||||
{visibleHosts.map((host) => {
|
{visibleHosts.map((host) => {
|
||||||
const isMember = memberIds.has(host.id);
|
const hostId = Number(host.id);
|
||||||
|
const isMember = memberIds.has(hostId);
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={host.id}
|
key={host.id}
|
||||||
@@ -379,7 +370,7 @@ function MemberPickerDialog({
|
|||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isMember}
|
checked={isMember}
|
||||||
disabled={busyId === host.id}
|
disabled={busyId === hostId}
|
||||||
onCheckedChange={() => toggleHost(host, isMember)}
|
onCheckedChange={() => toggleHost(host, isMember)}
|
||||||
/>
|
/>
|
||||||
<span className="truncate flex-1">{host.name}</span>
|
<span className="truncate flex-1">{host.name}</span>
|
||||||
@@ -411,10 +402,12 @@ function MemberPickerDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FleetShareDialog({
|
function FleetShareDialog({
|
||||||
|
api,
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
fleet,
|
fleet,
|
||||||
}: {
|
}: {
|
||||||
|
api: FleetsApi;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
fleet: FleetRow | null;
|
fleet: FleetRow | null;
|
||||||
@@ -422,12 +415,8 @@ function FleetShareDialog({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
|
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [shareUsers, setShareUsers] = useState<
|
const [shareUsers, setShareUsers] = useState<ShareableUser[]>([]);
|
||||||
{ id: string; username: string }[]
|
const [shareRoles, setShareRoles] = useState<ShareableRole[]>([]);
|
||||||
>([]);
|
|
||||||
const [shareRoles, setShareRoles] = useState<
|
|
||||||
{ id: number; name: string; displayName?: string }[]
|
|
||||||
>([]);
|
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
|
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
@@ -457,26 +446,13 @@ function FleetShareDialog({
|
|||||||
setTargetTab("user");
|
setTargetTab("user");
|
||||||
setSummary(null);
|
setSummary(null);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
getUserList().catch(() => ({ users: [] })),
|
api.shareTargetUsers().catch(() => ({ users: [] })),
|
||||||
getRoles().catch(() => ({ roles: [] })),
|
api.shareTargetRoles().catch(() => ({ roles: [] })),
|
||||||
]).then(([usersRes, rolesRes]) => {
|
]).then(([usersRes, rolesRes]) => {
|
||||||
setShareUsers(
|
setShareUsers(usersRes.users ?? []);
|
||||||
(usersRes.users ?? []).map((u) => ({
|
setShareRoles(rolesRes.roles ?? []);
|
||||||
id: String(u.userId),
|
|
||||||
username: u.username,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
setShareRoles(
|
|
||||||
(rolesRes.roles ?? [])
|
|
||||||
.filter((r) => !r.isSystem)
|
|
||||||
.map((r) => ({
|
|
||||||
id: Number(r.id),
|
|
||||||
name: r.name,
|
|
||||||
displayName: r.displayName,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}, [open]);
|
}, [open, api]);
|
||||||
|
|
||||||
const filteredUsers = useMemo(() => {
|
const filteredUsers = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
@@ -523,7 +499,7 @@ function FleetShareDialog({
|
|||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const result = await shareFleet(fleet.id, {
|
const result = await api.share(fleet.id, {
|
||||||
targets,
|
targets,
|
||||||
permissionLevel,
|
permissionLevel,
|
||||||
...(durationHours ? { durationHours } : {}),
|
...(durationHours ? { durationHours } : {}),
|
||||||
@@ -808,7 +784,7 @@ function FleetShareDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RunCommandTab({ fleetId }: { fleetId: number }) {
|
function RunCommandTab({ api, fleetId }: { api: FleetsApi; fleetId: number }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [command, setCommand] = useState("");
|
const [command, setCommand] = useState("");
|
||||||
const [inputs, setInputs] = useState<SnippetInput[]>([]);
|
const [inputs, setInputs] = useState<SnippetInput[]>([]);
|
||||||
@@ -828,7 +804,7 @@ function RunCommandTab({ fleetId }: { fleetId: number }) {
|
|||||||
setRunning(true);
|
setRunning(true);
|
||||||
setResults(null);
|
setResults(null);
|
||||||
try {
|
try {
|
||||||
const { results: r } = await runFleetCommand(
|
const { results: r } = await api.runCommand(
|
||||||
fleetId,
|
fleetId,
|
||||||
command,
|
command,
|
||||||
inputs.length > 0 ? inputValues : undefined,
|
inputs.length > 0 ? inputValues : undefined,
|
||||||
@@ -901,7 +877,7 @@ function RunCommandTab({ fleetId }: { fleetId: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TransferTab({ fleetId }: { fleetId: number }) {
|
function TransferTab({ api, fleetId }: { api: FleetsApi; fleetId: number }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [direction, setDirection] = useState<"push" | "pull">("push");
|
const [direction, setDirection] = useState<"push" | "pull">("push");
|
||||||
const [remotePath, setRemotePath] = useState("");
|
const [remotePath, setRemotePath] = useState("");
|
||||||
@@ -923,14 +899,14 @@ function TransferTab({ fleetId }: { fleetId: number }) {
|
|||||||
setResults(null);
|
setResults(null);
|
||||||
try {
|
try {
|
||||||
if (direction === "push" && file) {
|
if (direction === "push" && file) {
|
||||||
const { results: r } = await pushFleetFile(fleetId, file, remotePath);
|
const { results: r } = await api.pushFile(fleetId, file, remotePath);
|
||||||
setResults(r);
|
setResults(r);
|
||||||
} else {
|
} else {
|
||||||
const {
|
const {
|
||||||
results: r,
|
results: r,
|
||||||
blob,
|
blob,
|
||||||
fileName,
|
fileName,
|
||||||
} = await pullFleetFile(fleetId, remotePath);
|
} = await api.pullFile(fleetId, remotePath);
|
||||||
setResults(r);
|
setResults(r);
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -1018,7 +994,7 @@ function TransferTab({ fleetId }: { fleetId: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InventoryTab({ fleetId }: { fleetId: number }) {
|
function InventoryTab({ api, fleetId }: { api: FleetsApi; fleetId: number }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [entries, setEntries] = useState<FleetInventoryEntry[]>([]);
|
const [entries, setEntries] = useState<FleetInventoryEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -1027,7 +1003,7 @@ function InventoryTab({ fleetId }: { fleetId: number }) {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getFleetInventory(fleetId);
|
const data = await api.inventory(fleetId);
|
||||||
setEntries(data);
|
setEntries(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error, "");
|
const message = getErrorMessage(error, "");
|
||||||
@@ -1035,7 +1011,7 @@ function InventoryTab({ fleetId }: { fleetId: number }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [fleetId, t]);
|
}, [api, fleetId, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -1044,7 +1020,7 @@ function InventoryTab({ fleetId }: { fleetId: number }) {
|
|||||||
async function handleRefresh() {
|
async function handleRefresh() {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
const { results } = await refreshFleetInventory(fleetId);
|
const { results } = await api.refreshInventory(fleetId);
|
||||||
const failed = results.filter((r) => !r.success).length;
|
const failed = results.filter((r) => !r.success).length;
|
||||||
if (failed > 0) {
|
if (failed > 0) {
|
||||||
toast.warning(
|
toast.warning(
|
||||||
@@ -1137,7 +1113,7 @@ function InventoryTab({ fleetId }: { fleetId: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PackagesTab({ fleetId }: { fleetId: number }) {
|
function PackagesTab({ api, fleetId }: { api: FleetsApi; fleetId: number }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [action, setAction] = useState<FleetPackageAction>("install");
|
const [action, setAction] = useState<FleetPackageAction>("install");
|
||||||
const [packageName, setPackageName] = useState("");
|
const [packageName, setPackageName] = useState("");
|
||||||
@@ -1152,7 +1128,7 @@ function PackagesTab({ fleetId }: { fleetId: number }) {
|
|||||||
setRunning(true);
|
setRunning(true);
|
||||||
setResults(null);
|
setResults(null);
|
||||||
try {
|
try {
|
||||||
const { results: r } = await runFleetPackageAction(
|
const { results: r } = await api.runPackageAction(
|
||||||
fleetId,
|
fleetId,
|
||||||
action,
|
action,
|
||||||
action === "upgrade-all" ? undefined : packageName.trim(),
|
action === "upgrade-all" ? undefined : packageName.trim(),
|
||||||
@@ -1240,14 +1216,16 @@ function PackagesTab({ fleetId }: { fleetId: number }) {
|
|||||||
type FleetActionView = "run" | "transfer" | "inventory" | "packages";
|
type FleetActionView = "run" | "transfer" | "inventory" | "packages";
|
||||||
|
|
||||||
function FleetDetail({
|
function FleetDetail({
|
||||||
|
api,
|
||||||
fleet,
|
fleet,
|
||||||
allHosts,
|
allHosts,
|
||||||
onBack,
|
onBack,
|
||||||
onFleetChanged,
|
onFleetChanged,
|
||||||
onOpenFleetInventory,
|
onOpenFleetInventory,
|
||||||
}: {
|
}: {
|
||||||
|
api: FleetsApi;
|
||||||
fleet: FleetRow;
|
fleet: FleetRow;
|
||||||
allHosts: SSHHost[];
|
allHosts: PluginHostRecord[];
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onFleetChanged: () => void;
|
onFleetChanged: () => void;
|
||||||
onOpenFleetInventory?: (fleetId: number) => void;
|
onOpenFleetInventory?: (fleetId: number) => void;
|
||||||
@@ -1262,7 +1240,7 @@ function FleetDetail({
|
|||||||
const loadMembers = useCallback(async () => {
|
const loadMembers = useCallback(async () => {
|
||||||
setLoadingMembers(true);
|
setLoadingMembers(true);
|
||||||
try {
|
try {
|
||||||
const data = await getFleetMembers(fleet.id);
|
const data = await api.members(fleet.id);
|
||||||
setMembers(data);
|
setMembers(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error, "");
|
const message = getErrorMessage(error, "");
|
||||||
@@ -1270,7 +1248,7 @@ function FleetDetail({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingMembers(false);
|
setLoadingMembers(false);
|
||||||
}
|
}
|
||||||
}, [fleet.id, t]);
|
}, [api, fleet.id, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMembers();
|
loadMembers();
|
||||||
@@ -1354,15 +1332,24 @@ function FleetDetail({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actionView === "run" && <RunCommandTab fleetId={fleet.id} />}
|
{actionView === "run" && (
|
||||||
{actionView === "transfer" && <TransferTab fleetId={fleet.id} />}
|
<RunCommandTab api={api} fleetId={fleet.id} />
|
||||||
{actionView === "inventory" && <InventoryTab fleetId={fleet.id} />}
|
)}
|
||||||
{actionView === "packages" && <PackagesTab fleetId={fleet.id} />}
|
{actionView === "transfer" && (
|
||||||
|
<TransferTab api={api} fleetId={fleet.id} />
|
||||||
|
)}
|
||||||
|
{actionView === "inventory" && (
|
||||||
|
<InventoryTab api={api} fleetId={fleet.id} />
|
||||||
|
)}
|
||||||
|
{actionView === "packages" && (
|
||||||
|
<PackagesTab api={api} fleetId={fleet.id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MemberPickerDialog
|
<MemberPickerDialog
|
||||||
|
api={api}
|
||||||
open={pickerOpen}
|
open={pickerOpen}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
fleetId={fleet.id}
|
fleetId={fleet.id}
|
||||||
@@ -1372,6 +1359,7 @@ function FleetDetail({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FleetShareDialog
|
<FleetShareDialog
|
||||||
|
api={api}
|
||||||
open={shareOpen}
|
open={shareOpen}
|
||||||
onClose={() => setShareOpen(false)}
|
onClose={() => setShareOpen(false)}
|
||||||
fleet={fleet}
|
fleet={fleet}
|
||||||
@@ -1381,15 +1369,17 @@ function FleetDetail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FleetsPanel({
|
export function FleetsPanel({
|
||||||
|
api,
|
||||||
active,
|
active,
|
||||||
onOpenFleetInventory,
|
onOpenFleetInventory,
|
||||||
}: {
|
}: {
|
||||||
|
api: FleetsApi;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
onOpenFleetInventory?: (fleetId: number) => void;
|
onOpenFleetInventory?: (fleetId: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { hosts: allHosts } = useHosts();
|
||||||
const [fleets, setFleets] = useState<FleetRow[]>([]);
|
const [fleets, setFleets] = useState<FleetRow[]>([]);
|
||||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selectedFleet, setSelectedFleet] = useState<FleetRow | null>(null);
|
const [selectedFleet, setSelectedFleet] = useState<FleetRow | null>(null);
|
||||||
@@ -1401,7 +1391,7 @@ export function FleetsPanel({
|
|||||||
|
|
||||||
const loadFleets = useCallback(async () => {
|
const loadFleets = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await listFleets();
|
const data = await api.list();
|
||||||
setFleets(data);
|
setFleets(data);
|
||||||
setSelectedFleet((prev) =>
|
setSelectedFleet((prev) =>
|
||||||
prev ? (data.find((f) => f.id === prev.id) ?? null) : null,
|
prev ? (data.find((f) => f.id === prev.id) ?? null) : null,
|
||||||
@@ -1410,28 +1400,24 @@ export function FleetsPanel({
|
|||||||
const message = getErrorMessage(error, "");
|
const message = getErrorMessage(error, "");
|
||||||
toast.error(message || t("newUi.sidebar.fleets.loadFailed"));
|
toast.error(message || t("newUi.sidebar.fleets.loadFailed"));
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, [api, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active || hasLoadedRef.current) return;
|
if (!active || hasLoadedRef.current) return;
|
||||||
hasLoadedRef.current = true;
|
hasLoadedRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([listFleets(), getSSHHosts({ includeStatus: false })])
|
loadFleets()
|
||||||
.then(([fleetData, hosts]) => {
|
|
||||||
setFleets(fleetData);
|
|
||||||
setAllHosts(hosts as unknown as SSHHost[]);
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
const message = getErrorMessage(error, "");
|
const message = getErrorMessage(error, "");
|
||||||
toast.error(message || t("newUi.sidebar.fleets.loadFailed"));
|
toast.error(message || t("newUi.sidebar.fleets.loadFailed"));
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [active, hasLoadedRef, t]);
|
}, [active, hasLoadedRef, loadFleets, t]);
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
try {
|
try {
|
||||||
await deleteFleet(deleteTarget.id);
|
await api.remove(deleteTarget.id);
|
||||||
toast.success(t("newUi.sidebar.fleets.fleetDeleted"));
|
toast.success(t("newUi.sidebar.fleets.fleetDeleted"));
|
||||||
if (selectedFleet?.id === deleteTarget.id) setSelectedFleet(null);
|
if (selectedFleet?.id === deleteTarget.id) setSelectedFleet(null);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
@@ -1449,6 +1435,7 @@ export function FleetsPanel({
|
|||||||
if (selectedFleet) {
|
if (selectedFleet) {
|
||||||
return (
|
return (
|
||||||
<FleetDetail
|
<FleetDetail
|
||||||
|
api={api}
|
||||||
fleet={selectedFleet}
|
fleet={selectedFleet}
|
||||||
allHosts={allHosts}
|
allHosts={allHosts}
|
||||||
onBack={() => setSelectedFleet(null)}
|
onBack={() => setSelectedFleet(null)}
|
||||||
@@ -1578,6 +1565,7 @@ export function FleetsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FleetFormDialog
|
<FleetFormDialog
|
||||||
|
api={api}
|
||||||
open={formOpen}
|
open={formOpen}
|
||||||
onClose={() => setFormOpen(false)}
|
onClose={() => setFormOpen(false)}
|
||||||
fleet={editingFleet}
|
fleet={editingFleet}
|
||||||
@@ -1585,6 +1573,7 @@ export function FleetsPanel({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FleetShareDialog
|
<FleetShareDialog
|
||||||
|
api={api}
|
||||||
open={!!shareTarget}
|
open={!!shareTarget}
|
||||||
onClose={() => setShareTarget(null)}
|
onClose={() => setShareTarget(null)}
|
||||||
fleet={shareTarget}
|
fleet={shareTarget}
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
import {
|
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||||
authApi,
|
|
||||||
handleApiError,
|
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
|
||||||
type SharePermissionLevel,
|
|
||||||
type ShareTarget,
|
export interface ShareTarget {
|
||||||
} from "@/main-axios";
|
type: "user" | "role";
|
||||||
|
id: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareableUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareableRole {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
displayName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FleetRow {
|
export interface FleetRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -25,7 +38,7 @@ export interface FleetMemberRow {
|
|||||||
ip: string;
|
ip: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
static: boolean;
|
static: boolean;
|
||||||
permissionLevel: "connect" | "view" | "edit" | "manage" | null;
|
permissionLevel: SharePermissionLevel | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetHostResult {
|
export interface FleetHostResult {
|
||||||
@@ -58,243 +71,145 @@ export interface FleetInventoryEntry {
|
|||||||
|
|
||||||
export type FleetPackageAction = "install" | "remove" | "upgrade-all";
|
export type FleetPackageAction = "install" | "remove" | "upgrade-all";
|
||||||
|
|
||||||
export async function listFleets(): Promise<FleetRow[]> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.get("/plugin-api/fleets");
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "fetch fleets");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createFleet(fleetData: {
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
color?: string | null;
|
|
||||||
icon?: string | null;
|
|
||||||
tagRules?: string[];
|
|
||||||
}): Promise<FleetRow> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.post("/plugin-api/fleets", fleetData);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "create fleet");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateFleet(
|
|
||||||
fleetId: number,
|
|
||||||
fleetData: {
|
|
||||||
name?: string;
|
|
||||||
description?: string | null;
|
|
||||||
color?: string | null;
|
|
||||||
icon?: string | null;
|
|
||||||
tagRules?: string[];
|
|
||||||
},
|
|
||||||
): Promise<FleetRow> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.patch(
|
|
||||||
`/plugin-api/fleets/${fleetId}`,
|
|
||||||
fleetData,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "update fleet");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteFleet(
|
|
||||||
fleetId: number,
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.delete(`/plugin-api/fleets/${fleetId}`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "delete fleet");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFleetMembers(
|
|
||||||
fleetId: number,
|
|
||||||
): Promise<FleetMemberRow[]> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.get(`/plugin-api/fleets/${fleetId}/members`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "fetch fleet members");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addFleetMember(
|
|
||||||
fleetId: number,
|
|
||||||
hostId: number,
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.post(
|
|
||||||
`/plugin-api/fleets/${fleetId}/members`,
|
|
||||||
{
|
|
||||||
hostId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "add fleet member");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeFleetMember(
|
|
||||||
fleetId: number,
|
|
||||||
hostId: number,
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.delete(
|
|
||||||
`/plugin-api/fleets/${fleetId}/members/${hostId}`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "remove fleet member");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runFleetCommand(
|
|
||||||
fleetId: number,
|
|
||||||
command: string,
|
|
||||||
inputValues?: Record<string, string>,
|
|
||||||
): Promise<{ results: FleetHostResult[] }> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.post(
|
|
||||||
`/plugin-api/fleets/${fleetId}/execute`,
|
|
||||||
{
|
|
||||||
command,
|
|
||||||
...(inputValues ? { inputValues } : {}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "run fleet command");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pushFleetFile(
|
|
||||||
fleetId: number,
|
|
||||||
file: File,
|
|
||||||
remotePath: string,
|
|
||||||
): Promise<{ results: FleetHostResult[] }> {
|
|
||||||
try {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append("file", file);
|
|
||||||
form.append("remotePath", remotePath);
|
|
||||||
const response = await authApi.post(
|
|
||||||
`/plugin-api/fleets/${fleetId}/transfer/push`,
|
|
||||||
form,
|
|
||||||
{ headers: { "Content-Type": "multipart/form-data" } },
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "push file to fleet");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pullFleetFile(
|
|
||||||
fleetId: number,
|
|
||||||
remotePath: string,
|
|
||||||
): Promise<{ results: FleetHostResult[]; blob: Blob; fileName: string }> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.post(
|
|
||||||
`/plugin-api/fleets/${fleetId}/transfer/pull`,
|
|
||||||
{ remotePath },
|
|
||||||
{ responseType: "blob" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const resultsHeader = response.headers["x-fleet-transfer-results"];
|
|
||||||
const results: FleetHostResult[] = resultsHeader
|
|
||||||
? JSON.parse(atob(resultsHeader))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const disposition = response.headers["content-disposition"] as
|
|
||||||
string | undefined;
|
|
||||||
const match = disposition?.match(/filename="([^"]+)"/);
|
|
||||||
const fileName = match?.[1] ?? "fleet-transfer.zip";
|
|
||||||
|
|
||||||
return { results, blob: response.data, fileName };
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "pull file from fleet");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFleetInventory(
|
|
||||||
fleetId: number,
|
|
||||||
): Promise<FleetInventoryEntry[]> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.get(
|
|
||||||
`/plugin-api/fleets/${fleetId}/inventory`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "fetch fleet inventory");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refreshFleetInventory(
|
|
||||||
fleetId: number,
|
|
||||||
): Promise<{ results: FleetHostResult[] }> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.post(
|
|
||||||
`/plugin-api/fleets/${fleetId}/inventory`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "refresh fleet inventory");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runFleetPackageAction(
|
|
||||||
fleetId: number,
|
|
||||||
action: FleetPackageAction,
|
|
||||||
packageName?: string,
|
|
||||||
): Promise<{ results: FleetHostResult[] }> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.post(
|
|
||||||
`/plugin-api/fleets/${fleetId}/packages`,
|
|
||||||
{
|
|
||||||
action,
|
|
||||||
...(packageName ? { package: packageName } : {}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "run fleet package action");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FleetShareHostResult {
|
export interface FleetShareHostResult {
|
||||||
hostId: number;
|
hostId: number;
|
||||||
shared: boolean;
|
shared: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function shareFleet(
|
export interface FleetShareResult {
|
||||||
fleetId: number,
|
|
||||||
shareData: {
|
|
||||||
targets: ShareTarget[];
|
|
||||||
permissionLevel: SharePermissionLevel;
|
|
||||||
durationHours?: number;
|
|
||||||
},
|
|
||||||
): Promise<{
|
|
||||||
success: boolean;
|
success: boolean;
|
||||||
permissionLevel: SharePermissionLevel;
|
permissionLevel: SharePermissionLevel;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
hostsShared: number;
|
hostsShared: number;
|
||||||
hostsTotal: number;
|
hostsTotal: number;
|
||||||
hostResults: FleetShareHostResult[];
|
hostResults: FleetShareHostResult[];
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.post(
|
|
||||||
`/plugin-api/fleets/${fleetId}/share`,
|
|
||||||
shareData,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleApiError(error, "share fleet");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fleets routes, through the plugin's own client. Paths are relative to
|
||||||
|
* /plugin-api/fleets/, which the client already points at.
|
||||||
|
*/
|
||||||
|
export function createFleetsApi(api: PluginApiClient) {
|
||||||
|
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: () => data(api.get<FleetRow[]>("/")),
|
||||||
|
|
||||||
|
create: (fleetData: {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
tagRules?: string[];
|
||||||
|
}) => data(api.post<FleetRow>("/", fleetData)),
|
||||||
|
|
||||||
|
update: (
|
||||||
|
fleetId: number,
|
||||||
|
fleetData: {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
tagRules?: string[];
|
||||||
|
},
|
||||||
|
) => data(api.patch<FleetRow>(`/${fleetId}`, fleetData)),
|
||||||
|
|
||||||
|
remove: (fleetId: number) =>
|
||||||
|
data(api.delete<{ success: boolean }>(`/${fleetId}`)),
|
||||||
|
|
||||||
|
members: (fleetId: number) =>
|
||||||
|
data(api.get<FleetMemberRow[]>(`/${fleetId}/members`)),
|
||||||
|
|
||||||
|
addMember: (fleetId: number, hostId: number) =>
|
||||||
|
data(api.post<{ success: boolean }>(`/${fleetId}/members`, { hostId })),
|
||||||
|
|
||||||
|
removeMember: (fleetId: number, hostId: number) =>
|
||||||
|
data(api.delete<{ success: boolean }>(`/${fleetId}/members/${hostId}`)),
|
||||||
|
|
||||||
|
runCommand: (
|
||||||
|
fleetId: number,
|
||||||
|
command: string,
|
||||||
|
inputValues?: Record<string, string>,
|
||||||
|
) =>
|
||||||
|
data(
|
||||||
|
api.post<{ results: FleetHostResult[] }>(`/${fleetId}/execute`, {
|
||||||
|
command,
|
||||||
|
...(inputValues ? { inputValues } : {}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
pushFile: async (fleetId: number, file: File, remotePath: string) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
form.append("remotePath", remotePath);
|
||||||
|
return data(
|
||||||
|
api.post<{ results: FleetHostResult[] }>(
|
||||||
|
`/${fleetId}/transfer/push`,
|
||||||
|
form,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
pullFile: async (
|
||||||
|
fleetId: number,
|
||||||
|
remotePath: string,
|
||||||
|
): Promise<{
|
||||||
|
results: FleetHostResult[];
|
||||||
|
blob: Blob;
|
||||||
|
fileName: string;
|
||||||
|
}> => {
|
||||||
|
const response = await api.post<Blob>(
|
||||||
|
`/${fleetId}/transfer/pull`,
|
||||||
|
{ remotePath },
|
||||||
|
{ responseType: "blob" },
|
||||||
|
);
|
||||||
|
const headers = (response as { headers?: Record<string, string> })
|
||||||
|
.headers;
|
||||||
|
const resultsHeader = headers?.["x-fleet-transfer-results"];
|
||||||
|
const results: FleetHostResult[] = resultsHeader
|
||||||
|
? JSON.parse(atob(resultsHeader))
|
||||||
|
: [];
|
||||||
|
const disposition = headers?.["content-disposition"];
|
||||||
|
const match = disposition?.match(/filename="([^"]+)"/);
|
||||||
|
const fileName = match?.[1] ?? "fleet-transfer.zip";
|
||||||
|
return { results, blob: response.data, fileName };
|
||||||
|
},
|
||||||
|
|
||||||
|
inventory: (fleetId: number) =>
|
||||||
|
data(api.get<FleetInventoryEntry[]>(`/${fleetId}/inventory`)),
|
||||||
|
|
||||||
|
refreshInventory: (fleetId: number) =>
|
||||||
|
data(api.post<{ results: FleetHostResult[] }>(`/${fleetId}/inventory`)),
|
||||||
|
|
||||||
|
runPackageAction: (
|
||||||
|
fleetId: number,
|
||||||
|
action: FleetPackageAction,
|
||||||
|
packageName?: string,
|
||||||
|
) =>
|
||||||
|
data(
|
||||||
|
api.post<{ results: FleetHostResult[] }>(`/${fleetId}/packages`, {
|
||||||
|
action,
|
||||||
|
...(packageName ? { package: packageName } : {}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
share: (
|
||||||
|
fleetId: number,
|
||||||
|
shareData: {
|
||||||
|
targets: ShareTarget[];
|
||||||
|
permissionLevel: SharePermissionLevel;
|
||||||
|
durationHours?: number;
|
||||||
|
},
|
||||||
|
) => data(api.post<FleetShareResult>(`/${fleetId}/share`, shareData)),
|
||||||
|
|
||||||
|
shareTargetUsers: () =>
|
||||||
|
data(api.get<{ users: ShareableUser[] }>("/share-targets/users")),
|
||||||
|
|
||||||
|
shareTargetRoles: () =>
|
||||||
|
data(api.get<{ roles: ShareableRole[] }>("/share-targets/roles")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FleetsApi = ReturnType<typeof createFleetsApi>;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a stable, human-readable message from an unknown thrown value.
|
||||||
|
* Mirrors src/ui/lib/error-message.ts - kept as a small standalone copy
|
||||||
|
* rather than a cross-plugin dependency for one pure function.
|
||||||
|
*/
|
||||||
|
export function getErrorMessage(
|
||||||
|
error: unknown,
|
||||||
|
fallback = "Unknown error",
|
||||||
|
): string {
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnippetInput {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INPUT_PATTERN =
|
||||||
|
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the $INPUT_n placeholders in a command, for the fleet run box's
|
||||||
|
* input fields. Mirrors src/ui/lib/snippet-variables.ts extractSnippetInputs.
|
||||||
|
*/
|
||||||
|
export function extractSnippetInputs(content: string): SnippetInput[] {
|
||||||
|
const seen = new Map<string, SnippetInput>();
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
INPUT_PATTERN.lastIndex = 0;
|
||||||
|
while ((match = INPUT_PATTERN.exec(content)) !== null) {
|
||||||
|
const digits = match[1] ?? match[3];
|
||||||
|
const key = `INPUT_${digits}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.set(key, { key, label: match[2]?.trim() || `Input ${digits}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(seen.values());
|
||||||
|
}
|
||||||
@@ -4,34 +4,38 @@ import type {
|
|||||||
TabProps,
|
TabProps,
|
||||||
TermixApp,
|
TermixApp,
|
||||||
} from "@termix/plugin-sdk/frontend";
|
} from "@termix/plugin-sdk/frontend";
|
||||||
import { FleetsPanel } from "./FleetsPanel";
|
import { FleetsPanel } from "./FleetsPanel.js";
|
||||||
import { FleetInventoryTab } from "./FleetInventoryTab";
|
import { FleetInventoryTab } from "./FleetInventoryTab.js";
|
||||||
import { listFleets } from "./fleets-api";
|
import { createFleetsApi } from "./fleets-api.js";
|
||||||
|
|
||||||
const INVENTORY_TAB = "fleet-inventory";
|
const INVENTORY_TAB = "fleet-inventory";
|
||||||
|
|
||||||
function Panel({ active, shell }: PanelProps) {
|
|
||||||
return (
|
|
||||||
<FleetsPanel
|
|
||||||
active={active}
|
|
||||||
onOpenFleetInventory={(fleetId) =>
|
|
||||||
shell.openSingletonTab(INVENTORY_TAB, { data: { fleetId } })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InventoryTab({ tab, isVisible }: TabProps) {
|
|
||||||
const fleetId = tab.data?.fleetId;
|
|
||||||
return (
|
|
||||||
<FleetInventoryTab
|
|
||||||
fleetId={typeof fleetId === "number" ? fleetId : undefined}
|
|
||||||
isVisible={isVisible}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activate(app: TermixApp): void {
|
export function activate(app: TermixApp): void {
|
||||||
|
const api = createFleetsApi(app.api);
|
||||||
|
|
||||||
|
function Panel({ active, shell }: PanelProps) {
|
||||||
|
return (
|
||||||
|
<FleetsPanel
|
||||||
|
api={api}
|
||||||
|
active={active}
|
||||||
|
onOpenFleetInventory={(fleetId) =>
|
||||||
|
shell.openSingletonTab(INVENTORY_TAB, { data: { fleetId } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryTab({ tab, isVisible }: TabProps) {
|
||||||
|
const fleetId = tab.data?.fleetId;
|
||||||
|
return (
|
||||||
|
<FleetInventoryTab
|
||||||
|
api={api}
|
||||||
|
fleetId={typeof fleetId === "number" ? fleetId : undefined}
|
||||||
|
isVisible={isVisible}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
app.registerRailItem({
|
app.registerRailItem({
|
||||||
id: "fleets",
|
id: "fleets",
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
@@ -51,5 +55,5 @@ export function activate(app: TermixApp): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Other plugins (automations' host picker) list fleets through this.
|
// Other plugins (automations' host picker) list fleets through this.
|
||||||
app.registerAction("fleets.list", () => listFleets());
|
app.registerAction("fleets.list", () => api.list());
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import express, { type Router } from "express";
|
||||||
|
import {
|
||||||
|
createMockCtx,
|
||||||
|
createTestDb,
|
||||||
|
type MockPluginContext,
|
||||||
|
type TestDb,
|
||||||
|
} from "@termix/plugin-sdk/testing";
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
import manifestJson from "../../manifest.json";
|
||||||
|
|
||||||
|
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||||
|
export const manifest = manifestJson as unknown as PluginManifest;
|
||||||
|
|
||||||
|
export interface TestServer {
|
||||||
|
db: TestDb;
|
||||||
|
mock: MockPluginContext;
|
||||||
|
request: (
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
options?: { user?: string; body?: unknown },
|
||||||
|
// Route bodies vary per test; asserting on them is the point.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
) => Promise<{ status: number; body: any }>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plugin activated against a real in-memory database, its router mounted
|
||||||
|
* on an express app the way core mounts it: JSON parsed, the acting user set
|
||||||
|
* from the request, everything else left to the plugin.
|
||||||
|
*/
|
||||||
|
export async function startServer(
|
||||||
|
options: {
|
||||||
|
permissions?: string[];
|
||||||
|
users?: string[];
|
||||||
|
hosts?: Array<{
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
name?: string;
|
||||||
|
ip?: string;
|
||||||
|
tags?: string | null;
|
||||||
|
}>;
|
||||||
|
} = {},
|
||||||
|
): Promise<TestServer> {
|
||||||
|
const hosts = options.hosts ?? [
|
||||||
|
{ id: 10, userId: "user-1", name: "host-a", ip: "10.0.0.1" },
|
||||||
|
{ id: 11, userId: "user-1", name: "host-b", ip: "10.0.0.2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const db = await createTestDb(pluginDir, {
|
||||||
|
before: (sqlite) => {
|
||||||
|
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||||
|
sqlite
|
||||||
|
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||||
|
.run(user, user);
|
||||||
|
}
|
||||||
|
// The plugin's fleet_members/fleet_inventory tables FK to ssh_data,
|
||||||
|
// which ctx.hosts (faked below) never touches - seed it directly so
|
||||||
|
// the constraint is satisfied the way it is against the real table.
|
||||||
|
for (const host of hosts) {
|
||||||
|
sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(host.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let router: Router | null = null;
|
||||||
|
const mock = createMockCtx({
|
||||||
|
pluginId: manifest.id,
|
||||||
|
manifest,
|
||||||
|
capabilities: manifest.capabilities,
|
||||||
|
db: db.database,
|
||||||
|
router: () => (router = express.Router()),
|
||||||
|
permissions: options.permissions ?? [
|
||||||
|
"fleets.view",
|
||||||
|
"fleets.manage",
|
||||||
|
"fleets.execute",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ctx.hosts is capability-checked separately from ctx.db; seed it with the
|
||||||
|
// same fixture hosts a real deployment would resolve through core.
|
||||||
|
mock.ctx.hosts.list = async () =>
|
||||||
|
hosts.map((h) => ({
|
||||||
|
id: h.id,
|
||||||
|
userId: h.userId,
|
||||||
|
name: h.name ?? null,
|
||||||
|
ip: h.ip ?? "",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
tags: h.tags ?? null,
|
||||||
|
folder: null,
|
||||||
|
authType: "password",
|
||||||
|
}));
|
||||||
|
mock.ctx.hosts.get = async (hostId) => {
|
||||||
|
const found = hosts.find((h) => h.id === hostId);
|
||||||
|
if (!found) return null;
|
||||||
|
return {
|
||||||
|
id: found.id,
|
||||||
|
userId: found.userId,
|
||||||
|
name: found.name ?? null,
|
||||||
|
ip: found.ip ?? "",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
tags: found.tags ?? null,
|
||||||
|
folder: null,
|
||||||
|
authType: "password",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
mock.ctx.hosts.checkAccess = async (hostId) => {
|
||||||
|
const found = hosts.find((h) => h.id === hostId);
|
||||||
|
if (!found) return { hasAccess: false, isOwner: false, isShared: false };
|
||||||
|
return {
|
||||||
|
hasAccess: true,
|
||||||
|
isOwner: true,
|
||||||
|
isShared: false,
|
||||||
|
permissionLevel: "manage",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { activate } = await import("../../src/backend/index.js");
|
||||||
|
await activate(mock.ctx);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use((req, res, next) => router!(req, res, next));
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
mock,
|
||||||
|
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||||
|
const body = method === "GET" ? undefined : given;
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"x-test-user": user,
|
||||||
|
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
db.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||||
|
import { pluginDir } from "./helpers";
|
||||||
|
|
||||||
|
// fleets/fleet_members/fleet_inventory as core's SQLite bootstrap created
|
||||||
|
// them before 2.9.0, plus the indexes performance-indexes.ts added.
|
||||||
|
const LEGACY_DDL = `
|
||||||
|
CREATE TABLE fleets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
color TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
tag_rules TEXT,
|
||||||
|
sync_id TEXT UNIQUE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE fleet_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
fleet_id INTEGER NOT NULL REFERENCES fleets(id) ON DELETE CASCADE,
|
||||||
|
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_fleet_members_fleet_host ON fleet_members(fleet_id, host_id);
|
||||||
|
CREATE INDEX idx_fleet_members_host ON fleet_members(host_id);
|
||||||
|
CREATE TABLE fleet_inventory (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
os_pretty_name TEXT,
|
||||||
|
kernel TEXT,
|
||||||
|
architecture TEXT,
|
||||||
|
hostname TEXT,
|
||||||
|
uptime_seconds INTEGER,
|
||||||
|
ip TEXT,
|
||||||
|
package_manager TEXT,
|
||||||
|
collected_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_fleet_inventory_host ON fleet_inventory(host_id, user_id);
|
||||||
|
CREATE INDEX idx_fleet_inventory_user ON fleet_inventory(user_id);
|
||||||
|
`;
|
||||||
|
|
||||||
|
let db: TestDb | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db?.close();
|
||||||
|
db = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableExists = (name: string) =>
|
||||||
|
!!db!.sqlite
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||||
|
.get(name);
|
||||||
|
|
||||||
|
const indexOn = (table: string) =>
|
||||||
|
(
|
||||||
|
db!.sqlite
|
||||||
|
.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
|
||||||
|
)
|
||||||
|
.all(table) as { name: string }[]
|
||||||
|
)
|
||||||
|
.map((row) => row.name)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
describe("adopting fleets, fleet_members, fleet_inventory", () => {
|
||||||
|
it("keeps every existing row when the legacy tables are there", async () => {
|
||||||
|
db = await createTestDb(pluginDir, {
|
||||||
|
before: (sqlite) => {
|
||||||
|
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||||
|
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
|
||||||
|
sqlite.exec(LEGACY_DDL);
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO fleets (user_id, name, tag_rules, sync_id) VALUES (?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.run("user-1", "Web fleet", '["prod-web"]', "sync-a");
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO fleet_members (fleet_id, host_id) VALUES (1, 1)",
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO fleet_inventory (host_id, user_id, os_pretty_name) VALUES (1, 'user-1', 'Debian')",
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.applied).toEqual(["0001_adopt_fleets"]);
|
||||||
|
expect(tableExists("fleets")).toBe(false);
|
||||||
|
expect(tableExists("fleet_members")).toBe(false);
|
||||||
|
expect(tableExists("fleet_inventory")).toBe(false);
|
||||||
|
expect(tableExists("p_fleets_fleets")).toBe(true);
|
||||||
|
expect(tableExists("p_fleets_members")).toBe(true);
|
||||||
|
expect(tableExists("p_fleets_inventory")).toBe(true);
|
||||||
|
|
||||||
|
const fleetRows = db.sqlite
|
||||||
|
.prepare("SELECT user_id, name, tag_rules, sync_id FROM p_fleets_fleets")
|
||||||
|
.all();
|
||||||
|
expect(fleetRows).toEqual([
|
||||||
|
{
|
||||||
|
user_id: "user-1",
|
||||||
|
name: "Web fleet",
|
||||||
|
tag_rules: '["prod-web"]',
|
||||||
|
sync_id: "sync-a",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const memberRows = db.sqlite
|
||||||
|
.prepare("SELECT fleet_id, host_id FROM p_fleets_members")
|
||||||
|
.all();
|
||||||
|
expect(memberRows).toEqual([{ fleet_id: 1, host_id: 1 }]);
|
||||||
|
|
||||||
|
const inventoryRows = db.sqlite
|
||||||
|
.prepare(
|
||||||
|
"SELECT host_id, user_id, os_pretty_name FROM p_fleets_inventory",
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
expect(inventoryRows).toEqual([
|
||||||
|
{ host_id: 1, user_id: "user-1", os_pretty_name: "Debian" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(indexOn("p_fleets_members")).toEqual(
|
||||||
|
["idx_fleet_members_fleet_host", "idx_fleet_members_host"].sort(),
|
||||||
|
);
|
||||||
|
expect(indexOn("p_fleets_inventory")).toEqual(
|
||||||
|
["idx_fleet_inventory_host", "idx_fleet_inventory_user"].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the tables fresh when there are no legacy ones", async () => {
|
||||||
|
db = await createTestDb(pluginDir);
|
||||||
|
|
||||||
|
expect(tableExists("fleets")).toBe(false);
|
||||||
|
expect(tableExists("p_fleets_fleets")).toBe(true);
|
||||||
|
expect(tableExists("p_fleets_members")).toBe(true);
|
||||||
|
expect(tableExists("p_fleets_inventory")).toBe(true);
|
||||||
|
expect(db.sqlite.prepare("SELECT * FROM p_fleets_fleets").all()).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cascades a fleet delete onto its member rows via the fleet_id FK", async () => {
|
||||||
|
db = await createTestDb(pluginDir, {
|
||||||
|
before: (sqlite) => {
|
||||||
|
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
|
||||||
|
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
db.sqlite
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO p_fleets_fleets (id, user_id, name) VALUES (1, 'user-1', 'Fleet')",
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
db.sqlite
|
||||||
|
.prepare("INSERT INTO p_fleets_members (fleet_id, host_id) VALUES (1, 1)")
|
||||||
|
.run();
|
||||||
|
|
||||||
|
db.sqlite.exec("DELETE FROM p_fleets_fleets WHERE id = 1");
|
||||||
|
|
||||||
|
expect(db.sqlite.prepare("SELECT * FROM p_fleets_members").all()).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a user's fleets when the user is deleted", async () => {
|
||||||
|
db = await createTestDb(pluginDir, {
|
||||||
|
before: (sqlite) => {
|
||||||
|
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const insert = db.sqlite.prepare(
|
||||||
|
"INSERT INTO p_fleets_fleets (user_id, name) VALUES (?, ?)",
|
||||||
|
);
|
||||||
|
insert.run("user-1", "Mine");
|
||||||
|
insert.run("user-2", "Theirs");
|
||||||
|
|
||||||
|
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
|
||||||
|
|
||||||
|
const names = (
|
||||||
|
db.sqlite.prepare("SELECT name FROM p_fleets_fleets").all() as {
|
||||||
|
name: string;
|
||||||
|
}[]
|
||||||
|
).map((row) => row.name);
|
||||||
|
expect(names).toEqual(["Theirs"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
|
|
||||||
import { FleetRepository } from "../../../../../src/backend/database/repositories/fleet-repository.js";
|
|
||||||
|
|
||||||
describe("FleetRepository", () => {
|
|
||||||
let adapter: TestSqliteDatabase | null = null;
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
if (adapter) {
|
|
||||||
await adapter.close();
|
|
||||||
adapter = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createRepository(
|
|
||||||
onWrite?: () => void | Promise<void>,
|
|
||||||
): Promise<{ repository: FleetRepository }> {
|
|
||||||
adapter = new TestSqliteDatabase();
|
|
||||||
const context = await adapter.connect();
|
|
||||||
await adapter.exec(`
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
|
||||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type, tags)
|
|
||||||
VALUES
|
|
||||||
(1, 'user-1', 'web-1', '10.0.0.1', 22, 'root', 'password', 'prod-web,edge'),
|
|
||||||
(2, 'user-1', 'web-2', '10.0.0.2', 22, 'root', 'password', 'prod-web'),
|
|
||||||
(3, 'user-1', 'db-1', '10.0.0.3', 22, 'root', 'password', 'prod-db'),
|
|
||||||
(4, 'user-1', 'static-only', '10.0.0.4', 22, 'root', 'password', NULL),
|
|
||||||
(5, 'user-2', 'other-user-host', '10.0.0.5', 22, 'root', 'password', 'prod-web');
|
|
||||||
`);
|
|
||||||
|
|
||||||
return { repository: new FleetRepository(context, onWrite) };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("unions static membership and tag-matched hosts, deduplicated by id", async () => {
|
|
||||||
let writes = 0;
|
|
||||||
const { repository } = await createRepository(() => {
|
|
||||||
writes += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const fleet = await repository.create("user-1", {
|
|
||||||
name: "web fleet",
|
|
||||||
tagRules: ["prod-web"],
|
|
||||||
});
|
|
||||||
// host 1 matches by tag AND is added statically - must appear once.
|
|
||||||
await repository.addMember(fleet.id, 1);
|
|
||||||
// host 4 has no tags - only reachable via static membership.
|
|
||||||
await repository.addMember(fleet.id, 4);
|
|
||||||
|
|
||||||
const members = await repository.listEffectiveMembers("user-1", fleet.id);
|
|
||||||
const ids = members.map((m) => m.id).sort((a, b) => a - b);
|
|
||||||
|
|
||||||
expect(ids).toEqual([1, 2, 4]);
|
|
||||||
expect(writes).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never returns another user's hosts even if tags match", async () => {
|
|
||||||
const { repository } = await createRepository();
|
|
||||||
|
|
||||||
const fleet = await repository.create("user-1", {
|
|
||||||
name: "web fleet",
|
|
||||||
tagRules: ["prod-web"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const members = await repository.listEffectiveMembers("user-1", fleet.id);
|
|
||||||
expect(members.map((m) => m.id)).not.toContain(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns only static members when a fleet has no tag rules", async () => {
|
|
||||||
const { repository } = await createRepository();
|
|
||||||
|
|
||||||
const fleet = await repository.create("user-1", { name: "static fleet" });
|
|
||||||
await repository.addMember(fleet.id, 3);
|
|
||||||
|
|
||||||
const members = await repository.listEffectiveMembers("user-1", fleet.id);
|
|
||||||
expect(members.map((m) => m.id)).toEqual([3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removeMember only drops the static row, not tag-based membership", async () => {
|
|
||||||
const { repository } = await createRepository();
|
|
||||||
|
|
||||||
const fleet = await repository.create("user-1", {
|
|
||||||
name: "web fleet",
|
|
||||||
tagRules: ["prod-web"],
|
|
||||||
});
|
|
||||||
await repository.addMember(fleet.id, 1);
|
|
||||||
|
|
||||||
await expect(repository.removeMember(fleet.id, 1)).resolves.toBe(true);
|
|
||||||
|
|
||||||
// host 1 still matches the tag rule, so it remains an effective member.
|
|
||||||
const members = await repository.listEffectiveMembers("user-1", fleet.id);
|
|
||||||
expect(members.map((m) => m.id)).toContain(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty list for a fleet the caller does not own", async () => {
|
|
||||||
const { repository } = await createRepository();
|
|
||||||
const fleet = await repository.create("user-1", { name: "private" });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
repository.listEffectiveMembers("user-2", fleet.id),
|
|
||||||
).resolves.toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||||
|
import type {
|
||||||
|
PluginHostSummary,
|
||||||
|
PluginHosts,
|
||||||
|
} from "@termix/plugin-sdk/backend";
|
||||||
|
import { pluginDir } from "./helpers";
|
||||||
|
import {
|
||||||
|
fleets,
|
||||||
|
fleetMembers,
|
||||||
|
fleetInventory,
|
||||||
|
} from "../../src/backend/tables.js";
|
||||||
|
import { createFleetRepository } from "../../src/backend/repository.js";
|
||||||
|
|
||||||
|
let db: TestDb | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db?.close();
|
||||||
|
db = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeHosts(hosts: PluginHostSummary[]): PluginHosts {
|
||||||
|
return {
|
||||||
|
list: async () => hosts,
|
||||||
|
get: async (hostId) => hosts.find((h) => h.id === hostId) ?? null,
|
||||||
|
checkAccess: async (hostId) => {
|
||||||
|
const found = hosts.find((h) => h.id === hostId);
|
||||||
|
return found
|
||||||
|
? {
|
||||||
|
hasAccess: true,
|
||||||
|
isOwner: true,
|
||||||
|
isShared: false,
|
||||||
|
permissionLevel: "manage",
|
||||||
|
}
|
||||||
|
: { hasAccess: false, isOwner: false, isShared: false };
|
||||||
|
},
|
||||||
|
share: async (hostId) => ({ hostId, shared: true }),
|
||||||
|
listUsers: async () => [],
|
||||||
|
listRoles: async () => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(hosts: PluginHostSummary[]) {
|
||||||
|
db = await createTestDb(pluginDir, {
|
||||||
|
before: (sqlite) => {
|
||||||
|
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||||
|
sqlite.exec(
|
||||||
|
`INSERT INTO ssh_data (id) VALUES ${hosts.map((h) => `(${h.id})`).join(", ")}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fleetsTable = await db.database.define(fleets);
|
||||||
|
const membersTable = await db.database.define(fleetMembers);
|
||||||
|
const inventoryTable = await db.database.define(fleetInventory);
|
||||||
|
const repo = createFleetRepository(
|
||||||
|
db.database,
|
||||||
|
fakeHosts(hosts),
|
||||||
|
fleetsTable,
|
||||||
|
membersTable,
|
||||||
|
inventoryTable,
|
||||||
|
);
|
||||||
|
return repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function host(
|
||||||
|
id: number,
|
||||||
|
overrides: Partial<PluginHostSummary> = {},
|
||||||
|
): PluginHostSummary {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
userId: "user-1",
|
||||||
|
name: `host-${id}`,
|
||||||
|
ip: `10.0.0.${id}`,
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
tags: null,
|
||||||
|
folder: null,
|
||||||
|
authType: "password",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FleetRepository.listEffectiveMembers", () => {
|
||||||
|
it("unions static membership and tag-matched hosts, deduplicated by id", async () => {
|
||||||
|
const repo = await setup([
|
||||||
|
host(1, { tags: "prod-web,edge" }),
|
||||||
|
host(2, { tags: "prod-web" }),
|
||||||
|
host(3, { tags: "prod-db" }),
|
||||||
|
host(4, { tags: null }),
|
||||||
|
host(5, { userId: "user-2", tags: "prod-web" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fleet = await repo.create("user-1", {
|
||||||
|
name: "web fleet",
|
||||||
|
tagRules: ["prod-web"],
|
||||||
|
});
|
||||||
|
// host 1 matches by tag AND is added statically - must appear once.
|
||||||
|
await repo.addMember(fleet.id, 1);
|
||||||
|
// host 4 has no tags - only reachable via static membership.
|
||||||
|
await repo.addMember(fleet.id, 4);
|
||||||
|
|
||||||
|
const members = await repo.listEffectiveMembers("user-1", fleet.id);
|
||||||
|
const ids = members.map((m) => m.id).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
expect(ids).toEqual([1, 2, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns another user's hosts even if tags match", async () => {
|
||||||
|
const repo = await setup([
|
||||||
|
host(1, { tags: "prod-web" }),
|
||||||
|
host(5, { userId: "user-2", tags: "prod-web" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fleet = await repo.create("user-1", {
|
||||||
|
name: "web fleet",
|
||||||
|
tagRules: ["prod-web"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = await repo.listEffectiveMembers("user-1", fleet.id);
|
||||||
|
expect(members.map((m) => m.id)).not.toContain(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only static members when a fleet has no tag rules", async () => {
|
||||||
|
const repo = await setup([host(3, { tags: "prod-db" })]);
|
||||||
|
|
||||||
|
const fleet = await repo.create("user-1", { name: "static fleet" });
|
||||||
|
await repo.addMember(fleet.id, 3);
|
||||||
|
|
||||||
|
const members = await repo.listEffectiveMembers("user-1", fleet.id);
|
||||||
|
expect(members.map((m) => m.id)).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeMember only drops the static row, not tag-based membership", async () => {
|
||||||
|
const repo = await setup([host(1, { tags: "prod-web" })]);
|
||||||
|
|
||||||
|
const fleet = await repo.create("user-1", {
|
||||||
|
name: "web fleet",
|
||||||
|
tagRules: ["prod-web"],
|
||||||
|
});
|
||||||
|
await repo.addMember(fleet.id, 1);
|
||||||
|
|
||||||
|
await expect(repo.removeMember(fleet.id, 1)).resolves.toBe(true);
|
||||||
|
|
||||||
|
// host 1 still matches the tag rule, so it remains an effective member.
|
||||||
|
const members = await repo.listEffectiveMembers("user-1", fleet.id);
|
||||||
|
expect(members.map((m) => m.id)).toContain(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list for a fleet the caller does not own", async () => {
|
||||||
|
const repo = await setup([host(1)]);
|
||||||
|
const fleet = await repo.create("user-1", { name: "private" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repo.listEffectiveMembers("user-2", fleet.id),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FleetRepository CRUD", () => {
|
||||||
|
it("creates, updates and deletes a fleet", async () => {
|
||||||
|
const repo = await setup([host(1)]);
|
||||||
|
const created = await repo.create("user-1", {
|
||||||
|
name: "Fleet",
|
||||||
|
tagRules: ["a"],
|
||||||
|
});
|
||||||
|
expect(created.name).toBe("Fleet");
|
||||||
|
expect(created.tagRules).toBe('["a"]');
|
||||||
|
|
||||||
|
const updated = await repo.update("user-1", created.id, {
|
||||||
|
name: "Renamed",
|
||||||
|
});
|
||||||
|
expect(updated?.name).toBe("Renamed");
|
||||||
|
|
||||||
|
await expect(repo.delete("user-1", created.id)).resolves.toBe(true);
|
||||||
|
await expect(repo.findById("user-1", created.id)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upserts and lists inventory per host", async () => {
|
||||||
|
const repo = await setup([host(1)]);
|
||||||
|
await repo.upsertInventory("user-1", 1, {
|
||||||
|
osPrettyName: "Debian",
|
||||||
|
kernel: "6.1.0",
|
||||||
|
architecture: "x86_64",
|
||||||
|
hostname: "web-1",
|
||||||
|
uptimeSeconds: 100,
|
||||||
|
ip: "10.0.0.1",
|
||||||
|
packageManager: "apt",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await repo.listInventoryForHosts("user-1", [1]);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({ hostId: 1, osPrettyName: "Debian" });
|
||||||
|
|
||||||
|
// Refreshing overwrites the latest-only snapshot rather than adding a row.
|
||||||
|
await repo.upsertInventory("user-1", 1, {
|
||||||
|
osPrettyName: "Ubuntu",
|
||||||
|
kernel: "6.2.0",
|
||||||
|
architecture: "x86_64",
|
||||||
|
hostname: "web-1",
|
||||||
|
uptimeSeconds: 200,
|
||||||
|
ip: "10.0.0.1",
|
||||||
|
packageManager: "apt",
|
||||||
|
});
|
||||||
|
const refreshed = await repo.listInventoryForHosts("user-1", [1]);
|
||||||
|
expect(refreshed).toHaveLength(1);
|
||||||
|
expect(refreshed[0].osPrettyName).toBe("Ubuntu");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,388 +1,125 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { Request, Response, Router } from "express";
|
import { startServer, type TestServer } from "./helpers";
|
||||||
|
import { parseInventoryProbe } from "../../src/backend/routes.js";
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
let server: TestServer;
|
||||||
currentUserId: "user-1",
|
|
||||||
fleets: new Map<number, { id: number; userId: string; name: string }>(),
|
|
||||||
members: new Map<number, { id: number; name: string }[]>(),
|
|
||||||
hostAccess: new Map<string, { hasAccess: boolean; isOwner: boolean }>(),
|
|
||||||
hosts: new Map<number, Record<string, unknown>>(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
|
afterEach(async () => {
|
||||||
|
await server?.close();
|
||||||
vi.mock("../../../../src/backend/utils/logger.js", () => ({
|
|
||||||
authLogger: {
|
|
||||||
error: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
success: vi.fn(),
|
|
||||||
},
|
|
||||||
databaseLogger: {
|
|
||||||
error: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
success: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
|
|
||||||
AuthManager: {
|
|
||||||
getInstance: () => ({
|
|
||||||
createAuthMiddleware:
|
|
||||||
() =>
|
|
||||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
|
||||||
req.userId = state.currentUserId;
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
createDataAccessMiddleware:
|
|
||||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
|
||||||
next(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../../src/backend/utils/permission-manager.js", () => ({
|
|
||||||
PermissionManager: {
|
|
||||||
getInstance: () => ({
|
|
||||||
canAccessHost: async (userId: string, hostId: number, level: string) => {
|
|
||||||
const key = `${userId}:${hostId}:${level}`;
|
|
||||||
const found = state.hostAccess.get(key);
|
|
||||||
if (found) return found;
|
|
||||||
// default: full access unless a test explicitly denies it
|
|
||||||
return { hasAccess: true, isOwner: true, permissionLevel: "manage" };
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
|
|
||||||
resolveHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../src/backend/ssh.js", () => ({
|
|
||||||
withSshConnection: async (
|
|
||||||
_host: unknown,
|
|
||||||
_options: unknown,
|
|
||||||
fn: (client: unknown) => unknown,
|
|
||||||
) => fn({}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../../src/backend/hosts/metrics-shared/common-utils.js", () => ({
|
|
||||||
execCommand: vi.fn(async () => ({ stdout: "ok", stderr: "", code: 0 })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../../src/backend/hosts/metrics-shared/platform.js", () => ({
|
|
||||||
detectPlatform: vi.fn(async () => ({ pkg: "apt", osPrettyName: "Debian" })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock(
|
|
||||||
"../../../../src/backend/hosts/metrics-shared/exec-elevated.js",
|
|
||||||
async () => {
|
|
||||||
class ElevationError extends Error {
|
|
||||||
code: string;
|
|
||||||
constructor(code: string, message: string) {
|
|
||||||
super(message);
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
execElevated: vi.fn(),
|
|
||||||
ElevationError,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mock(
|
|
||||||
"../../../../src/backend/hosts/metrics-shared/package-commands.js",
|
|
||||||
() => ({
|
|
||||||
buildPackageActionCommand: vi.fn(() => "apt-get install -y foo"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mock("../../../../src/backend/hosts/metrics-shared/validation.js", () => ({
|
|
||||||
isValidPackageName: (v: unknown) => typeof v === "string" && v.length > 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock(
|
|
||||||
"../../../../src/backend/database/routes/snippets-execution.js",
|
|
||||||
() => ({
|
|
||||||
resolveSnippetCommand: (command: string) => command,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mock("../../../../src/backend/database/routes/rbac.js", () => ({
|
|
||||||
isSharePermissionLevel: (v: unknown) =>
|
|
||||||
["connect", "view", "edit", "manage"].includes(v as string),
|
|
||||||
expiryFromDuration: () => null,
|
|
||||||
parseShareTargets: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
|
||||||
createCurrentFleetRepository: () => ({
|
|
||||||
findById: async (userId: string, fleetId: number) => {
|
|
||||||
const fleet = state.fleets.get(fleetId);
|
|
||||||
return fleet && fleet.userId === userId ? fleet : null;
|
|
||||||
},
|
|
||||||
listEffectiveMembers: async (_userId: string, fleetId: number) =>
|
|
||||||
state.members.get(fleetId) ?? [],
|
|
||||||
listStaticMemberIds: async () => [],
|
|
||||||
listByUser: async (userId: string) =>
|
|
||||||
[...state.fleets.values()].filter((f) => f.userId === userId),
|
|
||||||
}),
|
|
||||||
createCurrentFleetInventoryRepository: () => ({
|
|
||||||
listForHosts: async () => [],
|
|
||||||
upsert: async () => ({}),
|
|
||||||
}),
|
|
||||||
createCurrentRbacAccessRepository: () => ({}),
|
|
||||||
createCurrentRoleRepository: () => ({}),
|
|
||||||
createCurrentUserRepository: () => ({}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../../src/backend/database/routes/fleet-dispatch.js", () => ({
|
|
||||||
registerFleetsRouter: vi.fn(),
|
|
||||||
unregisterFleetsRouter: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { router, parseInventoryProbe, buildRemoveCommand } =
|
|
||||||
await import("../../src/backend/routes.js");
|
|
||||||
|
|
||||||
function findLayer(method: string, path: string) {
|
|
||||||
const stack = (router as unknown as Router).stack as Array<{
|
|
||||||
route?: {
|
|
||||||
path: string;
|
|
||||||
methods: Record<string, boolean>;
|
|
||||||
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
const layer = stack.find(
|
|
||||||
(l) => l.route?.path === path && l.route?.methods[method],
|
|
||||||
);
|
|
||||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
|
||||||
return layer.route.stack[layer.route.stack.length - 1].handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeReqRes(overrides: {
|
|
||||||
body?: Record<string, unknown>;
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
}) {
|
|
||||||
const req = {
|
|
||||||
userId: state.currentUserId,
|
|
||||||
body: overrides.body ?? {},
|
|
||||||
params: overrides.params ?? {},
|
|
||||||
headers: {},
|
|
||||||
} as unknown as Request;
|
|
||||||
|
|
||||||
const res = {
|
|
||||||
statusCode: 200,
|
|
||||||
jsonBody: null as unknown,
|
|
||||||
status(code: number) {
|
|
||||||
(this as unknown as { statusCode: number }).statusCode = code;
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
json(payload: unknown) {
|
|
||||||
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
} as unknown as Response & { statusCode: number; jsonBody: unknown };
|
|
||||||
|
|
||||||
return { req, res };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function invoke(
|
|
||||||
method: string,
|
|
||||||
path: string,
|
|
||||||
overrides: {
|
|
||||||
body?: Record<string, unknown>;
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
const handler = findLayer(method, path);
|
|
||||||
const { req, res } = makeReqRes(overrides);
|
|
||||||
await handler(req, res);
|
|
||||||
return res as unknown as {
|
|
||||||
statusCode: number;
|
|
||||||
jsonBody: Record<string, unknown> | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state.currentUserId = "user-1";
|
|
||||||
state.fleets = new Map([[1, { id: 1, userId: "user-1", name: "web fleet" }]]);
|
|
||||||
state.members = new Map([
|
|
||||||
[
|
|
||||||
1,
|
|
||||||
[
|
|
||||||
{ id: 10, name: "host-a" },
|
|
||||||
{ id: 11, name: "host-b" },
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
state.hostAccess = new Map();
|
|
||||||
state.hosts = new Map([
|
|
||||||
[
|
|
||||||
10,
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
userId: "user-1",
|
|
||||||
name: "host-a",
|
|
||||||
ip: "10.0.0.1",
|
|
||||||
port: 22,
|
|
||||||
username: "root",
|
|
||||||
sudoPassword: "secret",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
11,
|
|
||||||
{
|
|
||||||
id: 11,
|
|
||||||
userId: "user-1",
|
|
||||||
name: "host-b",
|
|
||||||
ip: "10.0.0.2",
|
|
||||||
port: 22,
|
|
||||||
username: "root",
|
|
||||||
sudoPassword: "secret",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /:id/members", () => {
|
async function createFleet(
|
||||||
it("404s for a fleet the caller does not own", async () => {
|
server: TestServer,
|
||||||
const res = await invoke("get", "/:id/members", {
|
body: Record<string, unknown> = { name: "Web fleet" },
|
||||||
params: { id: "999" },
|
) {
|
||||||
|
const res = await server.request("POST", "/", { body });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
return res.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("fleet CRUD routes", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
server = await startServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates and lists fleets with member counts", async () => {
|
||||||
|
const created = await createFleet(server);
|
||||||
|
expect(created).toMatchObject({ name: "Web fleet", tagRules: [] });
|
||||||
|
|
||||||
|
const list = await server.request("GET", "/");
|
||||||
|
expect(list.status).toBe(200);
|
||||||
|
expect(list.body).toHaveLength(1);
|
||||||
|
expect(list.body[0]).toMatchObject({ id: created.id, memberCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("400s on a missing name", async () => {
|
||||||
|
const res = await server.request("POST", "/", { body: {} });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates and deletes a fleet", async () => {
|
||||||
|
const created = await createFleet(server);
|
||||||
|
const updated = await server.request("PATCH", `/${created.id}`, {
|
||||||
|
body: { name: "Renamed" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(404);
|
expect(updated.status).toBe(200);
|
||||||
|
expect(updated.body.name).toBe("Renamed");
|
||||||
|
|
||||||
|
const deleted = await server.request("DELETE", `/${created.id}`);
|
||||||
|
expect(deleted.status).toBe(200);
|
||||||
|
expect((await server.request("GET", "/")).body).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s for a fleet the caller does not own", async () => {
|
||||||
|
const res = await server.request("GET", "/999/members");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("400s on a non-numeric fleet id", async () => {
|
it("400s on a non-numeric fleet id", async () => {
|
||||||
const res = await invoke("get", "/:id/members", {
|
const res = await server.request("GET", "/not-a-number/members");
|
||||||
params: { id: "not-a-number" },
|
expect(res.status).toBe(400);
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /:id/execute", () => {
|
describe("fleet membership routes", () => {
|
||||||
it("400s when command is missing", async () => {
|
beforeEach(async () => {
|
||||||
const res = await invoke("post", "/:id/execute", {
|
server = await startServer();
|
||||||
params: { id: "1" },
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports per-host success with the {hostId, hostName, success} shape", async () => {
|
it("adds and lists members, then removes one", async () => {
|
||||||
const res = await invoke("post", "/:id/execute", {
|
const fleet = await createFleet(server);
|
||||||
params: { id: "1" },
|
const added = await server.request("POST", `/${fleet.id}/members`, {
|
||||||
body: { command: "uptime" },
|
body: { hostId: 10 },
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
|
|
||||||
expect(results).toHaveLength(2);
|
|
||||||
expect(results[0]).toMatchObject({
|
|
||||||
hostId: 10,
|
|
||||||
hostName: "host-a",
|
|
||||||
success: true,
|
|
||||||
});
|
});
|
||||||
|
expect(added.status).toBe(200);
|
||||||
|
|
||||||
|
const members = await server.request("GET", `/${fleet.id}/members`);
|
||||||
|
expect(members.status).toBe(200);
|
||||||
|
expect(members.body).toHaveLength(1);
|
||||||
|
expect(members.body[0]).toMatchObject({ id: 10, static: true });
|
||||||
|
|
||||||
|
const removed = await server.request("DELETE", `/${fleet.id}/members/10`);
|
||||||
|
expect(removed.status).toBe(200);
|
||||||
|
expect(
|
||||||
|
(await server.request("GET", `/${fleet.id}/members`)).body,
|
||||||
|
).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isolates one host's access denial - the other host still succeeds", async () => {
|
it("404s when adding a host outside the fixture host list", async () => {
|
||||||
state.hostAccess.set("user-1:10:edit", {
|
const fleet = await createFleet(server);
|
||||||
hasAccess: false,
|
const res = await server.request("POST", `/${fleet.id}/members`, {
|
||||||
isOwner: false,
|
body: { hostId: 999 },
|
||||||
});
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
const res = await invoke("post", "/:id/execute", {
|
|
||||||
params: { id: "1" },
|
|
||||||
body: { command: "uptime" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
|
|
||||||
const denied = results.find((r) => r.hostId === 10);
|
|
||||||
const allowed = results.find((r) => r.hostId === 11);
|
|
||||||
expect(denied).toMatchObject({ success: false });
|
|
||||||
expect(String(denied?.error)).toMatch(/edit/);
|
|
||||||
expect(allowed).toMatchObject({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("404s for a fleet the caller does not own", async () => {
|
|
||||||
const res = await invoke("post", "/:id/execute", {
|
|
||||||
params: { id: "999" },
|
|
||||||
body: { command: "uptime" },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(404);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /:id/packages", () => {
|
describe("permission gates", () => {
|
||||||
it("400s on an invalid action", async () => {
|
it("view routes 403 without fleets.view", async () => {
|
||||||
const res = await invoke("post", "/:id/packages", {
|
server = await startServer({
|
||||||
params: { id: "1" },
|
permissions: ["fleets.manage", "fleets.execute"],
|
||||||
body: { action: "reformat-disk" },
|
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
const res = await server.request("GET", "/");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces an ElevationError as a per-host error, not a request failure", async () => {
|
it("manage routes 403 without fleets.manage", async () => {
|
||||||
const { execElevated, ElevationError } =
|
server = await startServer({
|
||||||
await import("../../../../src/backend/hosts/metrics-shared/exec-elevated.js");
|
permissions: ["fleets.view", "fleets.execute"],
|
||||||
(execElevated as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
||||||
new ElevationError("SUDO_REQUIRED", "sudo password required"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await invoke("post", "/:id/packages", {
|
|
||||||
params: { id: "1" },
|
|
||||||
body: { action: "install", package: "curl" },
|
|
||||||
});
|
});
|
||||||
|
const res = await server.request("POST", "/", { body: { name: "x" } });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.status).toBe(403);
|
||||||
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
|
|
||||||
expect(results.every((r) => r.success === false)).toBe(true);
|
|
||||||
expect(results[0].error).toMatch(/sudo password required/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires manage-level access, not just edit", async () => {
|
it("execute routes 403 without fleets.execute", async () => {
|
||||||
state.hostAccess.set("user-1:10:manage", {
|
server = await startServer({
|
||||||
hasAccess: false,
|
permissions: ["fleets.view", "fleets.manage"],
|
||||||
isOwner: false,
|
|
||||||
});
|
});
|
||||||
state.hostAccess.set("user-1:11:manage", {
|
const fleet = await createFleet(server);
|
||||||
hasAccess: false,
|
const res = await server.request("POST", `/${fleet.id}/execute`, {
|
||||||
isOwner: false,
|
body: { command: "uptime" },
|
||||||
});
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
const { execElevated } =
|
|
||||||
await import("../../../../src/backend/hosts/metrics-shared/exec-elevated.js");
|
|
||||||
(execElevated as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
code: 0,
|
|
||||||
stdout: "done",
|
|
||||||
stderr: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await invoke("post", "/:id/packages", {
|
|
||||||
params: { id: "1" },
|
|
||||||
body: { action: "upgrade-all" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
|
|
||||||
expect(results.every((r) => r.success === false)).toBe(true);
|
|
||||||
expect(String(results[0].error)).toMatch(/manage/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /:id/members", () => {
|
|
||||||
it("404s when the caller cannot access the target host", async () => {
|
|
||||||
state.hostAccess.set("user-1:99:connect", {
|
|
||||||
hasAccess: false,
|
|
||||||
isOwner: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await invoke("post", "/:id/members", {
|
|
||||||
params: { id: "1" },
|
|
||||||
body: { hostId: 99 },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(404);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -413,8 +150,7 @@ describe("parseInventoryProbe", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("nulls uptimeSeconds when the value is not a bare integer", () => {
|
it("nulls uptimeSeconds when the value is not a bare integer", () => {
|
||||||
const out = "uptime_seconds=";
|
expect(parseInventoryProbe("uptime_seconds=").uptimeSeconds).toBeNull();
|
||||||
expect(parseInventoryProbe(out).uptimeSeconds).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores lines with no '=' separator", () => {
|
it("ignores lines with no '=' separator", () => {
|
||||||
@@ -423,19 +159,18 @@ describe("parseInventoryProbe", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildRemoveCommand", () => {
|
describe("share target pickers", () => {
|
||||||
it("builds the correct remove command per package manager", () => {
|
beforeEach(async () => {
|
||||||
expect(buildRemoveCommand("apt", "curl")).toContain(
|
server = await startServer();
|
||||||
"apt-get -y remove curl",
|
|
||||||
);
|
|
||||||
expect(buildRemoveCommand("dnf", "curl")).toBe("dnf -y remove curl");
|
|
||||||
expect(buildRemoveCommand("yum", "curl")).toBe("yum -y remove curl");
|
|
||||||
expect(buildRemoveCommand("pacman", "curl")).toBe(
|
|
||||||
"pacman -R --noconfirm curl",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when no package manager was detected", () => {
|
it("lists users and roles through ctx.hosts", async () => {
|
||||||
expect(buildRemoveCommand(null, "curl")).toBeNull();
|
const users = await server.request("GET", "/share-targets/users");
|
||||||
|
expect(users.status).toBe(200);
|
||||||
|
expect(users.body).toEqual({ users: [] });
|
||||||
|
|
||||||
|
const roles = await server.request("GET", "/share-targets/roles");
|
||||||
|
expect(roles.status).toBe(200);
|
||||||
|
expect(roles.body).toEqual({ roles: [] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -31,9 +31,6 @@
|
|||||||
"plugins/docker/src/frontend/components/LogViewer.tsx",
|
"plugins/docker/src/frontend/components/LogViewer.tsx",
|
||||||
"plugins/docker/src/frontend/docker-api.ts",
|
"plugins/docker/src/frontend/docker-api.ts",
|
||||||
"plugins/docker/src/frontend/index.tsx",
|
"plugins/docker/src/frontend/index.tsx",
|
||||||
"plugins/fleets/src/frontend/FleetInventoryTab.tsx",
|
|
||||||
"plugins/fleets/src/frontend/FleetsPanel.tsx",
|
|
||||||
"plugins/fleets/src/frontend/fleets-api.ts",
|
|
||||||
"plugins/host-metrics/src/frontend/HostEditorStatsTab.tsx",
|
"plugins/host-metrics/src/frontend/HostEditorStatsTab.tsx",
|
||||||
"plugins/host-metrics/src/frontend/HostMetricsApp.tsx",
|
"plugins/host-metrics/src/frontend/HostMetricsApp.tsx",
|
||||||
"plugins/host-metrics/src/frontend/HostMetricsTab.tsx",
|
"plugins/host-metrics/src/frontend/HostMetricsTab.tsx",
|
||||||
@@ -136,7 +133,6 @@
|
|||||||
"plugins/docker/src/backend/index.ts",
|
"plugins/docker/src/backend/index.ts",
|
||||||
"plugins/docker/src/backend/routes.ts",
|
"plugins/docker/src/backend/routes.ts",
|
||||||
"plugins/docker/src/backend/session-manager.ts",
|
"plugins/docker/src/backend/session-manager.ts",
|
||||||
"plugins/fleets/src/backend/routes.ts",
|
|
||||||
"plugins/host-metrics/src/backend/automation-bridge.ts",
|
"plugins/host-metrics/src/backend/automation-bridge.ts",
|
||||||
"plugins/host-metrics/src/backend/history-routes.ts",
|
"plugins/host-metrics/src/backend/history-routes.ts",
|
||||||
"plugins/host-metrics/src/backend/managers/cron.ts",
|
"plugins/host-metrics/src/backend/managers/cron.ts",
|
||||||
|
|||||||
@@ -2664,87 +2664,6 @@ const migrateSchema = () => {
|
|||||||
}
|
}
|
||||||
// --- homepage end ---
|
// --- homepage end ---
|
||||||
|
|
||||||
// --- fleets begin ---
|
|
||||||
try {
|
|
||||||
sqlite.prepare("SELECT id FROM fleets LIMIT 1").get();
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
sqlite.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS fleets (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
color TEXT,
|
|
||||||
icon TEXT,
|
|
||||||
tag_rules TEXT,
|
|
||||||
sync_id TEXT UNIQUE,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
} catch (createError) {
|
|
||||||
databaseLogger.warn("Failed to create fleets table", {
|
|
||||||
operation: "schema_migration",
|
|
||||||
error: createError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
sqlite.prepare("SELECT id FROM fleet_members LIMIT 1").get();
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
sqlite.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS fleet_members (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
fleet_id INTEGER NOT NULL REFERENCES fleets(id) ON DELETE CASCADE,
|
|
||||||
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
|
|
||||||
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
sqlite.exec(
|
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fleet_members_fleet_host ON fleet_members(fleet_id, host_id)",
|
|
||||||
);
|
|
||||||
} catch (createError) {
|
|
||||||
databaseLogger.warn("Failed to create fleet_members table", {
|
|
||||||
operation: "schema_migration",
|
|
||||||
error: createError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
sqlite.prepare("SELECT id FROM fleet_inventory LIMIT 1").get();
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
sqlite.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS fleet_inventory (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
os_pretty_name TEXT,
|
|
||||||
kernel TEXT,
|
|
||||||
architecture TEXT,
|
|
||||||
hostname TEXT,
|
|
||||||
uptime_seconds INTEGER,
|
|
||||||
ip TEXT,
|
|
||||||
package_manager TEXT,
|
|
||||||
collected_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
sqlite.exec(
|
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fleet_inventory_host ON fleet_inventory(host_id, user_id)",
|
|
||||||
);
|
|
||||||
} catch (createError) {
|
|
||||||
databaseLogger.warn("Failed to create fleet_inventory table", {
|
|
||||||
operation: "schema_migration",
|
|
||||||
error: createError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// --- fleets end ---
|
|
||||||
|
|
||||||
// --- sync begin ---
|
// --- sync begin ---
|
||||||
// Stable per-row identity used to match rows across two independently-
|
// Stable per-row identity used to match rows across two independently-
|
||||||
// seeded databases (the embedded desktop backend and a connected remote
|
// seeded databases (the embedded desktop backend and a connected remote
|
||||||
|
|||||||
@@ -168,23 +168,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
|||||||
columns: "host_id",
|
columns: "host_id",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Fleets.
|
|
||||||
{
|
|
||||||
name: "idx_fleet_members_fleet",
|
|
||||||
table: "fleet_members",
|
|
||||||
columns: "fleet_id",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "idx_fleet_members_host",
|
|
||||||
table: "fleet_members",
|
|
||||||
columns: "host_id",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "idx_fleet_inventory_user",
|
|
||||||
table: "fleet_inventory",
|
|
||||||
columns: "user_id",
|
|
||||||
},
|
|
||||||
|
|
||||||
// Automations.
|
// Automations.
|
||||||
{
|
{
|
||||||
name: "idx_automations_user",
|
name: "idx_automations_user",
|
||||||
|
|||||||
@@ -1616,81 +1616,6 @@ export const homepageLayouts = mysqlTable("homepage_layouts", {
|
|||||||
});
|
});
|
||||||
// --- homepage end ---
|
// --- homepage end ---
|
||||||
|
|
||||||
// --- fleets begin ---
|
|
||||||
export const fleets = mysqlTable("fleets", {
|
|
||||||
id: int("id").autoincrement().primaryKey(),
|
|
||||||
userId: varchar("user_id", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
|
||||||
description: text("description"),
|
|
||||||
color: text("color"),
|
|
||||||
icon: text("icon"),
|
|
||||||
// JSON array of { tag: string } rules, unioned with static fleetMembers at
|
|
||||||
// resolution time. Kept to tag-equality matching for v1.
|
|
||||||
tagRules: text("tag_rules"),
|
|
||||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
|
||||||
createdAt: varchar("created_at", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
|
||||||
updatedAt: varchar("updated_at", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fleetMembers = mysqlTable(
|
|
||||||
"fleet_members",
|
|
||||||
{
|
|
||||||
id: int("id").autoincrement().primaryKey(),
|
|
||||||
fleetId: int("fleet_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => fleets.id, { onDelete: "cascade" }),
|
|
||||||
hostId: int("host_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
|
||||||
addedAt: text("added_at")
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
|
||||||
},
|
|
||||||
// fleet_id leads the unique pair, so listing a fleet's hosts is already
|
|
||||||
// served. Finding the fleets a host belongs to starts from host_id.
|
|
||||||
(table) => [
|
|
||||||
uniqueIndex("idx_fleet_members_fleet_host").on(table.fleetId, table.hostId),
|
|
||||||
index("idx_fleet_members_host").on(table.hostId),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Latest-only inventory snapshot per host, overwritten on each refresh - no
|
|
||||||
// historical log, matching the "latest snapshot only" scope decision.
|
|
||||||
export const fleetInventory = mysqlTable(
|
|
||||||
"fleet_inventory",
|
|
||||||
{
|
|
||||||
id: int("id").autoincrement().primaryKey(),
|
|
||||||
hostId: int("host_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
|
||||||
userId: varchar("user_id", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
osPrettyName: text("os_pretty_name"),
|
|
||||||
kernel: text("kernel"),
|
|
||||||
architecture: text("architecture"),
|
|
||||||
hostname: text("hostname"),
|
|
||||||
uptimeSeconds: int("uptime_seconds"),
|
|
||||||
ip: text("ip"),
|
|
||||||
packageManager: text("package_manager"),
|
|
||||||
collectedAt: text("collected_at")
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
|
||||||
},
|
|
||||||
// host_id leads the unique pair; a user's whole inventory is read by user_id.
|
|
||||||
(table) => [
|
|
||||||
uniqueIndex("idx_fleet_inventory_host").on(table.hostId, table.userId),
|
|
||||||
index("idx_fleet_inventory_user").on(table.userId),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
// --- fleets end ---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- sync begin ---
|
// --- sync begin ---
|
||||||
|
|||||||
@@ -1617,81 +1617,6 @@ export const homepageLayouts = pgTable("homepage_layouts", {
|
|||||||
});
|
});
|
||||||
// --- homepage end ---
|
// --- homepage end ---
|
||||||
|
|
||||||
// --- fleets begin ---
|
|
||||||
export const fleets = pgTable("fleets", {
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
userId: varchar("user_id", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
|
||||||
description: text("description"),
|
|
||||||
color: text("color"),
|
|
||||||
icon: text("icon"),
|
|
||||||
// JSON array of { tag: string } rules, unioned with static fleetMembers at
|
|
||||||
// resolution time. Kept to tag-equality matching for v1.
|
|
||||||
tagRules: text("tag_rules"),
|
|
||||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
|
||||||
createdAt: varchar("created_at", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
updatedAt: varchar("updated_at", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fleetMembers = pgTable(
|
|
||||||
"fleet_members",
|
|
||||||
{
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
fleetId: integer("fleet_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => fleets.id, { onDelete: "cascade" }),
|
|
||||||
hostId: integer("host_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
|
||||||
addedAt: text("added_at")
|
|
||||||
.notNull()
|
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
},
|
|
||||||
// fleet_id leads the unique pair, so listing a fleet's hosts is already
|
|
||||||
// served. Finding the fleets a host belongs to starts from host_id.
|
|
||||||
(table) => [
|
|
||||||
uniqueIndex("idx_fleet_members_fleet_host").on(table.fleetId, table.hostId),
|
|
||||||
index("idx_fleet_members_host").on(table.hostId),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Latest-only inventory snapshot per host, overwritten on each refresh - no
|
|
||||||
// historical log, matching the "latest snapshot only" scope decision.
|
|
||||||
export const fleetInventory = pgTable(
|
|
||||||
"fleet_inventory",
|
|
||||||
{
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
hostId: integer("host_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
|
||||||
userId: varchar("user_id", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
osPrettyName: text("os_pretty_name"),
|
|
||||||
kernel: text("kernel"),
|
|
||||||
architecture: text("architecture"),
|
|
||||||
hostname: text("hostname"),
|
|
||||||
uptimeSeconds: integer("uptime_seconds"),
|
|
||||||
ip: text("ip"),
|
|
||||||
packageManager: text("package_manager"),
|
|
||||||
collectedAt: text("collected_at")
|
|
||||||
.notNull()
|
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
},
|
|
||||||
// host_id leads the unique pair; a user's whole inventory is read by user_id.
|
|
||||||
(table) => [
|
|
||||||
uniqueIndex("idx_fleet_inventory_host").on(table.hostId, table.userId),
|
|
||||||
index("idx_fleet_inventory_user").on(table.userId),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
// --- fleets end ---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- sync begin ---
|
// --- sync begin ---
|
||||||
|
|||||||
@@ -1615,81 +1615,6 @@ export const homepageLayouts = sqliteTable("homepage_layouts", {
|
|||||||
});
|
});
|
||||||
// --- homepage end ---
|
// --- homepage end ---
|
||||||
|
|
||||||
// --- fleets begin ---
|
|
||||||
export const fleets = sqliteTable("fleets", {
|
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
||||||
userId: text("user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
description: text("description"),
|
|
||||||
color: text("color"),
|
|
||||||
icon: text("icon"),
|
|
||||||
// JSON array of { tag: string } rules, unioned with static fleetMembers at
|
|
||||||
// resolution time. Kept to tag-equality matching for v1.
|
|
||||||
tagRules: text("tag_rules"),
|
|
||||||
syncId: text("sync_id").unique(),
|
|
||||||
createdAt: text("created_at")
|
|
||||||
.notNull()
|
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
updatedAt: text("updated_at")
|
|
||||||
.notNull()
|
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fleetMembers = sqliteTable(
|
|
||||||
"fleet_members",
|
|
||||||
{
|
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
||||||
fleetId: integer("fleet_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => fleets.id, { onDelete: "cascade" }),
|
|
||||||
hostId: integer("host_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
|
||||||
addedAt: text("added_at")
|
|
||||||
.notNull()
|
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
},
|
|
||||||
// fleet_id leads the unique pair, so listing a fleet's hosts is already
|
|
||||||
// served. Finding the fleets a host belongs to starts from host_id.
|
|
||||||
(table) => [
|
|
||||||
uniqueIndex("idx_fleet_members_fleet_host").on(table.fleetId, table.hostId),
|
|
||||||
index("idx_fleet_members_host").on(table.hostId),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Latest-only inventory snapshot per host, overwritten on each refresh - no
|
|
||||||
// historical log, matching the "latest snapshot only" scope decision.
|
|
||||||
export const fleetInventory = sqliteTable(
|
|
||||||
"fleet_inventory",
|
|
||||||
{
|
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
||||||
hostId: integer("host_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
|
||||||
userId: text("user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
osPrettyName: text("os_pretty_name"),
|
|
||||||
kernel: text("kernel"),
|
|
||||||
architecture: text("architecture"),
|
|
||||||
hostname: text("hostname"),
|
|
||||||
uptimeSeconds: integer("uptime_seconds"),
|
|
||||||
ip: text("ip"),
|
|
||||||
packageManager: text("package_manager"),
|
|
||||||
collectedAt: text("collected_at")
|
|
||||||
.notNull()
|
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
},
|
|
||||||
// host_id leads the unique pair; a user's whole inventory is read by user_id.
|
|
||||||
(table) => [
|
|
||||||
uniqueIndex("idx_fleet_inventory_host").on(table.hostId, table.userId),
|
|
||||||
index("idx_fleet_inventory_user").on(table.userId),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
// --- fleets end ---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- sync begin ---
|
// --- sync begin ---
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import { CredentialRepository } from "./credential-repository.js";
|
|||||||
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
|
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
|
||||||
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
|
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
|
||||||
import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js";
|
import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js";
|
||||||
import { FleetRepository } from "./fleet-repository.js";
|
|
||||||
import { FleetInventoryRepository } from "./fleet-inventory-repository.js";
|
|
||||||
import { HomepageItemRepository } from "./homepage-item-repository.js";
|
import { HomepageItemRepository } from "./homepage-item-repository.js";
|
||||||
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
|
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
|
||||||
import { HostFolderRepository } from "./host-folder-repository.js";
|
import { HostFolderRepository } from "./host-folder-repository.js";
|
||||||
@@ -239,20 +237,6 @@ export function createCurrentFileManagerBookmarkRepository(): FileManagerBookmar
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCurrentFleetRepository(): FleetRepository {
|
|
||||||
return new FleetRepository(
|
|
||||||
createCurrentRepositoryContext(),
|
|
||||||
createCurrentRepositoryWriteHook("fleet_repository_write"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCurrentFleetInventoryRepository(): FleetInventoryRepository {
|
|
||||||
return new FleetInventoryRepository(
|
|
||||||
createCurrentRepositoryContext(),
|
|
||||||
createCurrentRepositoryWriteHook("fleet_inventory_repository_write"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCurrentHomepageItemRepository(): HomepageItemRepository {
|
export function createCurrentHomepageItemRepository(): HomepageItemRepository {
|
||||||
return new HomepageItemRepository(
|
return new HomepageItemRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
|
||||||
import { fleetInventory } from "../db/schema.js";
|
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
|
||||||
import { insertReturning, updateReturning } from "./returning.js";
|
|
||||||
|
|
||||||
export type FleetInventoryRecord = typeof fleetInventory.$inferSelect;
|
|
||||||
|
|
||||||
export interface FleetInventoryInput {
|
|
||||||
osPrettyName: string | null;
|
|
||||||
kernel: string | null;
|
|
||||||
architecture: string | null;
|
|
||||||
hostname: string | null;
|
|
||||||
uptimeSeconds: number | null;
|
|
||||||
ip: string | null;
|
|
||||||
packageManager: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FleetInventoryRepository {
|
|
||||||
constructor(
|
|
||||||
private readonly context: DatabaseContext,
|
|
||||||
private readonly onWrite?: () => void | Promise<void>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async upsert(
|
|
||||||
userId: string,
|
|
||||||
hostId: number,
|
|
||||||
input: FleetInventoryInput,
|
|
||||||
now = new Date().toISOString(),
|
|
||||||
): Promise<FleetInventoryRecord> {
|
|
||||||
const existing = await this.findOne(userId, hostId);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const [updated] = await updateReturning(
|
|
||||||
this.context,
|
|
||||||
fleetInventory,
|
|
||||||
{ ...input, collectedAt: now },
|
|
||||||
eq(fleetInventory.id, existing.id),
|
|
||||||
);
|
|
||||||
await this.afterWrite();
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [created] = await insertReturning(this.context, fleetInventory, {
|
|
||||||
userId,
|
|
||||||
hostId,
|
|
||||||
...input,
|
|
||||||
collectedAt: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.afterWrite();
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOne(
|
|
||||||
userId: string,
|
|
||||||
hostId: number,
|
|
||||||
): Promise<FleetInventoryRecord | null> {
|
|
||||||
const rows = await this.context.drizzle
|
|
||||||
.select()
|
|
||||||
.from(fleetInventory)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(fleetInventory.userId, userId),
|
|
||||||
eq(fleetInventory.hostId, hostId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
return rows[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async listForHosts(
|
|
||||||
userId: string,
|
|
||||||
hostIds: number[],
|
|
||||||
): Promise<FleetInventoryRecord[]> {
|
|
||||||
if (hostIds.length === 0) return [];
|
|
||||||
return this.context.drizzle
|
|
||||||
.select()
|
|
||||||
.from(fleetInventory)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(fleetInventory.userId, userId),
|
|
||||||
inArray(fleetInventory.hostId, hostIds),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
|
||||||
await this.onWrite?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
import { fleets, fleetMembers, hosts } from "../db/schema.js";
|
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
|
||||||
import { rowsAffected } from "./mutation-result.js";
|
|
||||||
import {
|
|
||||||
deleteReturning,
|
|
||||||
insertReturning,
|
|
||||||
updateReturning,
|
|
||||||
} from "./returning.js";
|
|
||||||
|
|
||||||
export type FleetRecord = typeof fleets.$inferSelect;
|
|
||||||
export type FleetMemberHostRecord = typeof hosts.$inferSelect;
|
|
||||||
|
|
||||||
export interface FleetCreateInput {
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
color?: string | null;
|
|
||||||
icon?: string | null;
|
|
||||||
tagRules?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FleetUpdateInput {
|
|
||||||
name?: string;
|
|
||||||
description?: string | null;
|
|
||||||
color?: string | null;
|
|
||||||
icon?: string | null;
|
|
||||||
tagRules?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTagRules(raw: string | null): string[] {
|
|
||||||
if (!raw) return [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed)
|
|
||||||
? parsed.filter((t): t is string => typeof t === "string")
|
|
||||||
: [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FleetRepository {
|
|
||||||
constructor(
|
|
||||||
private readonly context: DatabaseContext,
|
|
||||||
private readonly onWrite?: () => void | Promise<void>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async listByUser(userId: string): Promise<FleetRecord[]> {
|
|
||||||
return this.context.drizzle
|
|
||||||
.select()
|
|
||||||
.from(fleets)
|
|
||||||
.where(eq(fleets.userId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(userId: string, fleetId: number): Promise<FleetRecord | null> {
|
|
||||||
const rows = await this.context.drizzle
|
|
||||||
.select()
|
|
||||||
.from(fleets)
|
|
||||||
.where(and(eq(fleets.id, fleetId), eq(fleets.userId, userId)))
|
|
||||||
.limit(1);
|
|
||||||
return rows[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(
|
|
||||||
userId: string,
|
|
||||||
input: FleetCreateInput,
|
|
||||||
now = new Date().toISOString(),
|
|
||||||
): Promise<FleetRecord> {
|
|
||||||
const [created] = await insertReturning(this.context, fleets, {
|
|
||||||
userId,
|
|
||||||
name: input.name,
|
|
||||||
description: input.description ?? null,
|
|
||||||
color: input.color ?? null,
|
|
||||||
icon: input.icon ?? null,
|
|
||||||
tagRules: input.tagRules ? JSON.stringify(input.tagRules) : null,
|
|
||||||
syncId: randomUUID(),
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.afterWrite();
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
userId: string,
|
|
||||||
fleetId: number,
|
|
||||||
input: FleetUpdateInput,
|
|
||||||
now = new Date().toISOString(),
|
|
||||||
): Promise<FleetRecord | null> {
|
|
||||||
const existing = await this.findById(userId, fleetId);
|
|
||||||
if (!existing) return null;
|
|
||||||
|
|
||||||
const [updated] = await updateReturning(
|
|
||||||
this.context,
|
|
||||||
fleets,
|
|
||||||
{
|
|
||||||
name: input.name ?? existing.name,
|
|
||||||
description:
|
|
||||||
input.description === undefined
|
|
||||||
? existing.description
|
|
||||||
: input.description,
|
|
||||||
color: input.color === undefined ? existing.color : input.color,
|
|
||||||
icon: input.icon === undefined ? existing.icon : input.icon,
|
|
||||||
tagRules:
|
|
||||||
input.tagRules === undefined
|
|
||||||
? existing.tagRules
|
|
||||||
: JSON.stringify(input.tagRules),
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
and(eq(fleets.id, fleetId), eq(fleets.userId, userId)),
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.afterWrite();
|
|
||||||
return updated ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(userId: string, fleetId: number): Promise<boolean> {
|
|
||||||
const deleted = await deleteReturning(
|
|
||||||
this.context,
|
|
||||||
fleets,
|
|
||||||
and(eq(fleets.id, fleetId), eq(fleets.userId, userId)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (deleted.length > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async addMember(
|
|
||||||
fleetId: number,
|
|
||||||
hostId: number,
|
|
||||||
now = new Date().toISOString(),
|
|
||||||
): Promise<void> {
|
|
||||||
const existing = await this.context.drizzle
|
|
||||||
.select({ id: fleetMembers.id })
|
|
||||||
.from(fleetMembers)
|
|
||||||
.where(
|
|
||||||
and(eq(fleetMembers.fleetId, fleetId), eq(fleetMembers.hostId, hostId)),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing.length > 0) return;
|
|
||||||
|
|
||||||
await this.context.drizzle.insert(fleetMembers).values({
|
|
||||||
fleetId,
|
|
||||||
hostId,
|
|
||||||
addedAt: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeMember(fleetId: number, hostId: number): Promise<boolean> {
|
|
||||||
const result = await this.context.drizzle
|
|
||||||
.delete(fleetMembers)
|
|
||||||
.where(
|
|
||||||
and(eq(fleetMembers.fleetId, fleetId), eq(fleetMembers.hostId, hostId)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const affected = rowsAffected(result) > 0;
|
|
||||||
if (affected) await this.afterWrite();
|
|
||||||
return affected;
|
|
||||||
}
|
|
||||||
|
|
||||||
async listStaticMemberIds(fleetId: number): Promise<number[]> {
|
|
||||||
const rows = await this.context.drizzle
|
|
||||||
.select({ hostId: fleetMembers.hostId })
|
|
||||||
.from(fleetMembers)
|
|
||||||
.where(eq(fleetMembers.fleetId, fleetId));
|
|
||||||
return rows.map((r) => r.hostId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Effective membership = static fleetMembers rows union hosts whose
|
|
||||||
* comma-separated tags string intersects any tag in the fleet's tagRules.
|
|
||||||
* Both sets are scoped to the fleet owner's hosts and deduplicated by id.
|
|
||||||
*/
|
|
||||||
async listEffectiveMembers(
|
|
||||||
userId: string,
|
|
||||||
fleetId: number,
|
|
||||||
): Promise<FleetMemberHostRecord[]> {
|
|
||||||
const fleet = await this.findById(userId, fleetId);
|
|
||||||
if (!fleet) return [];
|
|
||||||
|
|
||||||
const staticIds = await this.listStaticMemberIds(fleetId);
|
|
||||||
const tagRules = parseTagRules(fleet.tagRules);
|
|
||||||
|
|
||||||
const ownedHosts = await this.context.drizzle
|
|
||||||
.select()
|
|
||||||
.from(hosts)
|
|
||||||
.where(eq(hosts.userId, userId));
|
|
||||||
|
|
||||||
const byId = new Map<number, FleetMemberHostRecord>();
|
|
||||||
|
|
||||||
for (const host of ownedHosts) {
|
|
||||||
if (staticIds.includes(host.id)) {
|
|
||||||
byId.set(host.id, host);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (tagRules.length === 0) continue;
|
|
||||||
const hostTags = (host.tags ?? "")
|
|
||||||
.split(",")
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (hostTags.some((tag) => tagRules.includes(tag))) {
|
|
||||||
byId.set(host.id, host);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Static members can reference hosts the fleet owner no longer owns
|
|
||||||
// (rare, but the FK cascade only fires on host delete, not transfer) -
|
|
||||||
// filter those out rather than surface a partial/foreign row.
|
|
||||||
return Array.from(byId.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
|
||||||
const userFleets = await this.listByUser(userId);
|
|
||||||
const result = await this.context.drizzle
|
|
||||||
.delete(fleets)
|
|
||||||
.where(eq(fleets.userId, userId));
|
|
||||||
|
|
||||||
if (rowsAffected(result) > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
return userFleets.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
|
||||||
await this.onWrite?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* ctx.hosts: the hosts a plugin's acting user can see, and the sharing
|
||||||
|
* operations that need core's RBAC and secret-snapshot machinery.
|
||||||
|
*
|
||||||
|
* list/get/checkAccess need hosts:read. share and the share-target pickers
|
||||||
|
* need hosts:write, because granting access to a host is a write on that
|
||||||
|
* host even though the plugin owns neither the host nor the grant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PluginHosts,
|
||||||
|
PluginHostSummary,
|
||||||
|
PluginHostAccess,
|
||||||
|
PluginHostShareLevel,
|
||||||
|
PluginHostShareResult,
|
||||||
|
PluginShareTarget,
|
||||||
|
PluginShareableUser,
|
||||||
|
PluginShareableRole,
|
||||||
|
} from "@termix/plugin-sdk/backend";
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
import { assertCapability } from "./permissions.js";
|
||||||
|
import { getActor } from "./actor.js";
|
||||||
|
|
||||||
|
type AuditFn = (
|
||||||
|
action: string,
|
||||||
|
details: string,
|
||||||
|
outcome: { success: boolean; errorMessage?: string },
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
manifest: PluginManifest;
|
||||||
|
audit: AuditFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSummary(host: {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
name: string | null;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
tags: string | null;
|
||||||
|
folder: string | null;
|
||||||
|
authType: string;
|
||||||
|
}): PluginHostSummary {
|
||||||
|
return {
|
||||||
|
id: host.id,
|
||||||
|
userId: host.userId,
|
||||||
|
name: host.name,
|
||||||
|
ip: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
tags: host.tags,
|
||||||
|
folder: host.folder,
|
||||||
|
authType: host.authType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function actingUser(): string {
|
||||||
|
const actor = getActor();
|
||||||
|
if (!actor) {
|
||||||
|
throw new Error(
|
||||||
|
"ctx.hosts needs an acting user: call it inside a request or ctx.asUser",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPluginHosts({ manifest, audit }: Deps): PluginHosts {
|
||||||
|
const pluginId = manifest.id;
|
||||||
|
const declared = manifest.capabilities;
|
||||||
|
|
||||||
|
const requireRead = () => assertCapability(pluginId, "hosts:read", declared);
|
||||||
|
const requireWrite = () =>
|
||||||
|
assertCapability(pluginId, "hosts:write", declared);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: async () => {
|
||||||
|
await requireRead();
|
||||||
|
const userId = actingUser();
|
||||||
|
const {
|
||||||
|
createCurrentHostResolutionRepository,
|
||||||
|
createCurrentRoleRepository,
|
||||||
|
createCurrentRbacAccessRepository,
|
||||||
|
} = await import("../database/repositories/factory.js");
|
||||||
|
const repository = createCurrentHostResolutionRepository();
|
||||||
|
|
||||||
|
const owned = await repository.findHostsByUserId(userId);
|
||||||
|
const roleIds =
|
||||||
|
await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||||
|
const grants =
|
||||||
|
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
|
||||||
|
userId,
|
||||||
|
roleIds,
|
||||||
|
);
|
||||||
|
const sharedRows = await repository.listHostRowsForAccessList(
|
||||||
|
userId,
|
||||||
|
grants,
|
||||||
|
);
|
||||||
|
|
||||||
|
const byId = new Map<number, PluginHostSummary>();
|
||||||
|
for (const host of owned) byId.set(host.id, toSummary(host));
|
||||||
|
for (const host of sharedRows) {
|
||||||
|
if (!byId.has(host.id)) byId.set(host.id, toSummary(host));
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (hostId) => {
|
||||||
|
await requireRead();
|
||||||
|
const userId = actingUser();
|
||||||
|
const { PermissionManager } =
|
||||||
|
await import("../utils/permission-manager.js");
|
||||||
|
const access = await PermissionManager.getInstance().canAccessHost(
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
"connect",
|
||||||
|
);
|
||||||
|
if (!access.hasAccess) return null;
|
||||||
|
|
||||||
|
const { createCurrentHostResolutionRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
const ownerId =
|
||||||
|
(await createCurrentHostResolutionRepository().findHostOwnerId(
|
||||||
|
hostId,
|
||||||
|
)) ?? userId;
|
||||||
|
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||||
|
hostId,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
return host ? toSummary(host) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
checkAccess: async (
|
||||||
|
hostId: number,
|
||||||
|
level: PluginHostShareLevel,
|
||||||
|
): Promise<PluginHostAccess> => {
|
||||||
|
await requireRead();
|
||||||
|
const userId = actingUser();
|
||||||
|
const { PermissionManager } =
|
||||||
|
await import("../utils/permission-manager.js");
|
||||||
|
const access = await PermissionManager.getInstance().canAccessHost(
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
level,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
hasAccess: access.hasAccess,
|
||||||
|
isOwner: access.isOwner,
|
||||||
|
isShared: access.isShared,
|
||||||
|
permissionLevel: access.permissionLevel as
|
||||||
|
PluginHostShareLevel | undefined,
|
||||||
|
expiresAt: access.expiresAt,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
share: async (
|
||||||
|
hostId: number,
|
||||||
|
targets: PluginShareTarget[],
|
||||||
|
permissionLevel: PluginHostShareLevel,
|
||||||
|
durationHours?: number,
|
||||||
|
): Promise<PluginHostShareResult> => {
|
||||||
|
try {
|
||||||
|
await requireWrite();
|
||||||
|
} catch (error) {
|
||||||
|
await audit("hosts_share", `host ${hostId}`, {
|
||||||
|
success: false,
|
||||||
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = actingUser();
|
||||||
|
const { PermissionManager } =
|
||||||
|
await import("../utils/permission-manager.js");
|
||||||
|
const access = await PermissionManager.getInstance().canAccessHost(
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
"manage",
|
||||||
|
);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
const result: PluginHostShareResult = {
|
||||||
|
hostId,
|
||||||
|
shared: false,
|
||||||
|
reason: "forbidden",
|
||||||
|
};
|
||||||
|
await audit("hosts_share", `host ${hostId}`, {
|
||||||
|
success: false,
|
||||||
|
errorMessage: "forbidden",
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
createCurrentHostResolutionRepository,
|
||||||
|
createCurrentRbacAccessRepository,
|
||||||
|
} = await import("../database/repositories/factory.js");
|
||||||
|
const ownerId =
|
||||||
|
(await createCurrentHostResolutionRepository().findHostOwnerId(
|
||||||
|
hostId,
|
||||||
|
)) ?? userId;
|
||||||
|
|
||||||
|
if (targets.some((t) => t.type === "user" && t.id === ownerId)) {
|
||||||
|
await audit("hosts_share", `host ${hostId}`, {
|
||||||
|
success: false,
|
||||||
|
errorMessage: "owner",
|
||||||
|
});
|
||||||
|
return { hostId, shared: false, reason: "owner" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt =
|
||||||
|
durationHours && durationHours > 0
|
||||||
|
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
||||||
|
const { SharedHostSecretsManager } =
|
||||||
|
await import("../utils/shared-host-secrets-manager.js");
|
||||||
|
const secretsManager = SharedHostSecretsManager.getInstance();
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const grant = await rbacAccessRepository.upsertHostAccess({
|
||||||
|
hostId,
|
||||||
|
grantedBy: userId,
|
||||||
|
permissionLevel,
|
||||||
|
expiresAt,
|
||||||
|
...(target.type === "user"
|
||||||
|
? { targetType: "user" as const, targetUserId: target.id as string }
|
||||||
|
: {
|
||||||
|
targetType: "role" as const,
|
||||||
|
targetRoleId: target.id as number,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (target.type === "user") {
|
||||||
|
await secretsManager.snapshotForUser(
|
||||||
|
grant.id,
|
||||||
|
hostId,
|
||||||
|
target.id as string,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await secretsManager.snapshotForRole(
|
||||||
|
grant.id,
|
||||||
|
hostId,
|
||||||
|
target.id as number,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A snapshot failure never blocks the grant: the recipient can
|
||||||
|
// still be prompted, or the owner can retry sharing later.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await audit("hosts_share", `host ${hostId}`, { success: true });
|
||||||
|
return { hostId, shared: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
listUsers: async (): Promise<PluginShareableUser[]> => {
|
||||||
|
try {
|
||||||
|
await requireWrite();
|
||||||
|
} catch (error) {
|
||||||
|
await audit("hosts_list_users", "share target picker", {
|
||||||
|
success: false,
|
||||||
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const { createCurrentUserRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
const users = await createCurrentUserRepository().listAll();
|
||||||
|
await audit("hosts_list_users", "share target picker", {
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
return users.map((u) => ({ id: u.id, username: u.username }));
|
||||||
|
},
|
||||||
|
|
||||||
|
listRoles: async (): Promise<PluginShareableRole[]> => {
|
||||||
|
try {
|
||||||
|
await requireWrite();
|
||||||
|
} catch (error) {
|
||||||
|
await audit("hosts_list_roles", "share target picker", {
|
||||||
|
success: false,
|
||||||
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const { createCurrentRoleRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
const roles = await createCurrentRoleRepository().listRoles();
|
||||||
|
await audit("hosts_list_roles", "share target picker", {
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
return roles
|
||||||
|
.filter((r) => !r.isSystem)
|
||||||
|
.map((r) => ({ id: r.id, name: r.name, displayName: r.displayName }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
resolveDatabaseDialect,
|
resolveDatabaseDialect,
|
||||||
} from "../database/db/dialect.js";
|
} from "../database/db/dialect.js";
|
||||||
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
|
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
|
||||||
|
import { createPluginHosts } from "./ctx-hosts.js";
|
||||||
|
|
||||||
export type { PluginModule };
|
export type { PluginModule };
|
||||||
|
|
||||||
@@ -608,6 +609,7 @@ export function createPluginContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hosts: createPluginHosts({ manifest, audit: auditCall }),
|
||||||
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
||||||
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
granted: new Set<string>(),
|
||||||
|
actor: undefined as string | undefined,
|
||||||
|
access: {
|
||||||
|
hasAccess: true,
|
||||||
|
isOwner: true,
|
||||||
|
isShared: false,
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
ownedHosts: [] as Array<Record<string, unknown>>,
|
||||||
|
sharedRows: [] as Array<Record<string, unknown>>,
|
||||||
|
visibleGrants: [] as Array<{ hostId: number }>,
|
||||||
|
ownerId: "user-1",
|
||||||
|
hostById: null as Record<string, unknown> | null,
|
||||||
|
upserts: [] as Array<Record<string, unknown>>,
|
||||||
|
users: [] as Array<{ id: string; username: string }>,
|
||||||
|
roles: [] as Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
displayName: string | null;
|
||||||
|
isSystem: boolean;
|
||||||
|
}>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../plugins/permissions.js", async () => {
|
||||||
|
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||||
|
return {
|
||||||
|
assertCapability: async (
|
||||||
|
pluginId: string,
|
||||||
|
capability: string,
|
||||||
|
declared: readonly string[],
|
||||||
|
) => {
|
||||||
|
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||||
|
throw new PluginCapabilityError(pluginId, capability);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
||||||
|
vi.mock("../../utils/permission-manager.js", () => ({
|
||||||
|
PermissionManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
canAccessHost: async () => h.access,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentHostResolutionRepository: () => ({
|
||||||
|
findHostsByUserId: async () => h.ownedHosts,
|
||||||
|
listHostRowsForAccessList: async () => h.sharedRows,
|
||||||
|
findHostOwnerId: async () => h.ownerId,
|
||||||
|
findHostById: async () => h.hostById,
|
||||||
|
}),
|
||||||
|
createCurrentRoleRepository: () => ({
|
||||||
|
listUserRoleIds: async () => [],
|
||||||
|
listRoles: async () => h.roles,
|
||||||
|
}),
|
||||||
|
createCurrentRbacAccessRepository: () => ({
|
||||||
|
listVisibleHostAccessEntries: async () => h.visibleGrants,
|
||||||
|
upsertHostAccess: async (input: Record<string, unknown>) => {
|
||||||
|
h.upserts.push(input);
|
||||||
|
return { id: h.upserts.length, created: true };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createCurrentUserRepository: () => ({
|
||||||
|
listAll: async () => h.users,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||||
|
SharedHostSecretsManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
snapshotForUser: async () => {},
|
||||||
|
snapshotForRole: async () => {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
||||||
|
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||||
|
|
||||||
|
function manifest(capabilities: string[]) {
|
||||||
|
return {
|
||||||
|
id: "fixture",
|
||||||
|
name: "Fixture",
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities,
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.granted = new Set();
|
||||||
|
h.actor = "user-1";
|
||||||
|
h.access = { hasAccess: true, isOwner: true, isShared: false };
|
||||||
|
h.ownedHosts = [];
|
||||||
|
h.sharedRows = [];
|
||||||
|
h.visibleGrants = [];
|
||||||
|
h.ownerId = "user-1";
|
||||||
|
h.hostById = null;
|
||||||
|
h.upserts = [];
|
||||||
|
h.users = [];
|
||||||
|
h.roles = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.hosts", () => {
|
||||||
|
it("refuses list/get/checkAccess without hosts:read", async () => {
|
||||||
|
const audit = vi.fn(async () => {});
|
||||||
|
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
||||||
|
await expect(hosts.list()).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||||
|
await expect(hosts.get(1)).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||||
|
await expect(hosts.checkAccess(1, "view")).rejects.toBeInstanceOf(
|
||||||
|
PluginCapabilityError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists owned and shared hosts once granted hosts:read", async () => {
|
||||||
|
h.granted = new Set(["hosts:read"]);
|
||||||
|
h.ownedHosts = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
userId: "user-1",
|
||||||
|
name: "own",
|
||||||
|
ip: "10.0.0.1",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
tags: "prod",
|
||||||
|
folder: null,
|
||||||
|
authType: "password",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
h.sharedRows = [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
userId: "user-2",
|
||||||
|
name: "shared",
|
||||||
|
ip: "10.0.0.2",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
tags: null,
|
||||||
|
folder: null,
|
||||||
|
authType: "key",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:read"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
const list = await hosts.list();
|
||||||
|
expect(list.map((h2) => h2.id)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checkAccess reports the granted level", async () => {
|
||||||
|
h.granted = new Set(["hosts:read"]);
|
||||||
|
h.access = {
|
||||||
|
hasAccess: true,
|
||||||
|
isOwner: false,
|
||||||
|
isShared: true,
|
||||||
|
permissionLevel: "edit",
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:read"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
const access = await hosts.checkAccess(1, "edit");
|
||||||
|
expect(access).toMatchObject({ hasAccess: true, permissionLevel: "edit" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses share, listUsers, listRoles without hosts:write, auditing the refusal", async () => {
|
||||||
|
const audit = vi.fn(async () => {});
|
||||||
|
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
||||||
|
await expect(
|
||||||
|
hosts.share(1, [{ type: "user", id: "user-2" }], "view"),
|
||||||
|
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||||
|
await expect(hosts.listUsers()).rejects.toBeInstanceOf(
|
||||||
|
PluginCapabilityError,
|
||||||
|
);
|
||||||
|
await expect(hosts.listRoles()).rejects.toBeInstanceOf(
|
||||||
|
PluginCapabilityError,
|
||||||
|
);
|
||||||
|
expect(audit).toHaveBeenCalledWith(
|
||||||
|
"hosts_share",
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({ success: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares a host it manages and snapshots secrets per target", async () => {
|
||||||
|
h.granted = new Set(["hosts:write"]);
|
||||||
|
h.access = { hasAccess: true, isOwner: true, isShared: false };
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:write"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
const result = await hosts.share(
|
||||||
|
5,
|
||||||
|
[{ type: "user", id: "user-2" }],
|
||||||
|
"view",
|
||||||
|
24,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ hostId: 5, shared: true });
|
||||||
|
expect(h.upserts).toHaveLength(1);
|
||||||
|
expect(h.upserts[0]).toMatchObject({
|
||||||
|
hostId: 5,
|
||||||
|
permissionLevel: "view",
|
||||||
|
targetType: "user",
|
||||||
|
targetUserId: "user-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to share a host the caller does not manage", async () => {
|
||||||
|
h.granted = new Set(["hosts:write"]);
|
||||||
|
h.access = { hasAccess: false, isOwner: false, isShared: false };
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:write"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
const result = await hosts.share(
|
||||||
|
5,
|
||||||
|
[{ type: "user", id: "user-2" }],
|
||||||
|
"view",
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ hostId: 5, shared: false, reason: "forbidden" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists non-system roles and users once granted hosts:write", async () => {
|
||||||
|
h.granted = new Set(["hosts:write"]);
|
||||||
|
h.users = [{ id: "user-1", username: "alice" }];
|
||||||
|
h.roles = [
|
||||||
|
{ id: 1, name: "admin", displayName: "Admin", isSystem: true },
|
||||||
|
{ id: 2, name: "auditor", displayName: "Auditor", isSystem: false },
|
||||||
|
];
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:write"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
expect(await hosts.listUsers()).toEqual([
|
||||||
|
{ id: "user-1", username: "alice" },
|
||||||
|
]);
|
||||||
|
expect(await hosts.listRoles()).toEqual([
|
||||||
|
{ id: 2, name: "auditor", displayName: "Auditor" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
parsePlatformProbe,
|
||||||
|
buildPackageActionCommand,
|
||||||
|
buildPackageRemoveCommand,
|
||||||
|
isValidPackageName,
|
||||||
|
buildSudoCommand,
|
||||||
|
parseUpgradable,
|
||||||
|
} from "@termix/plugin-sdk/host-commands";
|
||||||
|
|
||||||
|
describe("parsePlatformProbe", () => {
|
||||||
|
it("prefers dnf over yum when both are present", () => {
|
||||||
|
const out = ["dnf=1", "yum=1", "apt=0", "pacman=0"].join("\n");
|
||||||
|
expect(parsePlatformProbe(out).pkg).toBe("dnf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the pretty OS name and tool flags", () => {
|
||||||
|
const out = [
|
||||||
|
"systemd=1",
|
||||||
|
"apt=1",
|
||||||
|
"dnf=0",
|
||||||
|
"yum=0",
|
||||||
|
"pacman=0",
|
||||||
|
"certbot=1",
|
||||||
|
"acmesh=0",
|
||||||
|
"docker=1",
|
||||||
|
"os=Debian GNU/Linux 12 (bookworm)",
|
||||||
|
].join("\n");
|
||||||
|
const info = parsePlatformProbe(out);
|
||||||
|
expect(info).toMatchObject({
|
||||||
|
hasSystemd: true,
|
||||||
|
pkg: "apt",
|
||||||
|
hasCertbot: true,
|
||||||
|
hasAcmeSh: false,
|
||||||
|
hasDocker: true,
|
||||||
|
osPrettyName: "Debian GNU/Linux 12 (bookworm)",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nulls the OS name when the probe line is empty", () => {
|
||||||
|
expect(parsePlatformProbe("os=").osPrettyName).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildPackageActionCommand", () => {
|
||||||
|
it("builds install and upgrade-all commands per package manager", () => {
|
||||||
|
expect(buildPackageActionCommand("apt", "install", "curl")).toContain(
|
||||||
|
"apt-get -y install curl",
|
||||||
|
);
|
||||||
|
expect(buildPackageActionCommand("dnf", "upgrade-all")).toBe(
|
||||||
|
"dnf -y upgrade",
|
||||||
|
);
|
||||||
|
expect(buildPackageActionCommand("yum", "install", "curl")).toBe(
|
||||||
|
"yum -y install curl",
|
||||||
|
);
|
||||||
|
expect(buildPackageActionCommand("pacman", "upgrade-all")).toBe(
|
||||||
|
"pacman -Syu --noconfirm",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no package manager was detected", () => {
|
||||||
|
expect(buildPackageActionCommand(null, "install", "curl")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildPackageRemoveCommand", () => {
|
||||||
|
it("builds the correct remove command per package manager", () => {
|
||||||
|
expect(buildPackageRemoveCommand("apt", "curl")).toContain(
|
||||||
|
"apt-get -y remove curl",
|
||||||
|
);
|
||||||
|
expect(buildPackageRemoveCommand("dnf", "curl")).toBe("dnf -y remove curl");
|
||||||
|
expect(buildPackageRemoveCommand("yum", "curl")).toBe("yum -y remove curl");
|
||||||
|
expect(buildPackageRemoveCommand("pacman", "curl")).toBe(
|
||||||
|
"pacman -R --noconfirm curl",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no package manager was detected", () => {
|
||||||
|
expect(buildPackageRemoveCommand(null, "curl")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isValidPackageName", () => {
|
||||||
|
it("accepts ordinary package names", () => {
|
||||||
|
expect(isValidPackageName("curl")).toBe(true);
|
||||||
|
expect(isValidPackageName("lib32-glibc")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects shell metacharacters and empty strings", () => {
|
||||||
|
expect(isValidPackageName("curl; rm -rf /")).toBe(false);
|
||||||
|
expect(isValidPackageName("")).toBe(false);
|
||||||
|
expect(isValidPackageName(123)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildSudoCommand", () => {
|
||||||
|
it("single-quotes the password and wraps the marker echo around the command", () => {
|
||||||
|
const cmd = buildSudoCommand("uptime", "p'w");
|
||||||
|
expect(cmd).toContain("sudo -S -p ''");
|
||||||
|
expect(cmd).toContain("__TX_SUDO_OK__");
|
||||||
|
expect(cmd).toContain("uptime");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseUpgradable", () => {
|
||||||
|
it("parses apt list --upgradable output", () => {
|
||||||
|
const out = "curl/stable 7.88.0 amd64 [upgradable from: 7.87.0]";
|
||||||
|
expect(parseUpgradable("apt", out)).toEqual([
|
||||||
|
{ name: "curl", newVersion: "7.88.0", currentVersion: "7.87.0" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list for no package manager", () => {
|
||||||
|
expect(parseUpgradable(null, "anything")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user