PSA/server/migrations/20251130120003_migrate_software_jsonb_to_normalized.cjs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

247 lines
10 KiB
JavaScript

/**
* Migration: Migrate Software JSONB to Normalized Tables
*
* Populates the new software_catalog and asset_software tables from existing
* installed_software JSONB columns in workstation_assets and server_assets.
*
* This migration:
* 1. Reads all existing JSONB software data
* 2. Creates deduplicated entries in software_catalog
* 3. Creates junction entries in asset_software
* 4. Does NOT drop the original JSONB columns (for rollback safety)
*
* The JSONB columns can be dropped in a future migration after validation.
*
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
// Helper function to normalize software name for matching
const normalizeName = (name) => {
if (!name) return '';
return name.toLowerCase().trim();
};
// Helper function to infer software category from name
const inferCategory = (name) => {
if (!name) return null;
const lower = name.toLowerCase();
if (/chrome|firefox|safari|edge|opera|brave|browser/.test(lower)) return 'Browser';
if (/office|word|excel|powerpoint|outlook|teams|onenote/.test(lower)) return 'Productivity';
if (/visual studio|vscode|intellij|xcode|android studio|eclipse|jetbrains|rider|webstorm|phpstorm|pycharm/.test(lower)) return 'Development';
if (/antivirus|defender|norton|mcafee|sentinelone|crowdstrike|sophos|bitdefender|kaspersky|malwarebytes|firewall|security/.test(lower)) return 'Security';
if (/zoom|teams|slack|discord|skype|webex/.test(lower)) return 'Communication';
if (/adobe|photoshop|illustrator|acrobat|premiere|lightroom|indesign|creative/.test(lower)) return 'Creative';
if (/node|python|java|dotnet|\.net|runtime|framework|sdk|jdk|jre/.test(lower)) return 'Runtime';
if (/driver|nvidia|amd|intel|realtek/.test(lower)) return 'Driver';
return null;
};
// Helper function to determine software type
const inferSoftwareType = (name) => {
if (!name) return 'application';
const lower = name.toLowerCase();
if (/driver/.test(lower)) return 'driver';
if (/update|hotfix|kb\d+|patch/.test(lower)) return 'update';
if (/runtime|framework|redistributable/.test(lower)) return 'system';
return 'application';
};
// Cache for software catalog entries to avoid duplicate lookups
const softwareCatalogCache = new Map(); // key: 'tenant|normalized_name|publisher' -> software_id
// Process workstation_assets
const workstations = await knex('workstation_assets')
.select('tenant', 'asset_id', 'installed_software')
.whereNotNull('installed_software');
console.log(`Processing ${workstations.length} workstations with software data...`);
for (const ws of workstations) {
if (!ws.installed_software) continue;
let softwareList;
try {
softwareList = typeof ws.installed_software === 'string'
? JSON.parse(ws.installed_software)
: ws.installed_software;
} catch (e) {
console.warn(`Failed to parse software for workstation ${ws.asset_id}:`, e.message);
continue;
}
if (!Array.isArray(softwareList) || softwareList.length === 0) continue;
for (const sw of softwareList) {
if (!sw.name) continue;
const normalizedName = normalizeName(sw.name);
const publisher = sw.publisher?.trim() || null;
const cacheKey = `${ws.tenant}|${normalizedName}|${publisher || ''}`;
let softwareId = softwareCatalogCache.get(cacheKey);
// Create software catalog entry if it doesn't exist
if (!softwareId) {
// Check if it exists in DB
const existing = await knex('software_catalog')
.where({
tenant: ws.tenant,
normalized_name: normalizedName,
publisher: publisher
})
.first();
if (existing) {
softwareId = existing.software_id;
} else {
// Create new entry
const [entry] = await knex('software_catalog')
.insert({
tenant: ws.tenant,
name: sw.name.trim(),
normalized_name: normalizedName,
publisher: publisher,
category: inferCategory(sw.name),
software_type: inferSoftwareType(sw.name),
is_managed: false,
is_security_relevant: /antivirus|security|defender|firewall/.test(sw.name.toLowerCase())
})
.returning('software_id');
softwareId = entry.software_id;
}
softwareCatalogCache.set(cacheKey, softwareId);
}
// Create asset_software entry
try {
await knex('asset_software')
.insert({
tenant: ws.tenant,
asset_id: ws.asset_id,
software_id: softwareId,
version: sw.version || null,
install_date: sw.installDate ? new Date(sw.installDate) : null,
install_path: sw.location || null,
size_bytes: sw.size || null,
first_seen_at: knex.fn.now(),
last_seen_at: knex.fn.now(),
is_current: true
})
.onConflict(['tenant', 'asset_id', 'software_id'])
.ignore(); // Ignore duplicates
} catch (e) {
// Ignore duplicate key errors
if (!e.message.includes('duplicate key')) {
console.warn(`Failed to insert software ${sw.name} for asset ${ws.asset_id}:`, e.message);
}
}
}
}
// Process server_assets
const servers = await knex('server_assets')
.select('tenant', 'asset_id', 'installed_software')
.whereNotNull('installed_software');
console.log(`Processing ${servers.length} servers with software data...`);
for (const srv of servers) {
if (!srv.installed_software) continue;
let softwareList;
try {
softwareList = typeof srv.installed_software === 'string'
? JSON.parse(srv.installed_software)
: srv.installed_software;
} catch (e) {
console.warn(`Failed to parse software for server ${srv.asset_id}:`, e.message);
continue;
}
if (!Array.isArray(softwareList) || softwareList.length === 0) continue;
for (const sw of softwareList) {
if (!sw.name) continue;
const normalizedName = normalizeName(sw.name);
const publisher = sw.publisher?.trim() || null;
const cacheKey = `${srv.tenant}|${normalizedName}|${publisher || ''}`;
let softwareId = softwareCatalogCache.get(cacheKey);
if (!softwareId) {
const existing = await knex('software_catalog')
.where({
tenant: srv.tenant,
normalized_name: normalizedName,
publisher: publisher
})
.first();
if (existing) {
softwareId = existing.software_id;
} else {
const [entry] = await knex('software_catalog')
.insert({
tenant: srv.tenant,
name: sw.name.trim(),
normalized_name: normalizedName,
publisher: publisher,
category: inferCategory(sw.name),
software_type: inferSoftwareType(sw.name),
is_managed: false,
is_security_relevant: /antivirus|security|defender|firewall/.test(sw.name.toLowerCase())
})
.returning('software_id');
softwareId = entry.software_id;
}
softwareCatalogCache.set(cacheKey, softwareId);
}
try {
await knex('asset_software')
.insert({
tenant: srv.tenant,
asset_id: srv.asset_id,
software_id: softwareId,
version: sw.version || null,
install_date: sw.installDate ? new Date(sw.installDate) : null,
install_path: sw.location || null,
size_bytes: sw.size || null,
first_seen_at: knex.fn.now(),
last_seen_at: knex.fn.now(),
is_current: true
})
.onConflict(['tenant', 'asset_id', 'software_id'])
.ignore();
} catch (e) {
if (!e.message.includes('duplicate key')) {
console.warn(`Failed to insert software ${sw.name} for asset ${srv.asset_id}:`, e.message);
}
}
}
}
const catalogCount = await knex('software_catalog').count('* as count').first();
const assetSoftwareCount = await knex('asset_software').count('* as count').first();
console.log(`Migration complete. Created ${catalogCount.count} software catalog entries and ${assetSoftwareCount.count} asset-software relationships.`);
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
// Clear the migrated data (the JSONB columns are still intact)
await knex('asset_software').del();
await knex('software_catalog').del();
console.log('Cleared migrated software data from normalized tables.');
};