- Add batchDetectContainerTypes() helper function that uses pct list and qm list to detect all container types in 2 SSH calls per server - Update getAllInstalledScripts to use batch detection instead of individual isVM() calls per script - Update getInstalledScriptsByServer to use batch detection for single server - Update database queries to include lxc_config relation for fallback detection - Fix isVM() function to properly default to LXC when VM config doesn't exist - Significantly improves performance: reduces from N SSH calls per script to 2 SSH calls per server
609 lines
18 KiB
TypeScript
609 lines
18 KiB
TypeScript
import { prisma } from './db';
|
|
import { join } from 'path';
|
|
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
|
|
import { existsSync } from 'fs';
|
|
import type { CreateServerData } from '../types/server';
|
|
import type { Prisma } from '../../prisma/generated/prisma/client';
|
|
|
|
// Type definitions based on Prisma schema
|
|
type Server = {
|
|
id: number;
|
|
name: string;
|
|
ip: string;
|
|
user: string;
|
|
password: string | null;
|
|
auth_type: string | null;
|
|
ssh_key: string | null;
|
|
ssh_key_passphrase: string | null;
|
|
ssh_port: number | null;
|
|
color: string | null;
|
|
created_at: Date | null;
|
|
updated_at: Date | null;
|
|
ssh_key_path: string | null;
|
|
key_generated: boolean | null;
|
|
};
|
|
|
|
type InstalledScript = {
|
|
id: number;
|
|
script_name: string;
|
|
script_path: string;
|
|
container_id: string | null;
|
|
server_id: number | null;
|
|
execution_mode: string;
|
|
installation_date: Date | null;
|
|
status: string;
|
|
output_log: string | null;
|
|
web_ui_ip: string | null;
|
|
web_ui_port: number | null;
|
|
};
|
|
|
|
type InstalledScriptWithServer = InstalledScript & {
|
|
server: Server | null;
|
|
};
|
|
|
|
type LXCConfig = {
|
|
id: number;
|
|
installed_script_id: number;
|
|
arch: string | null;
|
|
cores: number | null;
|
|
memory: number | null;
|
|
hostname: string | null;
|
|
swap: number | null;
|
|
onboot: number | null;
|
|
ostype: string | null;
|
|
unprivileged: number | null;
|
|
net_name: string | null;
|
|
net_bridge: string | null;
|
|
net_hwaddr: string | null;
|
|
net_ip_type: string | null;
|
|
net_ip: string | null;
|
|
net_gateway: string | null;
|
|
net_type: string | null;
|
|
net_vlan: number | null;
|
|
rootfs_storage: string | null;
|
|
rootfs_size: string | null;
|
|
feature_keyctl: number | null;
|
|
feature_nesting: number | null;
|
|
feature_fuse: number | null;
|
|
feature_mount: string | null;
|
|
tags: string | null;
|
|
advanced_config: string | null;
|
|
synced_at: Date | null;
|
|
config_hash: string | null;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
};
|
|
|
|
type Backup = {
|
|
id: number;
|
|
container_id: string;
|
|
server_id: number;
|
|
hostname: string;
|
|
backup_name: string;
|
|
backup_path: string;
|
|
size: bigint | null;
|
|
created_at: Date | null;
|
|
storage_name: string;
|
|
storage_type: string;
|
|
discovered_at: Date;
|
|
};
|
|
|
|
type BackupWithServer = Backup & {
|
|
server: Server | null;
|
|
};
|
|
|
|
type PBSStorageCredential = {
|
|
id: number;
|
|
server_id: number;
|
|
storage_name: string;
|
|
pbs_ip: string;
|
|
pbs_datastore: string;
|
|
pbs_password: string;
|
|
pbs_fingerprint: string;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
};
|
|
|
|
type LXCConfigInput = Partial<Omit<LXCConfig, 'id' | 'installed_script_id' | 'created_at' | 'updated_at'>>;
|
|
|
|
class DatabaseServicePrisma {
|
|
constructor() {
|
|
this.init();
|
|
}
|
|
|
|
init(): void {
|
|
// Ensure data/ssh-keys directory exists (recursive to create parent dirs)
|
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
|
if (!existsSync(sshKeysDir)) {
|
|
mkdirSync(sshKeysDir, { recursive: true, mode: 0o700 });
|
|
}
|
|
}
|
|
|
|
// Server CRUD operations
|
|
async createServer(serverData: CreateServerData): Promise<Server> {
|
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
|
const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : 22;
|
|
|
|
let ssh_key_path: string | null = null;
|
|
|
|
// If using SSH key authentication, create persistent key file
|
|
if (auth_type === 'key' && ssh_key) {
|
|
const serverId = await this.getNextServerId();
|
|
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
|
|
}
|
|
|
|
const result = await prisma.server.create({
|
|
data: {
|
|
name,
|
|
ip,
|
|
user,
|
|
password,
|
|
auth_type: auth_type ?? 'password',
|
|
ssh_key,
|
|
ssh_key_passphrase,
|
|
ssh_port: Number.isNaN(normalizedPort) ? 22 : normalizedPort,
|
|
ssh_key_path,
|
|
key_generated: Boolean(key_generated),
|
|
color,
|
|
}
|
|
});
|
|
return result as Server;
|
|
}
|
|
|
|
async getAllServers(): Promise<Server[]> {
|
|
const result = await prisma.server.findMany({
|
|
orderBy: { created_at: 'desc' }
|
|
});
|
|
return result as Server[];
|
|
}
|
|
|
|
async getServerById(id: number): Promise<Server | null> {
|
|
const result = await prisma.server.findUnique({
|
|
where: { id }
|
|
});
|
|
return result as Server | null;
|
|
}
|
|
|
|
async updateServer(id: number, serverData: CreateServerData): Promise<Server> {
|
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
|
const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : undefined;
|
|
|
|
// Get existing server to check for key changes
|
|
const existingServer = await this.getServerById(id);
|
|
let ssh_key_path = existingServer?.ssh_key_path ?? null;
|
|
|
|
// Handle SSH key changes
|
|
if (auth_type === 'key' && ssh_key) {
|
|
// Delete old key file if it exists
|
|
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
|
try {
|
|
unlinkSync(existingServer.ssh_key_path);
|
|
// Also delete public key file if it exists
|
|
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
|
if (existsSync(pubKeyPath)) {
|
|
unlinkSync(pubKeyPath);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to delete old SSH key file:', error);
|
|
}
|
|
}
|
|
|
|
// Create new key file
|
|
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
|
|
} else if (auth_type !== 'key') {
|
|
// If switching away from key auth, delete key files
|
|
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
|
try {
|
|
unlinkSync(existingServer.ssh_key_path);
|
|
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
|
if (existsSync(pubKeyPath)) {
|
|
unlinkSync(pubKeyPath);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to delete SSH key file:', error);
|
|
}
|
|
}
|
|
ssh_key_path = null;
|
|
}
|
|
|
|
const result = await prisma.server.update({
|
|
where: { id },
|
|
data: {
|
|
name,
|
|
ip,
|
|
user,
|
|
password,
|
|
auth_type: auth_type ?? 'password',
|
|
ssh_key,
|
|
ssh_key_passphrase,
|
|
ssh_port: normalizedPort ?? 22,
|
|
ssh_key_path,
|
|
key_generated: key_generated !== undefined ? Boolean(key_generated) : (existingServer?.key_generated ?? false),
|
|
color,
|
|
}
|
|
});
|
|
return result as Server;
|
|
}
|
|
|
|
async deleteServer(id: number): Promise<Server> {
|
|
// Get server info before deletion to clean up key files
|
|
const server = await this.getServerById(id);
|
|
|
|
// Delete SSH key files if they exist
|
|
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
|
|
try {
|
|
unlinkSync(server.ssh_key_path);
|
|
const pubKeyPath = server.ssh_key_path + '.pub';
|
|
if (existsSync(pubKeyPath)) {
|
|
unlinkSync(pubKeyPath);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to delete SSH key file:', error);
|
|
}
|
|
}
|
|
|
|
const result = await prisma.server.delete({
|
|
where: { id }
|
|
});
|
|
return result as Server;
|
|
}
|
|
|
|
// Installed Scripts CRUD operations
|
|
async createInstalledScript(scriptData: {
|
|
script_name: string;
|
|
script_path: string;
|
|
container_id?: string;
|
|
server_id?: number;
|
|
execution_mode: string;
|
|
status: 'in_progress' | 'success' | 'failed';
|
|
output_log?: string;
|
|
web_ui_ip?: string;
|
|
web_ui_port?: number;
|
|
}): Promise<InstalledScript> {
|
|
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
|
|
|
|
const result = await prisma.installedScript.create({
|
|
data: {
|
|
script_name,
|
|
script_path,
|
|
container_id: container_id ?? null,
|
|
server_id: server_id ?? null,
|
|
execution_mode,
|
|
status,
|
|
output_log: output_log ?? null,
|
|
web_ui_ip: web_ui_ip ?? null,
|
|
web_ui_port: web_ui_port ?? null,
|
|
}
|
|
});
|
|
return result as InstalledScript;
|
|
}
|
|
|
|
async getAllInstalledScripts(): Promise<InstalledScriptWithServer[]> {
|
|
const result = await prisma.installedScript.findMany({
|
|
include: {
|
|
server: true,
|
|
lxc_config: true
|
|
},
|
|
orderBy: { installation_date: 'desc' }
|
|
});
|
|
return result as InstalledScriptWithServer[];
|
|
}
|
|
|
|
async getInstalledScriptById(id: number): Promise<InstalledScriptWithServer | null> {
|
|
const result = await prisma.installedScript.findUnique({
|
|
where: { id },
|
|
include: {
|
|
server: true
|
|
}
|
|
});
|
|
return result as InstalledScriptWithServer | null;
|
|
}
|
|
|
|
async getInstalledScriptsByServer(server_id: number): Promise<InstalledScriptWithServer[]> {
|
|
const result = await prisma.installedScript.findMany({
|
|
where: { server_id },
|
|
include: {
|
|
server: true,
|
|
lxc_config: true
|
|
},
|
|
orderBy: { installation_date: 'desc' }
|
|
});
|
|
return result as InstalledScriptWithServer[];
|
|
}
|
|
|
|
async updateInstalledScript(id: number, updateData: {
|
|
script_name?: string;
|
|
container_id?: string;
|
|
status?: 'in_progress' | 'success' | 'failed';
|
|
output_log?: string;
|
|
web_ui_ip?: string;
|
|
web_ui_port?: number;
|
|
}): Promise<InstalledScript | { changes: number }> {
|
|
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
|
|
|
|
const updateFields: Prisma.InstalledScriptUpdateInput = {};
|
|
if (script_name !== undefined) updateFields.script_name = script_name;
|
|
if (container_id !== undefined) updateFields.container_id = container_id;
|
|
if (status !== undefined) updateFields.status = status;
|
|
if (output_log !== undefined) updateFields.output_log = output_log;
|
|
if (web_ui_ip !== undefined) updateFields.web_ui_ip = web_ui_ip;
|
|
if (web_ui_port !== undefined) updateFields.web_ui_port = web_ui_port;
|
|
|
|
if (Object.keys(updateFields).length === 0) {
|
|
return { changes: 0 };
|
|
}
|
|
|
|
const result = await prisma.installedScript.update({
|
|
where: { id },
|
|
data: updateFields
|
|
});
|
|
return result as InstalledScript;
|
|
}
|
|
|
|
async deleteInstalledScript(id: number): Promise<InstalledScript> {
|
|
const result = await prisma.installedScript.delete({
|
|
where: { id }
|
|
});
|
|
return result as InstalledScript;
|
|
}
|
|
|
|
async deleteInstalledScriptsByServer(server_id: number): Promise<{ count: number }> {
|
|
const result = await prisma.installedScript.deleteMany({
|
|
where: { server_id }
|
|
});
|
|
return result as { count: number };
|
|
}
|
|
|
|
async getNextServerId(): Promise<number> {
|
|
const result = await prisma.server.findFirst({
|
|
orderBy: { id: 'desc' },
|
|
select: { id: true }
|
|
});
|
|
return ((result as { id: number } | null)?.id ?? 0) + 1;
|
|
}
|
|
|
|
createSSHKeyFile(serverId: number, sshKey: string): string {
|
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
|
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
|
|
|
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
|
const normalizedKey = sshKey.trimEnd() + '\n';
|
|
writeFileSync(keyPath, normalizedKey);
|
|
chmodSync(keyPath, 0o600); // Set proper permissions
|
|
|
|
return keyPath;
|
|
}
|
|
|
|
// LXC Config CRUD operations
|
|
async createLXCConfig(scriptId: number, configData: LXCConfigInput): Promise<LXCConfig> {
|
|
const result = await prisma.lXCConfig.create({
|
|
data: {
|
|
installed_script_id: scriptId,
|
|
...configData
|
|
}
|
|
});
|
|
return result as LXCConfig;
|
|
}
|
|
|
|
async updateLXCConfig(scriptId: number, configData: LXCConfigInput): Promise<LXCConfig> {
|
|
const result = await prisma.lXCConfig.upsert({
|
|
where: { installed_script_id: scriptId },
|
|
update: configData,
|
|
create: {
|
|
installed_script_id: scriptId,
|
|
...configData
|
|
}
|
|
});
|
|
return result as LXCConfig;
|
|
}
|
|
|
|
async getLXCConfigByScriptId(scriptId: number): Promise<LXCConfig | null> {
|
|
const result = await prisma.lXCConfig.findUnique({
|
|
where: { installed_script_id: scriptId }
|
|
});
|
|
return result as LXCConfig | null;
|
|
}
|
|
|
|
async deleteLXCConfig(scriptId: number): Promise<void> {
|
|
await prisma.lXCConfig.delete({
|
|
where: { installed_script_id: scriptId }
|
|
});
|
|
}
|
|
|
|
// Backup CRUD operations
|
|
async createOrUpdateBackup(backupData: {
|
|
container_id: string;
|
|
server_id: number;
|
|
hostname: string;
|
|
backup_name: string;
|
|
backup_path: string;
|
|
size?: bigint;
|
|
created_at?: Date;
|
|
storage_name: string;
|
|
storage_type: 'local' | 'storage' | 'pbs';
|
|
}): Promise<Backup> {
|
|
// Find existing backup by container_id, server_id, and backup_path
|
|
const existing = await prisma.backup.findFirst({
|
|
where: {
|
|
container_id: backupData.container_id,
|
|
server_id: backupData.server_id,
|
|
backup_path: backupData.backup_path,
|
|
},
|
|
}) as Backup | null;
|
|
|
|
if (existing) {
|
|
// Update existing backup
|
|
const result = await prisma.backup.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
hostname: backupData.hostname,
|
|
backup_name: backupData.backup_name,
|
|
size: backupData.size,
|
|
created_at: backupData.created_at,
|
|
storage_name: backupData.storage_name,
|
|
storage_type: backupData.storage_type,
|
|
discovered_at: new Date(),
|
|
},
|
|
});
|
|
return result as Backup;
|
|
} else {
|
|
// Create new backup
|
|
const result = await prisma.backup.create({
|
|
data: {
|
|
container_id: backupData.container_id,
|
|
server_id: backupData.server_id,
|
|
hostname: backupData.hostname,
|
|
backup_name: backupData.backup_name,
|
|
backup_path: backupData.backup_path,
|
|
size: backupData.size,
|
|
created_at: backupData.created_at,
|
|
storage_name: backupData.storage_name,
|
|
storage_type: backupData.storage_type,
|
|
discovered_at: new Date(),
|
|
},
|
|
});
|
|
return result as Backup;
|
|
}
|
|
}
|
|
|
|
async getAllBackups(): Promise<BackupWithServer[]> {
|
|
const result = await prisma.backup.findMany({
|
|
include: {
|
|
server: true,
|
|
},
|
|
orderBy: [
|
|
{ container_id: 'asc' },
|
|
{ created_at: 'desc' },
|
|
],
|
|
});
|
|
return result as BackupWithServer[];
|
|
}
|
|
|
|
async getBackupById(id: number): Promise<BackupWithServer | null> {
|
|
const result = await prisma.backup.findUnique({
|
|
where: { id },
|
|
include: {
|
|
server: true,
|
|
},
|
|
});
|
|
return result as BackupWithServer | null;
|
|
}
|
|
|
|
async getBackupsByContainerId(containerId: string): Promise<BackupWithServer[]> {
|
|
const result = await prisma.backup.findMany({
|
|
where: { container_id: containerId },
|
|
include: {
|
|
server: true,
|
|
},
|
|
orderBy: { created_at: 'desc' },
|
|
});
|
|
return result as BackupWithServer[];
|
|
}
|
|
|
|
async deleteBackupsForContainer(containerId: string, serverId: number): Promise<{ count: number }> {
|
|
const result = await prisma.backup.deleteMany({
|
|
where: {
|
|
container_id: containerId,
|
|
server_id: serverId,
|
|
},
|
|
});
|
|
return result as { count: number };
|
|
}
|
|
|
|
async getBackupsGroupedByContainer(): Promise<Map<string, BackupWithServer[]>> {
|
|
const backups = await this.getAllBackups();
|
|
const grouped = new Map<string, BackupWithServer[]>();
|
|
|
|
for (const backup of backups) {
|
|
const key = backup.container_id;
|
|
if (!grouped.has(key)) {
|
|
grouped.set(key, []);
|
|
}
|
|
grouped.get(key)!.push(backup);
|
|
}
|
|
|
|
return grouped;
|
|
}
|
|
|
|
// PBS Credentials CRUD operations
|
|
async createOrUpdatePBSCredential(credentialData: {
|
|
server_id: number;
|
|
storage_name: string;
|
|
pbs_ip: string;
|
|
pbs_datastore: string;
|
|
pbs_password: string;
|
|
pbs_fingerprint: string;
|
|
}): Promise<PBSStorageCredential> {
|
|
const result = await prisma.pBSStorageCredential.upsert({
|
|
where: {
|
|
server_id_storage_name: {
|
|
server_id: credentialData.server_id,
|
|
storage_name: credentialData.storage_name,
|
|
},
|
|
},
|
|
update: {
|
|
pbs_ip: credentialData.pbs_ip,
|
|
pbs_datastore: credentialData.pbs_datastore,
|
|
pbs_password: credentialData.pbs_password,
|
|
pbs_fingerprint: credentialData.pbs_fingerprint,
|
|
updated_at: new Date(),
|
|
},
|
|
create: {
|
|
server_id: credentialData.server_id,
|
|
storage_name: credentialData.storage_name,
|
|
pbs_ip: credentialData.pbs_ip,
|
|
pbs_datastore: credentialData.pbs_datastore,
|
|
pbs_password: credentialData.pbs_password,
|
|
pbs_fingerprint: credentialData.pbs_fingerprint,
|
|
},
|
|
});
|
|
return result as PBSStorageCredential;
|
|
}
|
|
|
|
async getPBSCredential(serverId: number, storageName: string): Promise<PBSStorageCredential | null> {
|
|
const result = await prisma.pBSStorageCredential.findUnique({
|
|
where: {
|
|
server_id_storage_name: {
|
|
server_id: serverId,
|
|
storage_name: storageName,
|
|
},
|
|
},
|
|
});
|
|
return result as PBSStorageCredential | null;
|
|
}
|
|
|
|
async getPBSCredentialsByServer(serverId: number): Promise<PBSStorageCredential[]> {
|
|
const result = await prisma.pBSStorageCredential.findMany({
|
|
where: { server_id: serverId },
|
|
orderBy: { storage_name: 'asc' },
|
|
});
|
|
return result as PBSStorageCredential[];
|
|
}
|
|
|
|
async deletePBSCredential(serverId: number, storageName: string): Promise<PBSStorageCredential> {
|
|
const result = await prisma.pBSStorageCredential.delete({
|
|
where: {
|
|
server_id_storage_name: {
|
|
server_id: serverId,
|
|
storage_name: storageName,
|
|
},
|
|
},
|
|
});
|
|
return result as PBSStorageCredential;
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
let dbInstance: DatabaseServicePrisma | null = null;
|
|
|
|
export function getDatabase(): DatabaseServicePrisma {
|
|
dbInstance ??= new DatabaseServicePrisma();
|
|
return dbInstance;
|
|
}
|
|
|
|
export default DatabaseServicePrisma;
|