Initial
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
import { hostSchema, vcenterImportSchema, vcenterPreviewSchema } from '../forms/schemas.js';
|
||||
import { hostSchema, vcenterConnectionSchema, vcenterImportSchema, vcenterPreviewSchema } from '../forms/schemas.js';
|
||||
import { createHost, deleteHost, findVisibleHostByIdentity, listHosts, updateHost } from '../models/hostModel.js';
|
||||
import { buildHostPayloadFromVm, previewVCenterHosts, vcenterStatus } from '../services/vcenterService.js';
|
||||
import { createHostGroup, deleteHostGroup, getVisibleHostGroup, listHostGroups, syncHostGroup, syncDynamicHostGroups, updateHostGroup } from '../models/hostGroupModel.js';
|
||||
import {
|
||||
createVCenterConnection,
|
||||
deleteVCenterConnection,
|
||||
getVCenterConnection,
|
||||
listVCenterConnections,
|
||||
recordVCenterConnectionTest,
|
||||
updateVCenterConnection
|
||||
} from '../models/vcenterConnectionModel.js';
|
||||
import { buildHostPayloadFromVm, previewVCenterHosts, testVCenterConnection, vcenterStatus } from '../services/vcenterService.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listHosts(req.user.id));
|
||||
@@ -23,15 +32,58 @@ export function destroy(req, res) {
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function listGroups(req, res) {
|
||||
res.json(listHostGroups(req.user.id));
|
||||
}
|
||||
|
||||
export function createGroup(req, res) {
|
||||
try {
|
||||
res.status(201).json(createHostGroup(req.body, req.user.id));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export function updateGroup(req, res) {
|
||||
try {
|
||||
const group = updateHostGroup(req.params.groupId, req.body, req.user.id);
|
||||
if (!group) return res.status(404).json({ error: 'Host group not found' });
|
||||
res.json(group);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteGroup(req, res) {
|
||||
deleteHostGroup(req.params.groupId, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function syncGroup(req, res) {
|
||||
const group = getVisibleHostGroup(req.params.groupId, req.user.id);
|
||||
if (!group) return res.status(404).json({ error: 'Host group not found' });
|
||||
if (group.mode !== 'dynamic') return res.status(400).json({ error: 'Only dynamic host groups can be synced.' });
|
||||
res.json(syncHostGroup(group.id));
|
||||
}
|
||||
|
||||
export function syncGroups(req, res) {
|
||||
res.json(syncDynamicHostGroups());
|
||||
}
|
||||
|
||||
export function vcenterImportStatus(req, res) {
|
||||
res.json(vcenterStatus());
|
||||
res.json({
|
||||
defaultConnection: vcenterStatus(),
|
||||
connections: listVCenterConnections(req.user.id)
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewVCenterImport(req, res) {
|
||||
const parsed = vcenterPreviewSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter preview payload' });
|
||||
try {
|
||||
res.json(await previewVCenterHosts(parsed.data));
|
||||
const connection = parsed.data.connectionId ? getVCenterConnection(parsed.data.connectionId, req.user.id, true) : null;
|
||||
if (parsed.data.connectionId && !connection) return res.status(404).json({ error: 'vCenter connection not found' });
|
||||
res.json(await previewVCenterHosts({ ...parsed.data, connection }));
|
||||
} catch (err) {
|
||||
res.status(err.statusCode || 502).json({ error: err.message, trace: err.trace || [] });
|
||||
}
|
||||
@@ -41,7 +93,9 @@ export async function importVCenterHosts(req, res) {
|
||||
const parsed = vcenterImportSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter import payload' });
|
||||
try {
|
||||
const preview = await previewVCenterHosts(parsed.data);
|
||||
const connection = parsed.data.connectionId ? getVCenterConnection(parsed.data.connectionId, req.user.id, true) : null;
|
||||
if (parsed.data.connectionId && !connection) return res.status(404).json({ error: 'vCenter connection not found' });
|
||||
const preview = await previewVCenterHosts({ ...parsed.data, connection });
|
||||
const selected = new Set(parsed.data.vmIds || []);
|
||||
const candidates = selected.size
|
||||
? preview.candidates.filter((candidate) => selected.has(candidate.id))
|
||||
@@ -64,3 +118,40 @@ export async function importVCenterHosts(req, res) {
|
||||
res.status(err.statusCode || 502).json({ error: err.message, trace: err.trace || [] });
|
||||
}
|
||||
}
|
||||
|
||||
export function listVCenterConnectionRecords(req, res) {
|
||||
res.json(listVCenterConnections(req.user.id));
|
||||
}
|
||||
|
||||
export function createVCenterConnectionRecord(req, res) {
|
||||
const parsed = vcenterConnectionSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter connection payload' });
|
||||
if (!parsed.data.password) return res.status(400).json({ error: 'A vCenter password is required when creating a connection' });
|
||||
res.status(201).json(createVCenterConnection(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function updateVCenterConnectionRecord(req, res) {
|
||||
const parsed = vcenterConnectionSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter connection payload' });
|
||||
const connection = updateVCenterConnection(req.params.connectionId, parsed.data, req.user.id);
|
||||
if (!connection) return res.status(404).json({ error: 'vCenter connection not found' });
|
||||
res.json(connection);
|
||||
}
|
||||
|
||||
export function deleteVCenterConnectionRecord(req, res) {
|
||||
deleteVCenterConnection(req.params.connectionId, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export async function testVCenterConnectionRecord(req, res) {
|
||||
const connection = getVCenterConnection(req.params.connectionId, req.user.id, true);
|
||||
if (!connection) return res.status(404).json({ error: 'vCenter connection not found' });
|
||||
try {
|
||||
const result = await testVCenterConnection(connection);
|
||||
recordVCenterConnectionTest(req.params.connectionId, 'success', result.message);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
recordVCenterConnectionTest(req.params.connectionId, 'failed', err.message);
|
||||
res.status(err.statusCode || 502).json({ error: err.message, trace: err.trace || [] });
|
||||
}
|
||||
}
|
||||
|
||||
61
server/db.js
61
server/db.js
@@ -93,10 +93,60 @@ export function migrate() {
|
||||
credential_id TEXT REFERENCES credentials(id) ON DELETE SET NULL,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
notes TEXT,
|
||||
source_type TEXT NOT NULL DEFAULT 'manual',
|
||||
source_connection_id TEXT,
|
||||
source_ref TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vcenter_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
secret_cipher TEXT NOT NULL,
|
||||
secret_iv TEXT NOT NULL,
|
||||
secret_tag TEXT NOT NULL,
|
||||
api_mode TEXT NOT NULL DEFAULT 'auto',
|
||||
request_timeout_ms INTEGER NOT NULL DEFAULT 20000,
|
||||
allow_untrusted_tls INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_test_status TEXT,
|
||||
last_test_message TEXT,
|
||||
last_test_at TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
mode TEXT NOT NULL DEFAULT 'manual',
|
||||
match_mode TEXT NOT NULL DEFAULT 'all',
|
||||
rules TEXT NOT NULL DEFAULT '[]',
|
||||
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
last_synced_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_group_members (
|
||||
host_group_id TEXT NOT NULL REFERENCES host_groups(id) ON DELETE CASCADE,
|
||||
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (host_group_id, host_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
|
||||
@@ -384,6 +434,12 @@ export function migrate() {
|
||||
PRIMARY KEY (runplan_id, host_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runplan_host_groups (
|
||||
runplan_id TEXT NOT NULL REFERENCES runplans(id) ON DELETE CASCADE,
|
||||
host_group_id TEXT NOT NULL REFERENCES host_groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (runplan_id, host_group_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
runplan_id TEXT REFERENCES runplans(id) ON DELETE SET NULL,
|
||||
@@ -450,6 +506,11 @@ export function migrate() {
|
||||
ensureColumn('hosts', 'visibility', "TEXT NOT NULL DEFAULT 'shared'");
|
||||
ensureColumn('hosts', 'owner_user_id', 'TEXT REFERENCES users(id) ON DELETE SET NULL');
|
||||
ensureColumn('hosts', 'group_id', 'TEXT REFERENCES groups(id) ON DELETE SET NULL');
|
||||
// Host provenance is hidden from normal manual edit forms but used by
|
||||
// automation gates such as vCenter power-state checks before execution.
|
||||
ensureColumn('hosts', 'source_type', "TEXT NOT NULL DEFAULT 'manual'");
|
||||
ensureColumn('hosts', 'source_connection_id', 'TEXT');
|
||||
ensureColumn('hosts', 'source_ref', 'TEXT');
|
||||
// Tenant write-back is opt-in per Graph connection. Default 0 so existing
|
||||
// read-only connections can never be used to change a tenant by accident.
|
||||
ensureColumn('graph_connections', 'allow_write', 'INTEGER NOT NULL DEFAULT 0');
|
||||
|
||||
@@ -60,6 +60,22 @@ export const hostSchema = z.object({
|
||||
tags: z.array(z.string()).default([]),
|
||||
notes: z.string().optional(),
|
||||
visibility: visibilitySchema.default('shared'),
|
||||
groupId: z.string().nullable().optional(),
|
||||
sourceType: z.enum(['manual', 'vmware']).optional().default('manual'),
|
||||
sourceConnectionId: z.string().nullable().optional(),
|
||||
sourceRef: z.string().max(300).optional().default('')
|
||||
});
|
||||
|
||||
export const vcenterConnectionSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
baseUrl: z.string().url(),
|
||||
username: z.string().min(1).max(200),
|
||||
password: z.string().optional().default(''),
|
||||
apiMode: z.enum(['auto', 'api', 'rest']).optional().default('auto'),
|
||||
requestTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional().default(20000),
|
||||
allowUntrustedTls: z.boolean().optional().default(false),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
visibility: visibilitySchema.default('shared'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
@@ -70,6 +86,7 @@ const vcenterFilterSchema = z.object({
|
||||
});
|
||||
|
||||
export const vcenterPreviewSchema = z.object({
|
||||
connectionId: z.string().nullable().optional(),
|
||||
filter: vcenterFilterSchema.optional().default({}),
|
||||
limit: z.coerce.number().int().min(1).max(500).optional().default(100)
|
||||
});
|
||||
@@ -84,6 +101,23 @@ export const vcenterImportSchema = vcenterPreviewSchema.extend({
|
||||
tags: z.array(z.string().max(80)).optional().default([])
|
||||
});
|
||||
|
||||
export const hostGroupRuleSchema = z.object({
|
||||
field: z.enum(['name', 'fqdn', 'address', 'tags', 'transport', 'sourceType', 'osFamily']).default('name'),
|
||||
operator: z.enum(['contains', 'equals', 'startsWith', 'endsWith']).default('contains'),
|
||||
value: z.string().min(1).max(200)
|
||||
});
|
||||
|
||||
export const hostGroupSchema = z.object({
|
||||
name: z.string().min(1).max(160),
|
||||
description: z.string().max(600).optional().default(''),
|
||||
mode: z.enum(['manual', 'dynamic']).default('manual'),
|
||||
matchMode: z.enum(['all', 'any']).optional().default('all'),
|
||||
rules: z.array(hostGroupRuleSchema).optional().default([]),
|
||||
hostIds: z.array(z.string()).optional().default([]),
|
||||
visibility: visibilitySchema.default('shared'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const folderSchema = z.object({
|
||||
parentId: z.string().nullable().optional(),
|
||||
name: z.string().min(1),
|
||||
@@ -310,8 +344,11 @@ const scriptPayloadSchema = z.object({
|
||||
});
|
||||
|
||||
export const scriptTestRunSchema = z.object({
|
||||
hostId: z.string().min(1),
|
||||
hostId: z.string().optional().default(''),
|
||||
hostGroupId: z.string().optional().default(''),
|
||||
credentialId: z.string().min(1)
|
||||
}).refine((value) => Boolean(value.hostId || value.hostGroupId), {
|
||||
message: 'Choose a host or host group.'
|
||||
});
|
||||
|
||||
const runPlanPayloadSchema = z.object({
|
||||
@@ -323,7 +360,8 @@ const runPlanPayloadSchema = z.object({
|
||||
groupId: z.string().nullable().optional(),
|
||||
group_id: z.string().nullable().optional(),
|
||||
parallel: z.boolean().or(z.number()).default(true),
|
||||
hostIds: z.array(z.string()).optional()
|
||||
hostIds: z.array(z.string()).optional(),
|
||||
hostGroupIds: z.array(z.string()).optional()
|
||||
});
|
||||
|
||||
export function normalizeScriptPayload(body) {
|
||||
@@ -347,6 +385,7 @@ export function normalizeRunPlanPayload(body) {
|
||||
visibility: parsed.visibility,
|
||||
groupId: parsed.visibility === 'group' ? parsed.groupId ?? parsed.group_id ?? null : null,
|
||||
parallel: Boolean(parsed.parallel),
|
||||
hostIds: parsed.hostIds || []
|
||||
hostIds: parsed.hostIds || [],
|
||||
hostGroupIds: parsed.hostGroupIds || []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { logger } from './services/logger.js';
|
||||
import { effectiveTrustedOrigins } from './models/settingsModel.js';
|
||||
import { startCatalogWorker } from './services/catalogWorker.js';
|
||||
import { startPromotionWorker } from './services/promotionWorker.js';
|
||||
import { startHostGroupWorker } from './services/hostGroupWorker.js';
|
||||
|
||||
// Fail fast in production rather than ship with well-known default secrets.
|
||||
// These defaults are fine for local development but are publicly known, so a
|
||||
@@ -63,4 +64,5 @@ app.listen(config.port, () => {
|
||||
logger.info(`POSHManager API listening on ${config.port}`);
|
||||
startCatalogWorker();
|
||||
startPromotionWorker();
|
||||
startHostGroupWorker();
|
||||
});
|
||||
|
||||
185
server/models/hostGroupModel.js
Normal file
185
server/models/hostGroupModel.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import { db, id, json, now, parseJson, visibleClause } from '../db.js';
|
||||
import { hostGroupSchema } from '../forms/schemas.js';
|
||||
import { camelHostGroup } from './mappers.js';
|
||||
import { filterVisibleHostIds } from './hostModel.js';
|
||||
|
||||
function scopedGroupId(payload) {
|
||||
return payload.visibility === 'group' ? payload.groupId || null : null;
|
||||
}
|
||||
|
||||
function normalizeHostGroup(body) {
|
||||
const parsed = hostGroupSchema.parse(body || {});
|
||||
return {
|
||||
...parsed,
|
||||
groupId: scopedGroupId(parsed),
|
||||
rules: parsed.mode === 'dynamic' ? parsed.rules : [],
|
||||
hostIds: parsed.mode === 'manual' ? parsed.hostIds : []
|
||||
};
|
||||
}
|
||||
|
||||
export function listHostGroups(userId) {
|
||||
return db.prepare(`
|
||||
SELECT hg.*, g.name AS group_name,
|
||||
COALESCE((SELECT COUNT(*) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), 0) AS member_count,
|
||||
COALESCE((SELECT json_group_array(host_id) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), '[]') AS host_ids
|
||||
FROM host_groups hg
|
||||
LEFT JOIN groups g ON g.id = hg.group_id
|
||||
WHERE ${visibleClause('hg')}
|
||||
ORDER BY hg.name
|
||||
`).all(userId, userId).map(camelHostGroup);
|
||||
}
|
||||
|
||||
export function getVisibleHostGroup(groupId, userId) {
|
||||
const row = db.prepare(`
|
||||
SELECT hg.*, g.name AS group_name,
|
||||
COALESCE((SELECT COUNT(*) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), 0) AS member_count,
|
||||
COALESCE((SELECT json_group_array(host_id) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), '[]') AS host_ids
|
||||
FROM host_groups hg
|
||||
LEFT JOIN groups g ON g.id = hg.group_id
|
||||
WHERE hg.id = ? AND ${visibleClause('hg')}
|
||||
`).get(groupId, userId, userId);
|
||||
return row ? camelHostGroup(row) : null;
|
||||
}
|
||||
|
||||
export function createHostGroup(body, userId) {
|
||||
const payload = normalizeHostGroup(body);
|
||||
if (payload.mode === 'dynamic' && !payload.rules.length) throw new Error('Dynamic host groups require at least one rule.');
|
||||
const groupId = id('hgrp');
|
||||
db.prepare(`
|
||||
INSERT INTO host_groups (
|
||||
id, name, description, mode, match_mode, rules, visibility, owner_user_id,
|
||||
group_id, created_by, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
groupId,
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.mode,
|
||||
payload.matchMode,
|
||||
json(payload.rules),
|
||||
payload.visibility,
|
||||
userId,
|
||||
payload.groupId,
|
||||
userId,
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
if (payload.mode === 'manual') replaceHostGroupMembers(groupId, filterVisibleHostIds(payload.hostIds, userId), 'manual');
|
||||
else syncHostGroup(groupId);
|
||||
return getVisibleHostGroup(groupId, userId);
|
||||
}
|
||||
|
||||
export function updateHostGroup(groupId, body, userId) {
|
||||
const existing = db.prepare(`SELECT * FROM host_groups WHERE id = ? AND ${visibleClause()}`).get(groupId, userId, userId);
|
||||
if (!existing) return null;
|
||||
const payload = normalizeHostGroup({
|
||||
name: existing.name,
|
||||
description: existing.description || '',
|
||||
mode: existing.mode,
|
||||
matchMode: existing.match_mode,
|
||||
rules: parseJson(existing.rules, []),
|
||||
visibility: existing.visibility,
|
||||
groupId: existing.group_id,
|
||||
...body
|
||||
});
|
||||
if (payload.mode === 'dynamic' && !payload.rules.length) throw new Error('Dynamic host groups require at least one rule.');
|
||||
db.prepare(`
|
||||
UPDATE host_groups
|
||||
SET name = ?, description = ?, mode = ?, match_mode = ?, rules = ?,
|
||||
visibility = ?, group_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.mode,
|
||||
payload.matchMode,
|
||||
json(payload.rules),
|
||||
payload.visibility,
|
||||
payload.groupId,
|
||||
now(),
|
||||
groupId
|
||||
);
|
||||
if (payload.mode === 'manual') replaceHostGroupMembers(groupId, filterVisibleHostIds(payload.hostIds, userId), 'manual');
|
||||
else syncHostGroup(groupId);
|
||||
return getVisibleHostGroup(groupId, userId);
|
||||
}
|
||||
|
||||
export function deleteHostGroup(groupId, userId) {
|
||||
db.prepare(`DELETE FROM host_groups WHERE id = ? AND ${visibleClause()}`).run(groupId, userId, userId);
|
||||
}
|
||||
|
||||
export function filterVisibleHostGroupIds(groupIds = [], userId) {
|
||||
if (!groupIds.length) return [];
|
||||
const visible = new Set(
|
||||
db.prepare(`SELECT id FROM host_groups WHERE ${visibleClause()}`).all(userId, userId).map((row) => row.id)
|
||||
);
|
||||
return groupIds.filter((groupId) => visible.has(groupId));
|
||||
}
|
||||
|
||||
export function syncHostGroup(groupId) {
|
||||
const group = db.prepare('SELECT * FROM host_groups WHERE id = ?').get(groupId);
|
||||
if (!group || group.mode !== 'dynamic') return { groupId, matched: 0, skipped: true };
|
||||
const ownerId = group.owner_user_id || group.created_by;
|
||||
const hosts = ownerId
|
||||
? db.prepare(`SELECT * FROM hosts WHERE ${visibleClause()} ORDER BY name`).all(ownerId, ownerId)
|
||||
: db.prepare("SELECT * FROM hosts WHERE visibility = 'shared' ORDER BY name").all();
|
||||
const rules = parseJson(group.rules, []);
|
||||
const matched = hosts.filter((host) => hostMatchesRules(host, rules, group.match_mode)).map((host) => host.id);
|
||||
replaceHostGroupMembers(groupId, matched, 'rule');
|
||||
db.prepare('UPDATE host_groups SET last_synced_at = ?, updated_at = ? WHERE id = ?').run(now(), now(), groupId);
|
||||
return { groupId, matched: matched.length, skipped: false };
|
||||
}
|
||||
|
||||
export function syncDynamicHostGroups() {
|
||||
const groups = db.prepare("SELECT id FROM host_groups WHERE mode = 'dynamic'").all();
|
||||
const summary = { evaluated: 0, synced: 0, matched: 0, errors: 0 };
|
||||
for (const group of groups) {
|
||||
summary.evaluated += 1;
|
||||
try {
|
||||
const result = syncHostGroup(group.id);
|
||||
if (!result.skipped) {
|
||||
summary.synced += 1;
|
||||
summary.matched += result.matched;
|
||||
}
|
||||
} catch {
|
||||
summary.errors += 1;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function replaceHostGroupMembers(groupId, hostIds, source) {
|
||||
db.prepare('DELETE FROM host_group_members WHERE host_group_id = ?').run(groupId);
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR IGNORE INTO host_group_members (host_group_id, host_id, source, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const hostId of [...new Set(hostIds)]) stmt.run(groupId, hostId, source, now());
|
||||
}
|
||||
|
||||
export function hostMatchesRules(host, rules = [], matchMode = 'all') {
|
||||
if (!rules.length) return false;
|
||||
const results = rules.map((rule) => hostMatchesRule(host, rule));
|
||||
return matchMode === 'any' ? results.some(Boolean) : results.every(Boolean);
|
||||
}
|
||||
|
||||
function hostMatchesRule(host, rule) {
|
||||
const values = hostRuleValues(host, rule.field);
|
||||
const needle = String(rule.value || '').toLowerCase();
|
||||
return values.some((value) => compareRuleValue(String(value || '').toLowerCase(), needle, rule.operator));
|
||||
}
|
||||
|
||||
function hostRuleValues(host, field) {
|
||||
if (field === 'tags') return parseJson(host.tags, []);
|
||||
if (field === 'sourceType') return [host.source_type || 'manual'];
|
||||
if (field === 'osFamily') return [host.os_family || ''];
|
||||
return [host[field] || ''];
|
||||
}
|
||||
|
||||
function compareRuleValue(value, needle, operator = 'contains') {
|
||||
if (operator === 'equals') return value === needle;
|
||||
if (operator === 'startsWith') return value.startsWith(needle);
|
||||
if (operator === 'endsWith') return value.endsWith(needle);
|
||||
return value.includes(needle);
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import { camelHost } from './mappers.js';
|
||||
|
||||
export function listHosts(userId) {
|
||||
return db.prepare(`
|
||||
SELECT h.*, c.name AS credential_name, g.name AS group_name
|
||||
SELECT h.*, c.name AS credential_name, g.name AS group_name, vc.name AS source_connection_name
|
||||
FROM hosts h
|
||||
LEFT JOIN credentials c ON c.id = h.credential_id
|
||||
LEFT JOIN groups g ON g.id = h.group_id
|
||||
LEFT JOIN vcenter_connections vc ON vc.id = h.source_connection_id
|
||||
WHERE ${visibleClause('h')}
|
||||
ORDER BY h.name
|
||||
`).all(userId, userId).map(camelHost);
|
||||
@@ -20,8 +21,9 @@ export function createHost(payload, userId) {
|
||||
const hostId = id('hst');
|
||||
db.prepare(`
|
||||
INSERT INTO hosts (id, name, fqdn, address, os_family, transport, port, credential_id, tags, notes,
|
||||
visibility, owner_user_id, group_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
visibility, owner_user_id, group_id, source_type, source_connection_id, source_ref,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
hostId,
|
||||
payload.name,
|
||||
@@ -36,6 +38,9 @@ export function createHost(payload, userId) {
|
||||
payload.visibility || 'shared',
|
||||
userId,
|
||||
scopedGroupId(payload),
|
||||
payload.sourceType || 'manual',
|
||||
payload.sourceConnectionId || null,
|
||||
payload.sourceRef || '',
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
@@ -69,7 +74,7 @@ export function updateHost(hostId, payload, userId) {
|
||||
db.prepare(`
|
||||
UPDATE hosts
|
||||
SET name = ?, fqdn = ?, address = ?, os_family = ?, transport = ?, port = ?, credential_id = ?,
|
||||
tags = ?, notes = ?, visibility = ?, group_id = ?, updated_at = ?
|
||||
tags = ?, notes = ?, visibility = ?, group_id = ?, source_type = ?, source_connection_id = ?, source_ref = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.name || existing.name,
|
||||
@@ -83,6 +88,9 @@ export function updateHost(hostId, payload, userId) {
|
||||
payload.notes ?? existing.notes,
|
||||
visibility,
|
||||
visibility === 'group' ? (payload.groupId ?? existing.group_id) : null,
|
||||
payload.sourceType ?? existing.source_type ?? 'manual',
|
||||
payload.sourceConnectionId ?? existing.source_connection_id,
|
||||
payload.sourceRef ?? existing.source_ref ?? '',
|
||||
now(),
|
||||
hostId
|
||||
);
|
||||
|
||||
@@ -37,6 +37,53 @@ export function camelHost(row) {
|
||||
ownerUserId: row.owner_user_id || null,
|
||||
groupId: row.group_id || null,
|
||||
groupName: row.group_name || null,
|
||||
sourceType: row.source_type || 'manual',
|
||||
sourceConnectionId: row.source_connection_id || null,
|
||||
sourceConnectionName: row.source_connection_name || null,
|
||||
sourceRef: row.source_ref || '',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelVCenterConnection(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
baseUrl: row.base_url,
|
||||
username: row.username,
|
||||
hasPassword: Boolean(row.secret_cipher),
|
||||
apiMode: row.api_mode || 'auto',
|
||||
requestTimeoutMs: row.request_timeout_ms || 20000,
|
||||
allowUntrustedTls: Boolean(row.allow_untrusted_tls),
|
||||
enabled: Boolean(row.enabled),
|
||||
lastTestStatus: row.last_test_status || '',
|
||||
lastTestMessage: row.last_test_message || '',
|
||||
lastTestAt: row.last_test_at || '',
|
||||
visibility: row.visibility || 'shared',
|
||||
ownerUserId: row.owner_user_id || null,
|
||||
groupId: row.group_id || null,
|
||||
groupName: row.group_name || null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelHostGroup(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
mode: row.mode || 'manual',
|
||||
matchMode: row.match_mode || 'all',
|
||||
rules: parseJson(row.rules, []),
|
||||
hostIds: parseJson(row.host_ids, []),
|
||||
memberCount: row.member_count || 0,
|
||||
visibility: row.visibility || 'shared',
|
||||
ownerUserId: row.owner_user_id || null,
|
||||
groupId: row.group_id || null,
|
||||
groupName: row.group_name || null,
|
||||
lastSyncedAt: row.last_synced_at || '',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
@@ -142,6 +189,7 @@ export function camelRunPlan(row) {
|
||||
groupName: row.group_name || null,
|
||||
parallel: Boolean(row.parallel),
|
||||
hostCount: row.host_count || 0,
|
||||
hostGroupCount: row.host_group_count || 0,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
|
||||
@@ -2,11 +2,20 @@ import { db, id, now, visibleClause } from '../db.js';
|
||||
import { normalizeRunPlanPayload } from '../forms/schemas.js';
|
||||
import { camelRunPlan } from './mappers.js';
|
||||
import { filterVisibleHostIds } from './hostModel.js';
|
||||
import { filterVisibleHostGroupIds } from './hostGroupModel.js';
|
||||
|
||||
export function listRunPlans(userId) {
|
||||
return db.prepare(`
|
||||
SELECT r.*, s.name AS script_name, g.name AS group_name,
|
||||
(SELECT COUNT(*) FROM runplan_hosts rh WHERE rh.runplan_id = r.id) AS host_count
|
||||
(SELECT COUNT(DISTINCT host_id) FROM (
|
||||
SELECT rh.host_id AS host_id FROM runplan_hosts rh WHERE rh.runplan_id = r.id
|
||||
UNION
|
||||
SELECT hgm.host_id AS host_id
|
||||
FROM runplan_host_groups rhg
|
||||
JOIN host_group_members hgm ON hgm.host_group_id = rhg.host_group_id
|
||||
WHERE rhg.runplan_id = r.id
|
||||
)) AS host_count,
|
||||
(SELECT COUNT(*) FROM runplan_host_groups rhg WHERE rhg.runplan_id = r.id) AS host_group_count
|
||||
FROM runplans r
|
||||
JOIN scripts s ON s.id = r.script_id
|
||||
LEFT JOIN groups g ON g.id = r.group_id
|
||||
@@ -23,13 +32,22 @@ export function createRunPlan(body, userId) {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(runplanId, payload.name, payload.description, payload.scriptId, payload.visibility, userId, payload.groupId, payload.parallel ? 1 : 0, now(), now());
|
||||
replaceRunPlanHosts(runplanId, filterVisibleHostIds(payload.hostIds, userId));
|
||||
replaceRunPlanHostGroups(runplanId, filterVisibleHostGroupIds(payload.hostGroupIds, userId));
|
||||
return loadRunPlan(runplanId);
|
||||
}
|
||||
|
||||
export function loadRunPlan(runplanId) {
|
||||
const runplan = db.prepare(`
|
||||
SELECT r.*, s.name AS script_name, g.name AS group_name,
|
||||
(SELECT COUNT(*) FROM runplan_hosts rh WHERE rh.runplan_id = r.id) AS host_count
|
||||
(SELECT COUNT(DISTINCT host_id) FROM (
|
||||
SELECT rh.host_id AS host_id FROM runplan_hosts rh WHERE rh.runplan_id = r.id
|
||||
UNION
|
||||
SELECT hgm.host_id AS host_id
|
||||
FROM runplan_host_groups rhg
|
||||
JOIN host_group_members hgm ON hgm.host_group_id = rhg.host_group_id
|
||||
WHERE rhg.runplan_id = r.id
|
||||
)) AS host_count,
|
||||
(SELECT COUNT(*) FROM runplan_host_groups rhg WHERE rhg.runplan_id = r.id) AS host_group_count
|
||||
FROM runplans r
|
||||
JOIN scripts s ON s.id = r.script_id
|
||||
LEFT JOIN groups g ON g.id = r.group_id
|
||||
@@ -37,7 +55,8 @@ export function loadRunPlan(runplanId) {
|
||||
`).get(runplanId);
|
||||
if (!runplan) return null;
|
||||
const hostIds = db.prepare('SELECT host_id FROM runplan_hosts WHERE runplan_id = ? ORDER BY host_id').all(runplanId).map((row) => row.host_id);
|
||||
return { ...camelRunPlan(runplan), hostIds };
|
||||
const hostGroupIds = db.prepare('SELECT host_group_id FROM runplan_host_groups WHERE runplan_id = ? ORDER BY host_group_id').all(runplanId).map((row) => row.host_group_id);
|
||||
return { ...camelRunPlan(runplan), hostIds, hostGroupIds };
|
||||
}
|
||||
|
||||
export function loadVisibleRunPlan(runplanId, userId) {
|
||||
@@ -48,12 +67,13 @@ export function loadVisibleRunPlan(runplanId, userId) {
|
||||
export function updateRunPlan(runplanId, body, userId) {
|
||||
const existing = db.prepare(`SELECT * FROM runplans WHERE id = ? AND ${visibleClause()}`).get(runplanId, userId, userId);
|
||||
if (!existing) return null;
|
||||
const payload = normalizeRunPlanPayload({ ...existing, ...body, hostIds: body.hostIds || undefined });
|
||||
const payload = normalizeRunPlanPayload({ ...existing, ...body, hostIds: body.hostIds || undefined, hostGroupIds: body.hostGroupIds || undefined });
|
||||
db.prepare(`
|
||||
UPDATE runplans SET name = ?, description = ?, script_id = ?, visibility = ?, group_id = ?, parallel = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(payload.name, payload.description, payload.scriptId, payload.visibility, payload.groupId, payload.parallel ? 1 : 0, now(), runplanId);
|
||||
if (body.hostIds) replaceRunPlanHosts(runplanId, filterVisibleHostIds(payload.hostIds, userId));
|
||||
if (body.hostGroupIds) replaceRunPlanHostGroups(runplanId, filterVisibleHostGroupIds(payload.hostGroupIds, userId));
|
||||
return loadRunPlan(runplanId);
|
||||
}
|
||||
|
||||
@@ -66,3 +86,9 @@ function replaceRunPlanHosts(runplanId, hostIds) {
|
||||
const stmt = db.prepare('INSERT OR IGNORE INTO runplan_hosts (runplan_id, host_id) VALUES (?, ?)');
|
||||
for (const hostId of hostIds) stmt.run(runplanId, hostId);
|
||||
}
|
||||
|
||||
function replaceRunPlanHostGroups(runplanId, hostGroupIds) {
|
||||
db.prepare('DELETE FROM runplan_host_groups WHERE runplan_id = ?').run(runplanId);
|
||||
const stmt = db.prepare('INSERT OR IGNORE INTO runplan_host_groups (runplan_id, host_group_id) VALUES (?, ?)');
|
||||
for (const groupId of hostGroupIds) stmt.run(runplanId, groupId);
|
||||
}
|
||||
|
||||
135
server/models/vcenterConnectionModel.js
Normal file
135
server/models/vcenterConnectionModel.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import { db, id, now, visibleClause } from '../db.js';
|
||||
import { vcenterConnectionSchema } from '../forms/schemas.js';
|
||||
import { decryptSecret, encryptSecret } from '../services/cryptoStore.js';
|
||||
import { camelVCenterConnection } from './mappers.js';
|
||||
|
||||
function normalizeConnection(body, existing = {}) {
|
||||
const parsed = vcenterConnectionSchema.parse({
|
||||
...existing,
|
||||
...body,
|
||||
password: body.password || ''
|
||||
});
|
||||
return {
|
||||
...parsed,
|
||||
baseUrl: String(parsed.baseUrl || '').trim().replace(/\/+$/, ''),
|
||||
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
|
||||
};
|
||||
}
|
||||
|
||||
export function listVCenterConnections(userId) {
|
||||
return db.prepare(`
|
||||
SELECT c.*, g.name AS group_name
|
||||
FROM vcenter_connections c
|
||||
LEFT JOIN groups g ON g.id = c.group_id
|
||||
WHERE ${visibleClause('c')}
|
||||
ORDER BY c.name
|
||||
`).all(userId, userId).map(camelVCenterConnection);
|
||||
}
|
||||
|
||||
export function getVCenterConnection(connectionId, userId, includeSecret = false) {
|
||||
if (!connectionId) return null;
|
||||
const row = db.prepare(`
|
||||
SELECT c.*, g.name AS group_name
|
||||
FROM vcenter_connections c
|
||||
LEFT JOIN groups g ON g.id = c.group_id
|
||||
WHERE c.id = ? AND ${visibleClause('c')}
|
||||
`).get(connectionId, userId, userId);
|
||||
if (!row) return null;
|
||||
return includeSecret ? { ...row, password: decryptSecret(row) } : camelVCenterConnection(row);
|
||||
}
|
||||
|
||||
export function getVCenterConnectionSystem(connectionId, includeSecret = false) {
|
||||
if (!connectionId) return null;
|
||||
const row = db.prepare('SELECT * FROM vcenter_connections WHERE id = ?').get(connectionId);
|
||||
if (!row) return null;
|
||||
return includeSecret ? { ...row, password: decryptSecret(row) } : camelVCenterConnection(row);
|
||||
}
|
||||
|
||||
export function createVCenterConnection(body, userId) {
|
||||
const payload = normalizeConnection(body);
|
||||
const encrypted = encryptSecret(payload.password);
|
||||
const connectionId = id('vc');
|
||||
db.prepare(`
|
||||
INSERT INTO vcenter_connections (
|
||||
id, name, base_url, username, secret_cipher, secret_iv, secret_tag,
|
||||
api_mode, request_timeout_ms, allow_untrusted_tls, enabled,
|
||||
visibility, owner_user_id, group_id, created_by, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
connectionId,
|
||||
payload.name,
|
||||
payload.baseUrl,
|
||||
payload.username,
|
||||
encrypted.cipher,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
payload.apiMode,
|
||||
payload.requestTimeoutMs,
|
||||
payload.allowUntrustedTls ? 1 : 0,
|
||||
payload.enabled ? 1 : 0,
|
||||
payload.visibility,
|
||||
userId,
|
||||
payload.groupId,
|
||||
userId,
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
return getVCenterConnection(connectionId, userId);
|
||||
}
|
||||
|
||||
export function updateVCenterConnection(connectionId, body, userId) {
|
||||
const existing = getVCenterConnection(connectionId, userId, true);
|
||||
if (!existing) return null;
|
||||
const payload = normalizeConnection(body, {
|
||||
name: existing.name,
|
||||
baseUrl: existing.base_url,
|
||||
username: existing.username,
|
||||
apiMode: existing.api_mode,
|
||||
requestTimeoutMs: existing.request_timeout_ms,
|
||||
allowUntrustedTls: Boolean(existing.allow_untrusted_tls),
|
||||
enabled: Boolean(existing.enabled),
|
||||
visibility: existing.visibility,
|
||||
groupId: existing.group_id
|
||||
});
|
||||
const encrypted = payload.password ? encryptSecret(payload.password) : {
|
||||
cipher: existing.secret_cipher,
|
||||
iv: existing.secret_iv,
|
||||
tag: existing.secret_tag
|
||||
};
|
||||
db.prepare(`
|
||||
UPDATE vcenter_connections
|
||||
SET name = ?, base_url = ?, username = ?, secret_cipher = ?, secret_iv = ?, secret_tag = ?,
|
||||
api_mode = ?, request_timeout_ms = ?, allow_untrusted_tls = ?, enabled = ?,
|
||||
visibility = ?, group_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.name,
|
||||
payload.baseUrl,
|
||||
payload.username,
|
||||
encrypted.cipher,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
payload.apiMode,
|
||||
payload.requestTimeoutMs,
|
||||
payload.allowUntrustedTls ? 1 : 0,
|
||||
payload.enabled ? 1 : 0,
|
||||
payload.visibility,
|
||||
payload.groupId,
|
||||
now(),
|
||||
connectionId
|
||||
);
|
||||
return getVCenterConnection(connectionId, userId);
|
||||
}
|
||||
|
||||
export function deleteVCenterConnection(connectionId, userId) {
|
||||
db.prepare(`DELETE FROM vcenter_connections WHERE id = ? AND ${visibleClause()}`).run(connectionId, userId, userId);
|
||||
}
|
||||
|
||||
export function recordVCenterConnectionTest(connectionId, status, message) {
|
||||
db.prepare(`
|
||||
UPDATE vcenter_connections
|
||||
SET last_test_status = ?, last_test_message = ?, last_test_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(status, message || '', now(), now(), connectionId);
|
||||
}
|
||||
@@ -1,12 +1,42 @@
|
||||
import { Router } from 'express';
|
||||
import { create, destroy, importVCenterHosts, index, previewVCenterImport, update, vcenterImportStatus } from '../controllers/hostController.js';
|
||||
import {
|
||||
create,
|
||||
createGroup,
|
||||
createVCenterConnectionRecord,
|
||||
deleteGroup,
|
||||
deleteVCenterConnectionRecord,
|
||||
destroy,
|
||||
importVCenterHosts,
|
||||
index,
|
||||
listGroups,
|
||||
listVCenterConnectionRecords,
|
||||
previewVCenterImport,
|
||||
syncGroup,
|
||||
syncGroups,
|
||||
testVCenterConnectionRecord,
|
||||
update,
|
||||
updateGroup,
|
||||
updateVCenterConnectionRecord,
|
||||
vcenterImportStatus
|
||||
} from '../controllers/hostController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const hostRoutes = Router();
|
||||
|
||||
hostRoutes.get('/import/vcenter/status', requireAuth, vcenterImportStatus);
|
||||
hostRoutes.get('/import/vcenter/connections', requireAuth, listVCenterConnectionRecords);
|
||||
hostRoutes.post('/import/vcenter/connections', requireAuth, createVCenterConnectionRecord);
|
||||
hostRoutes.put('/import/vcenter/connections/:connectionId', requireAuth, updateVCenterConnectionRecord);
|
||||
hostRoutes.delete('/import/vcenter/connections/:connectionId', requireAuth, deleteVCenterConnectionRecord);
|
||||
hostRoutes.post('/import/vcenter/connections/:connectionId/test', requireAuth, testVCenterConnectionRecord);
|
||||
hostRoutes.post('/import/vcenter/preview', requireAuth, previewVCenterImport);
|
||||
hostRoutes.post('/import/vcenter', requireAuth, importVCenterHosts);
|
||||
hostRoutes.get('/groups', requireAuth, listGroups);
|
||||
hostRoutes.post('/groups', requireAuth, createGroup);
|
||||
hostRoutes.post('/groups/sync', requireAuth, syncGroups);
|
||||
hostRoutes.put('/groups/:groupId', requireAuth, updateGroup);
|
||||
hostRoutes.delete('/groups/:groupId', requireAuth, deleteGroup);
|
||||
hostRoutes.post('/groups/:groupId/sync', requireAuth, syncGroup);
|
||||
hostRoutes.get('/', requireAuth, index);
|
||||
hostRoutes.post('/', requireAuth, create);
|
||||
hostRoutes.put('/:id', requireAuth, update);
|
||||
|
||||
41
server/services/hostGroupWorker.js
Normal file
41
server/services/hostGroupWorker.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getStringSetting } from '../models/settingsModel.js';
|
||||
import { syncDynamicHostGroups } from '../models/hostGroupModel.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
let timer = null;
|
||||
let running = false;
|
||||
|
||||
export function hostGroupSyncIntervalMinutes() {
|
||||
const minutes = Number(getStringSetting('host_group_sync_interval_minutes', process.env.HOST_GROUP_SYNC_INTERVAL_MINUTES || '60'));
|
||||
return Number.isFinite(minutes) ? minutes : 60;
|
||||
}
|
||||
|
||||
export function startHostGroupWorker() {
|
||||
const minutes = hostGroupSyncIntervalMinutes();
|
||||
if (minutes <= 0) return null;
|
||||
running = true;
|
||||
scheduleNext(minutes);
|
||||
logger.info(`host group sync scheduled every ${minutes} minute(s)`);
|
||||
return timer;
|
||||
}
|
||||
|
||||
export function stopHostGroupWorker() {
|
||||
running = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function scheduleNext(minutes) {
|
||||
if (!running || minutes <= 0) return;
|
||||
timer = setTimeout(() => {
|
||||
try {
|
||||
const summary = syncDynamicHostGroups();
|
||||
logger.info('host group sync complete', summary);
|
||||
} catch (error) {
|
||||
logger.error('host group sync failed', { error: error.message });
|
||||
} finally {
|
||||
scheduleNext(hostGroupSyncIntervalMinutes());
|
||||
}
|
||||
}, minutes * 60 * 1000);
|
||||
timer.unref?.();
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { logger } from './logger.js';
|
||||
import { listExecutableAssets } from '../models/assetModel.js';
|
||||
import { listExecutableCustomVariables } from '../models/variableModel.js';
|
||||
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
|
||||
import { getVCenterConnectionSystem } from '../models/vcenterConnectionModel.js';
|
||||
import { getVCenterVmPowerState } from './vcenterService.js';
|
||||
import { path } from '../utils/pathUtils.js';
|
||||
|
||||
// The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and
|
||||
@@ -45,12 +47,18 @@ export function executeRunPlan(runplanId, userId) {
|
||||
const hosts = db.prepare(`
|
||||
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
|
||||
c.secret_cipher, c.secret_iv, c.secret_tag
|
||||
FROM runplan_hosts rh
|
||||
JOIN hosts h ON h.id = rh.host_id
|
||||
FROM hosts h
|
||||
LEFT JOIN credentials c ON c.id = h.credential_id
|
||||
WHERE rh.runplan_id = ?
|
||||
WHERE h.id IN (
|
||||
SELECT host_id FROM runplan_hosts WHERE runplan_id = ?
|
||||
UNION
|
||||
SELECT hgm.host_id
|
||||
FROM runplan_host_groups rhg
|
||||
JOIN host_group_members hgm ON hgm.host_group_id = rhg.host_group_id
|
||||
WHERE rhg.runplan_id = ?
|
||||
)
|
||||
ORDER BY h.name
|
||||
`).all(runplanId);
|
||||
`).all(runplanId, runplanId);
|
||||
if (hosts.length === 0) throw new Error('RunPlan has no hosts');
|
||||
|
||||
const jobId = id('job');
|
||||
@@ -95,7 +103,31 @@ export function executeScriptTest(scriptId, payload, userId) {
|
||||
`).get(scriptId, userId, userId);
|
||||
if (!script) throw new Error('Script not found');
|
||||
|
||||
const host = db.prepare(`
|
||||
const hosts = payload.hostGroupId ? db.prepare(`
|
||||
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
|
||||
c.secret_cipher, c.secret_iv, c.secret_tag
|
||||
FROM host_group_members hgm
|
||||
JOIN host_groups hg ON hg.id = hgm.host_group_id
|
||||
AND (
|
||||
hg.visibility = 'shared'
|
||||
OR hg.owner_user_id = ?
|
||||
OR hg.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
)
|
||||
JOIN hosts h ON h.id = hgm.host_id
|
||||
AND (
|
||||
h.visibility = 'shared'
|
||||
OR h.owner_user_id = ?
|
||||
OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
)
|
||||
JOIN credentials c ON c.id = ?
|
||||
AND (
|
||||
c.visibility = 'shared'
|
||||
OR c.owner_user_id = ?
|
||||
OR c.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
)
|
||||
WHERE hgm.host_group_id = ?
|
||||
ORDER BY h.name
|
||||
`).all(userId, userId, userId, userId, payload.credentialId, userId, userId, payload.hostGroupId) : [db.prepare(`
|
||||
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
|
||||
c.secret_cipher, c.secret_iv, c.secret_tag
|
||||
FROM hosts h
|
||||
@@ -110,8 +142,8 @@ export function executeScriptTest(scriptId, payload, userId) {
|
||||
OR h.owner_user_id = ?
|
||||
OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
)
|
||||
`).get(payload.credentialId, userId, userId, payload.hostId, userId, userId);
|
||||
if (!host) throw new Error('Visible host and credential are required');
|
||||
`).get(payload.credentialId, userId, userId, payload.hostId, userId, userId)].filter(Boolean);
|
||||
if (!hosts.length) throw new Error('Visible host or host group members and credential are required');
|
||||
|
||||
const jobId = id('job');
|
||||
db.prepare(`
|
||||
@@ -121,7 +153,8 @@ export function executeScriptTest(scriptId, payload, userId) {
|
||||
db.prepare(`
|
||||
INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
|
||||
VALUES (?, ?, ?, 'queued', NULL)
|
||||
`).run(id('jhost'), jobId, host.id);
|
||||
`);
|
||||
for (const host of hosts) insertJobHost.run(id('jhost'), jobId, host.id);
|
||||
|
||||
const executionContext = {
|
||||
jobId,
|
||||
@@ -134,10 +167,10 @@ export function executeScriptTest(scriptId, payload, userId) {
|
||||
assets: listExecutableAssets(userId)
|
||||
};
|
||||
|
||||
appendLog(jobId, host.id, 'system', `Test run requested for ${script.name} using credential ${host.credential_name}`);
|
||||
void runJob(jobId, script, [host], false, executionContext).catch((error) => {
|
||||
for (const host of hosts) appendLog(jobId, host.id, 'system', `Test run requested for ${script.name} using credential ${host.credential_name}`);
|
||||
void runJob(jobId, script, hosts, false, executionContext).catch((error) => {
|
||||
logger.error('script test runner crashed', { jobId, error });
|
||||
appendLog(jobId, host.id, 'stderr', `Script test runner crashed: ${error.message}`);
|
||||
for (const host of hosts) appendLog(jobId, host.id, 'stderr', `Script test runner crashed: ${error.message}`);
|
||||
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId);
|
||||
});
|
||||
|
||||
@@ -168,6 +201,8 @@ async function runHost(jobId, script, host, executionContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await vCenterPowerPreflight(jobId, host))) return;
|
||||
|
||||
const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`);
|
||||
|
||||
try {
|
||||
@@ -183,6 +218,35 @@ async function runHost(jobId, script, host, executionContext) {
|
||||
}
|
||||
}
|
||||
|
||||
async function vCenterPowerPreflight(jobId, host) {
|
||||
if ((host.source_type || 'manual') !== 'vmware') return true;
|
||||
if (!host.source_connection_id || !host.source_ref) {
|
||||
appendLog(jobId, host.id, 'system', 'Host is marked as VMware-sourced, but no vCenter connection or VM reference is stored. Continuing without a power-state gate.');
|
||||
return true;
|
||||
}
|
||||
const connection = getVCenterConnectionSystem(host.source_connection_id, true);
|
||||
if (!connection) {
|
||||
appendLog(jobId, host.id, 'system', `Host is VMware-sourced, but vCenter connection ${host.source_connection_id} no longer exists. Continuing without a power-state gate.`);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const result = await getVCenterVmPowerState(connection, host.source_ref);
|
||||
if (!result.checked) {
|
||||
appendLog(jobId, host.id, 'system', `vCenter power-state check skipped: ${result.reason || 'not enough source metadata'}`);
|
||||
return true;
|
||||
}
|
||||
appendLog(jobId, host.id, 'system', `vCenter power state for VM ${host.source_ref}: ${result.state || 'unknown'} (${result.apiProfile || 'vCenter API'})`);
|
||||
if (result.state && result.state !== 'POWERED_ON') {
|
||||
appendLog(jobId, host.id, 'system', `Skipping ${host.name}; VMware VM is not powered on.`);
|
||||
finishHost(jobId, host.id, 'skipped', 0);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
appendLog(jobId, host.id, 'system', `Unable to verify vCenter power state before execution: ${err.message}. Continuing without a power-state gate.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildWrapper(content, host, executionContext = {}) {
|
||||
// Build PowerShell wrappers without shell interpolation; host values are quoted as PS literals.
|
||||
const runtimeVariables = runtimeVariableValues(host, executionContext);
|
||||
|
||||
@@ -15,6 +15,7 @@ const API_PROFILES = {
|
||||
sessionPath: '/api/session',
|
||||
vmListPath: '/api/vcenter/vm',
|
||||
vmNameParam: 'names',
|
||||
vmDetailPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}`,
|
||||
guestIdentityPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`,
|
||||
guestNetworkingPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`
|
||||
},
|
||||
@@ -24,6 +25,7 @@ const API_PROFILES = {
|
||||
sessionPath: '/rest/com/vmware/cis/session',
|
||||
vmListPath: '/rest/vcenter/vm',
|
||||
vmNameParam: 'filter.names',
|
||||
vmDetailPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}`,
|
||||
guestIdentityPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`,
|
||||
guestNetworkingPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`
|
||||
}
|
||||
@@ -52,6 +54,20 @@ export function getVCenterSettings() {
|
||||
};
|
||||
}
|
||||
|
||||
export function settingsFromVCenterConnection(connection) {
|
||||
if (!connection) return getVCenterSettings();
|
||||
const apiMode = String(connection.api_mode || connection.apiMode || 'auto').toLowerCase();
|
||||
return {
|
||||
enabled: Boolean(connection.enabled ?? true),
|
||||
baseUrl: normalizeBaseUrl(connection.base_url || connection.baseUrl),
|
||||
username: connection.username || '',
|
||||
password: connection.password || '',
|
||||
apiMode: ['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto',
|
||||
requestTimeoutMs: Number(connection.request_timeout_ms || connection.requestTimeoutMs || 20000),
|
||||
allowUntrustedTls: Boolean(connection.allow_untrusted_tls ?? connection.allowUntrustedTls)
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
@@ -69,6 +85,21 @@ export function vcenterStatus() {
|
||||
};
|
||||
}
|
||||
|
||||
export function vcenterConnectionStatus(connection) {
|
||||
const settings = settingsFromVCenterConnection(connection);
|
||||
return {
|
||||
id: connection?.id || '',
|
||||
name: connection?.name || 'Configured default',
|
||||
enabled: settings.enabled,
|
||||
configured: Boolean(settings.baseUrl && settings.username && settings.password),
|
||||
baseUrl: settings.baseUrl,
|
||||
username: settings.username,
|
||||
apiMode: settings.apiMode,
|
||||
requestTimeoutMs: settings.requestTimeoutMs,
|
||||
allowUntrustedTls: settings.allowUntrustedTls
|
||||
};
|
||||
}
|
||||
|
||||
function assertConfigured(settings) {
|
||||
if (!settings.enabled) throw new Error('VMware vCenter integration is disabled in Configuration.');
|
||||
if (!settings.baseUrl) throw new Error('VMware vCenter base URL is missing. Set vcenter_base_url in Configuration.');
|
||||
@@ -361,13 +392,16 @@ export function buildHostPayloadFromVm(candidate, options = {}) {
|
||||
candidate.ipAddresses?.length ? `IP addresses: ${candidate.ipAddresses.join(', ')}.` : 'IP address unavailable from VMware Tools at import time.'
|
||||
].filter(Boolean).join(' '),
|
||||
visibility: options.visibility || 'shared',
|
||||
groupId: options.visibility === 'group' ? options.groupId || null : null
|
||||
groupId: options.visibility === 'group' ? options.groupId || null : null,
|
||||
sourceType: 'vmware',
|
||||
sourceConnectionId: options.connectionId || null,
|
||||
sourceRef: candidate.id || ''
|
||||
};
|
||||
}
|
||||
|
||||
export async function previewVCenterHosts(options = {}) {
|
||||
const trace = [];
|
||||
const settings = getVCenterSettings();
|
||||
const settings = settingsFromVCenterConnection(options.connection);
|
||||
try {
|
||||
assertConfigured(settings);
|
||||
} catch (err) {
|
||||
@@ -405,7 +439,7 @@ export async function previewVCenterHosts(options = {}) {
|
||||
});
|
||||
|
||||
return {
|
||||
status: vcenterStatus(),
|
||||
status: options.connection ? vcenterConnectionStatus(options.connection) : vcenterStatus(),
|
||||
filter: options.filter || {},
|
||||
count: candidates.length,
|
||||
diagnostics,
|
||||
@@ -414,6 +448,50 @@ export async function previewVCenterHosts(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function testVCenterConnection(connection) {
|
||||
const trace = [];
|
||||
const settings = settingsFromVCenterConnection(connection);
|
||||
assertConfigured(settings);
|
||||
const { profile, vmPayload } = await connectAndListVms(settings, {}, trace);
|
||||
return {
|
||||
status: vcenterConnectionStatus(connection),
|
||||
apiProfile: profile.label,
|
||||
count: unwrapList(vmPayload).length,
|
||||
trace,
|
||||
message: `Connected to ${settings.baseUrl} with ${profile.label}; ${unwrapList(vmPayload).length} VM summary row(s) returned.`
|
||||
};
|
||||
}
|
||||
|
||||
export async function getVCenterVmPowerState(connection, vmId, trace = null) {
|
||||
if (!connection || !vmId) return { checked: false, reason: 'Missing vCenter connection or VM reference.' };
|
||||
const settings = settingsFromVCenterConnection(connection);
|
||||
assertConfigured(settings);
|
||||
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];
|
||||
let lastError = null;
|
||||
for (const mode of modes) {
|
||||
const profile = API_PROFILES[mode];
|
||||
try {
|
||||
const sessionId = await createSession(settings, profile, trace);
|
||||
const payload = await vcenterFetch(settings, profile.vmDetailPath(vmId), { sessionId, trace });
|
||||
const value = payload?.value || payload || {};
|
||||
return {
|
||||
checked: true,
|
||||
state: value.power_state || value.powerState || '',
|
||||
apiProfile: profile.label
|
||||
};
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (settings.apiMode !== 'auto' || mode === modes.at(-1) || !shouldTryFallback(err)) throw err;
|
||||
pushTraceInfo(trace, `Falling back after ${profile.label} power-state lookup failed`, {
|
||||
mode,
|
||||
error: err.message,
|
||||
statusCode: err.statusCode
|
||||
});
|
||||
}
|
||||
}
|
||||
throw lastError || new VCenterTraceError('No vCenter API profile could be attempted for power-state lookup.', trace);
|
||||
}
|
||||
|
||||
async function enrichVmsForPreview({ vms, settings, profile, sessionId, trace, filter, limit, diagnostics, candidates }) {
|
||||
let index = 0;
|
||||
async function worker() {
|
||||
|
||||
@@ -35,6 +35,7 @@ export function settingDefinitions() {
|
||||
{ key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') },
|
||||
{ key: 'powershell_bin', value: config.powershellBin, pinned: envProvided('POWERSHELL_BIN') },
|
||||
{ key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') },
|
||||
{ key: 'host_group_sync_interval_minutes', value: process.env.HOST_GROUP_SYNC_INTERVAL_MINUTES || '60', pinned: envProvided('HOST_GROUP_SYNC_INTERVAL_MINUTES') },
|
||||
{ key: 'vcenter_enabled', value: String(config.vcenter.enabled), pinned: envProvided('VCENTER_ENABLED') },
|
||||
{ key: 'vcenter_base_url', value: config.vcenter.baseUrl, pinned: envProvided('VCENTER_BASE_URL') },
|
||||
{ key: 'vcenter_username', value: config.vcenter.username, pinned: envProvided('VCENTER_USERNAME') },
|
||||
|
||||
@@ -3,6 +3,9 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { createHost, listHosts, updateHost, deleteHost, filterVisibleHostIds } = await load('../models/hostModel.js');
|
||||
const { createHostGroup, listHostGroups, syncHostGroup } = await load('../models/hostGroupModel.js');
|
||||
const { createRunPlan, loadRunPlan } = await load('../models/runPlanModel.js');
|
||||
const { db } = await load('../db.js');
|
||||
|
||||
const owner = adminUserId();
|
||||
const other = makeUser({ email: 'other-host@test.local' });
|
||||
@@ -18,6 +21,23 @@ test('shared hosts are visible to everyone', () => {
|
||||
assert.ok(listHosts(other).some((h) => h.id === host.id));
|
||||
});
|
||||
|
||||
test('hosts preserve hidden source provenance', () => {
|
||||
const host = createHost({
|
||||
name: 'VC Imported',
|
||||
address: 'vc-imported.local',
|
||||
osFamily: 'windows',
|
||||
transport: 'winrm',
|
||||
tags: [],
|
||||
visibility: 'shared',
|
||||
sourceType: 'vmware',
|
||||
sourceConnectionId: 'vc_test',
|
||||
sourceRef: 'vm-42'
|
||||
}, owner);
|
||||
assert.equal(host.sourceType, 'vmware');
|
||||
assert.equal(host.sourceConnectionId, 'vc_test');
|
||||
assert.equal(host.sourceRef, 'vm-42');
|
||||
});
|
||||
|
||||
test('a non-owner cannot update or delete a personal host', () => {
|
||||
const host = createHost({ name: 'Locked', address: 'locked.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'personal' }, owner);
|
||||
assert.equal(updateHost(host.id, { name: 'Hacked' }, other), null);
|
||||
@@ -31,3 +51,39 @@ test('filterVisibleHostIds drops hosts the user cannot see', () => {
|
||||
const visibleToOther = filterVisibleHostIds([priv.id, shared.id], other);
|
||||
assert.deepEqual(visibleToOther, [shared.id]);
|
||||
});
|
||||
|
||||
test('manual host groups keep explicit members', () => {
|
||||
const host = createHost({ name: 'ManualMember', address: 'manual-member.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
|
||||
const group = createHostGroup({ name: 'Manual Group', mode: 'manual', hostIds: [host.id], visibility: 'shared' }, owner);
|
||||
assert.equal(group.mode, 'manual');
|
||||
assert.deepEqual(group.hostIds, [host.id]);
|
||||
assert.equal(listHostGroups(owner).some((row) => row.id === group.id), true);
|
||||
});
|
||||
|
||||
test('dynamic host groups sync by rule', () => {
|
||||
const eng = createHost({ name: 'ENG-APP-01', address: 'eng-app-01.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
|
||||
createHost({ name: 'FIN-APP-01', address: 'fin-app-01.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
|
||||
const group = createHostGroup({
|
||||
name: 'Engineering',
|
||||
mode: 'dynamic',
|
||||
matchMode: 'all',
|
||||
rules: [{ field: 'name', operator: 'contains', value: 'ENG' }],
|
||||
visibility: 'shared'
|
||||
}, owner);
|
||||
const synced = syncHostGroup(group.id);
|
||||
const refreshed = listHostGroups(owner).find((row) => row.id === group.id);
|
||||
assert.equal(synced.matched >= 1, true);
|
||||
assert.equal(refreshed.hostIds.includes(eng.id), true);
|
||||
assert.equal(refreshed.hostIds.some((hostId) => hostId !== eng.id && db.prepare('SELECT name FROM hosts WHERE id = ?').get(hostId)?.name === 'FIN-APP-01'), false);
|
||||
});
|
||||
|
||||
test('runplans can target host groups', () => {
|
||||
const script = db.prepare('SELECT id FROM scripts LIMIT 1').get();
|
||||
const host = createHost({ name: 'RUN-GROUP-01', address: 'run-group-01.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
|
||||
const group = createHostGroup({ name: 'Run Group', mode: 'manual', hostIds: [host.id], visibility: 'shared' }, owner);
|
||||
const runplan = createRunPlan({ name: 'Grouped Run', scriptId: script.id, visibility: 'shared', hostIds: [], hostGroupIds: [group.id] }, owner);
|
||||
const loaded = loadRunPlan(runplan.id);
|
||||
assert.deepEqual(loaded.hostGroupIds, [group.id]);
|
||||
assert.equal(loaded.hostCount, 1);
|
||||
assert.equal(loaded.hostGroupCount, 1);
|
||||
});
|
||||
|
||||
@@ -107,13 +107,16 @@ test('buildHostPayloadFromVm attaches credential, tags, transport, and import no
|
||||
guestFullName: 'Microsoft Windows Server 2022',
|
||||
ipAddresses: ['10.42.1.20']
|
||||
},
|
||||
{ credentialId: 'cred_1', transport: 'winrm', tags: ['engineering'], visibility: 'group', groupId: 'grp_1' }
|
||||
{ credentialId: 'cred_1', connectionId: 'vc_1', transport: 'winrm', tags: ['engineering'], visibility: 'group', groupId: 'grp_1' }
|
||||
);
|
||||
|
||||
assert.equal(payload.credentialId, 'cred_1');
|
||||
assert.equal(payload.transport, 'winrm');
|
||||
assert.equal(payload.visibility, 'group');
|
||||
assert.equal(payload.groupId, 'grp_1');
|
||||
assert.equal(payload.sourceType, 'vmware');
|
||||
assert.equal(payload.sourceConnectionId, 'vc_1');
|
||||
assert.equal(payload.sourceRef, 'vm-42');
|
||||
assert.ok(payload.tags.includes('vcenter:vm-42'));
|
||||
assert.ok(payload.tags.includes('engineering'));
|
||||
assert.match(payload.notes, /Imported from VMware vCenter VM vm-42/);
|
||||
|
||||
Reference in New Issue
Block a user