This commit is contained in:
2026-06-25 14:43:13 -05:00
parent 6f77fac58e
commit aaa6a9ee26
25 changed files with 1542 additions and 67 deletions

View File

@@ -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 || [] });
}
}

View File

@@ -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');

View File

@@ -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 || []
};
}

View File

@@ -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();
});

View 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);
}

View File

@@ -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
);

View File

@@ -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
};

View File

@@ -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);
}

View 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);
}

View File

@@ -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);

View 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?.();
}

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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') },

View File

@@ -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);
});

View File

@@ -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/);