first commit
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import * as db from '../src/db/index.js'
|
||||
import { prisma } from '../src/db/prisma.js'
|
||||
import { getIncusClient, getInstance, getInstanceState } from '../src/lib/incus/index.js'
|
||||
import {
|
||||
persistResolvedInstanceNetworkAddresses,
|
||||
resolveInstanceNetworkAddresses,
|
||||
type IncusNetworkStateLike
|
||||
} from '../src/lib/instance-network-sync.js'
|
||||
|
||||
function parseArgs(argv: string[]): { dryRun: boolean; hostId: number | null } {
|
||||
let dryRun = false
|
||||
let hostId: number | null = null
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg === '--dry-run') {
|
||||
dryRun = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.startsWith('--host-id=')) {
|
||||
const value = Number(arg.slice('--host-id='.length))
|
||||
if (!Number.isNaN(value)) {
|
||||
hostId = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dryRun, hostId }
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { dryRun, hostId } = parseArgs(process.argv.slice(2))
|
||||
|
||||
const allHosts = await db.getAllHosts()
|
||||
const hosts = allHosts.filter(host => host.status !== 'offline' && (hostId === null || host.id === hostId))
|
||||
|
||||
if (hosts.length === 0) {
|
||||
console.log('[BackfillInstanceNetwork] No matching online hosts found')
|
||||
return
|
||||
}
|
||||
|
||||
let scanned = 0
|
||||
let changedInstances = 0
|
||||
let ipv4ChangedCount = 0
|
||||
let ipv6ChangedCount = 0
|
||||
let failed = 0
|
||||
|
||||
console.log(`[BackfillInstanceNetwork] Starting${dryRun ? ' dry-run' : ''} for ${hosts.length} host(s)`)
|
||||
|
||||
for (const host of hosts) {
|
||||
console.log(`[BackfillInstanceNetwork] Host #${host.id} ${host.name}`)
|
||||
|
||||
let client: Awaited<ReturnType<typeof getIncusClient>>
|
||||
try {
|
||||
client = await getIncusClient(host)
|
||||
} catch (error) {
|
||||
failed++
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[BackfillInstanceNetwork] Failed to connect host #${host.id}: ${message}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const instances = await prisma.instance.findMany({
|
||||
where: {
|
||||
hostId: host.id,
|
||||
status: { notIn: ['deleted', 'creating'] }
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
incusId: true,
|
||||
networkMode: true,
|
||||
ipv4: true,
|
||||
ipv6: true
|
||||
}
|
||||
})
|
||||
|
||||
for (const instance of instances) {
|
||||
scanned++
|
||||
|
||||
try {
|
||||
const incusInstance = await getInstance(client, instance.incusId)
|
||||
|
||||
let state: IncusNetworkStateLike | null = null
|
||||
try {
|
||||
state = await getInstanceState(client, instance.incusId) as IncusNetworkStateLike
|
||||
} catch {
|
||||
state = null
|
||||
}
|
||||
|
||||
const resolved = resolveInstanceNetworkAddresses(
|
||||
{
|
||||
id: instance.id,
|
||||
name: instance.name,
|
||||
networkMode: instance.networkMode,
|
||||
ipv4: instance.ipv4,
|
||||
ipv6: instance.ipv6
|
||||
},
|
||||
incusInstance,
|
||||
state
|
||||
)
|
||||
|
||||
const ipv4WillChange = (instance.ipv4 ?? null) !== (resolved.ipv4 ?? null)
|
||||
const ipv6WillChange = (instance.ipv6 ?? null) !== (resolved.ipv6 ?? null)
|
||||
|
||||
if (!ipv4WillChange && !ipv6WillChange) {
|
||||
continue
|
||||
}
|
||||
|
||||
changedInstances++
|
||||
if (ipv4WillChange) ipv4ChangedCount++
|
||||
if (ipv6WillChange) ipv6ChangedCount++
|
||||
|
||||
if (dryRun) {
|
||||
console.log(
|
||||
`[BackfillInstanceNetwork] DRY-RUN instance #${instance.id} ${instance.name}: `
|
||||
+ `ipv4 ${instance.ipv4 ?? '-'} -> ${resolved.ipv4 ?? '-'}, `
|
||||
+ `ipv6 ${instance.ipv6 ?? '-'} -> ${resolved.ipv6 ?? '-'}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const persisted = await persistResolvedInstanceNetworkAddresses(
|
||||
{
|
||||
id: instance.id,
|
||||
name: instance.name,
|
||||
networkMode: instance.networkMode,
|
||||
ipv4: instance.ipv4,
|
||||
ipv6: instance.ipv6
|
||||
},
|
||||
resolved,
|
||||
{ updateLastSyncedAt: true }
|
||||
)
|
||||
|
||||
console.log(
|
||||
`[BackfillInstanceNetwork] Updated instance #${instance.id} ${instance.name}: `
|
||||
+ `ipv4 ${persisted.oldIpv4 ?? '-'} -> ${persisted.newIpv4 ?? '-'}, `
|
||||
+ `ipv6 ${persisted.oldIpv6 ?? '-'} -> ${persisted.newIpv6 ?? '-'}`
|
||||
)
|
||||
} catch (error) {
|
||||
failed++
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[BackfillInstanceNetwork] Failed instance #${instance.id} ${instance.name}: ${message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[BackfillInstanceNetwork] Completed${dryRun ? ' dry-run' : ''}: `
|
||||
+ `scanned=${scanned}, changed=${changedInstances}, `
|
||||
+ `ipv4Changed=${ipv4ChangedCount}, ipv6Changed=${ipv6ChangedCount}, failed=${failed}`
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(error => {
|
||||
const message = error instanceof Error ? error.stack || error.message : String(error)
|
||||
console.error(`[BackfillInstanceNetwork] Fatal error: ${message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'dotenv/config'
|
||||
|
||||
import { prisma } from '../src/db/prisma.js'
|
||||
import { getInstanceBillingLineageIds, reassignInstanceBillingRecords } from '../src/db/billing-records.js'
|
||||
|
||||
type MigrationMessageData = {
|
||||
instanceId?: unknown
|
||||
}
|
||||
|
||||
function isInteger(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value)
|
||||
}
|
||||
|
||||
async function countBillingRecords(instanceId: number): Promise<number> {
|
||||
return prisma.instanceBillingRecord.count({
|
||||
where: { instanceId }
|
||||
})
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const apply = process.argv.includes('--apply')
|
||||
const limitArg = process.argv.find(arg => arg.startsWith('--limit='))
|
||||
const limit = limitArg ? Number(limitArg.split('=')[1]) : null
|
||||
|
||||
if (limitArg && (!Number.isInteger(limit) || Number(limit) <= 0)) {
|
||||
throw new Error('参数 --limit 必须是正整数')
|
||||
}
|
||||
|
||||
const migrationMessages = await prisma.inboxMessage.findMany({
|
||||
where: {
|
||||
eventType: 'instance_migrated'
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
...(limit ? { take: limit } : {})
|
||||
})
|
||||
|
||||
let scanned = 0
|
||||
let eligible = 0
|
||||
let movedRecords = 0
|
||||
let unresolved = 0
|
||||
let skipped = 0
|
||||
const processedTargetIds = new Set<number>()
|
||||
|
||||
console.log(`[FixMigratedInstanceBilling] mode=${apply ? 'apply' : 'dry-run'}, messages=${migrationMessages.length}`)
|
||||
|
||||
for (const message of migrationMessages) {
|
||||
scanned += 1
|
||||
|
||||
const data = (message.data || {}) as MigrationMessageData
|
||||
if (!isInteger(data.instanceId)) {
|
||||
skipped += 1
|
||||
console.log(`[Skip] inboxMessage=${message.id} 原始数据缺失 newInstanceId`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (processedTargetIds.has(data.instanceId)) {
|
||||
skipped += 1
|
||||
console.log(`[Skip] inboxMessage=${message.id} 目标实例 ${data.instanceId} 已处理过`)
|
||||
continue
|
||||
}
|
||||
|
||||
const newInstance = await prisma.instance.findFirst({
|
||||
where: {
|
||||
id: data.instanceId,
|
||||
userId: message.userId
|
||||
},
|
||||
select: { id: true }
|
||||
})
|
||||
|
||||
if (!newInstance) {
|
||||
skipped += 1
|
||||
console.log(`[Skip] inboxMessage=${message.id} 找不到目标实例 newInstanceId=${data.instanceId}`)
|
||||
continue
|
||||
}
|
||||
|
||||
processedTargetIds.add(newInstance.id)
|
||||
|
||||
const lineage = await getInstanceBillingLineageIds(newInstance.id)
|
||||
const sourceInstanceIds = lineage.slice(0, -1)
|
||||
|
||||
if (sourceInstanceIds.length === 0) {
|
||||
unresolved += 1
|
||||
console.log(`[Unresolved] inboxMessage=${message.id} newInstance=${newInstance.id} 未解析到历史实例`)
|
||||
continue
|
||||
}
|
||||
|
||||
const sourceSummaries = await Promise.all(sourceInstanceIds.map(async sourceInstanceId => ({
|
||||
sourceInstanceId,
|
||||
billingRecordCount: await countBillingRecords(sourceInstanceId)
|
||||
})))
|
||||
|
||||
eligible += 1
|
||||
console.log(
|
||||
`[Match] inboxMessage=${message.id} lineage=${lineage.join(' -> ')} ` +
|
||||
`sources=${sourceSummaries.map(item => `${item.sourceInstanceId}(records=${item.billingRecordCount})`).join(', ')}`
|
||||
)
|
||||
|
||||
if (!apply) {
|
||||
continue
|
||||
}
|
||||
|
||||
const moved = await prisma.$transaction(async tx => {
|
||||
let totalMoved = 0
|
||||
for (const sourceInstanceId of sourceInstanceIds) {
|
||||
totalMoved += await reassignInstanceBillingRecords(sourceInstanceId, newInstance.id, tx)
|
||||
}
|
||||
return totalMoved
|
||||
})
|
||||
|
||||
movedRecords += moved
|
||||
}
|
||||
|
||||
console.log(`[Summary] scanned=${scanned}, eligible=${eligible}, movedRecords=${movedRecords}, unresolved=${unresolved}, skipped=${skipped}`)
|
||||
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
|
||||
void main().catch(async error => {
|
||||
console.error('[FixMigratedInstanceBilling] failed:', error)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,975 @@
|
||||
import 'dotenv/config'
|
||||
|
||||
// @ts-ignore - bcryptjs has its own types
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
import * as db from '../src/db/index.js'
|
||||
import { prisma } from '../src/db/prisma.js'
|
||||
|
||||
type SeedRole = 'admin' | 'user'
|
||||
type HostStatus = 'online' | 'offline' | 'maintenance'
|
||||
type HostArchitecture = 'x86_64' | 'aarch64'
|
||||
type HostInstanceType = 'container' | 'vm' | 'both'
|
||||
type PackageInstanceType = 'container' | 'vm'
|
||||
type NetworkMode = 'nat' | 'nat_ipv6' | 'nat_ipv6_nat' | 'ipv6_only' | 'ipv6_nat'
|
||||
type IoLimitMode = 'throughput' | 'iops'
|
||||
|
||||
type SeedCounter = {
|
||||
created: number
|
||||
updated: number
|
||||
}
|
||||
|
||||
type SeedStats = {
|
||||
users: SeedCounter
|
||||
hosts: SeedCounter
|
||||
packages: SeedCounter
|
||||
plans: SeedCounter
|
||||
}
|
||||
|
||||
type SeedUserSpec = {
|
||||
username: string
|
||||
email: string
|
||||
role: SeedRole
|
||||
password?: string
|
||||
quota?: {
|
||||
hostLimit: number
|
||||
packageLimit: number
|
||||
}
|
||||
}
|
||||
|
||||
type SeedHostSpec = {
|
||||
ownerUsername: string
|
||||
name: string
|
||||
url: string
|
||||
location: string
|
||||
countryCode: string
|
||||
architecture: HostArchitecture
|
||||
instanceType: HostInstanceType
|
||||
status: HostStatus
|
||||
tags: string[]
|
||||
natPublicIp: string
|
||||
natPortStart: number
|
||||
natPortEnd: number
|
||||
ipAddress: string
|
||||
storageDriver: string
|
||||
storageType: string
|
||||
storageSize: number
|
||||
enableApi: boolean
|
||||
isInstalled: boolean
|
||||
cpuAllowanceMax: number
|
||||
memoryMax: number
|
||||
}
|
||||
|
||||
type SeedPlanSpec = {
|
||||
name: string
|
||||
description: string
|
||||
cpu: number
|
||||
memory: number
|
||||
disk: number
|
||||
portLimit: number
|
||||
snapshotLimit: number
|
||||
backupLimit: number
|
||||
siteLimit: number
|
||||
swapSize: number
|
||||
trafficLimit: bigint
|
||||
trafficLimitSpeed: string
|
||||
price: number
|
||||
billingCycle: number
|
||||
setupFee: number
|
||||
isActive: boolean
|
||||
sortOrder: number
|
||||
slaGuarantee: number | null
|
||||
}
|
||||
|
||||
type SeedPackageSpec = {
|
||||
ownerUsername: string
|
||||
name: string
|
||||
description: string
|
||||
hostNames: string[]
|
||||
cpuMax: number
|
||||
memoryMax: number
|
||||
diskMax: number
|
||||
bandwidthMax: number | null
|
||||
networkMode: NetworkMode
|
||||
instanceType: PackageInstanceType
|
||||
privileged: boolean
|
||||
nested: boolean
|
||||
active: boolean
|
||||
portLimit: number
|
||||
snapshotLimit: number
|
||||
backupLimit: number
|
||||
siteLimit: number
|
||||
monthlyTrafficLimit: bigint | null
|
||||
ioLimitMode: IoLimitMode
|
||||
limitsRead: string
|
||||
limitsWrite: string
|
||||
limitsReadIops: number
|
||||
limitsWriteIops: number
|
||||
limitsIngress: string
|
||||
limitsEgress: string
|
||||
limitsProcesses: number
|
||||
limitsCpuPriority: number
|
||||
bootAutostart: boolean
|
||||
bootAutostartPriority: number
|
||||
bootAutostartDelay: number
|
||||
bootHostShutdownTimeout: number
|
||||
globalShared: boolean
|
||||
globalQuotaMultiplier: number | null
|
||||
globalMaxInstances: number | null
|
||||
allowInstanceDeletion: boolean
|
||||
plans: SeedPlanSpec[]
|
||||
}
|
||||
|
||||
const LOCAL_PASSWORD_SALT = '$2a$10$1234567890123456789012'
|
||||
|
||||
const stats: SeedStats = {
|
||||
users: { created: 0, updated: 0 },
|
||||
hosts: { created: 0, updated: 0 },
|
||||
packages: { created: 0, updated: 0 },
|
||||
plans: { created: 0, updated: 0 }
|
||||
}
|
||||
|
||||
const userIds = new Map<string, number>()
|
||||
const hostIds = new Map<string, number>()
|
||||
|
||||
function bytesFromGb(value: number): bigint {
|
||||
return BigInt(value) * 1024n * 1024n * 1024n
|
||||
}
|
||||
|
||||
function hostKey(ownerUsername: string, hostName: string): string {
|
||||
return `${ownerUsername}:${hostName}`
|
||||
}
|
||||
|
||||
function countPlans(packages: SeedPackageSpec[]): number {
|
||||
return packages.reduce((total, pkg) => total + pkg.plans.length, 0)
|
||||
}
|
||||
|
||||
function getUserId(username: string): number {
|
||||
const userId = userIds.get(username)
|
||||
if (!userId) {
|
||||
throw new Error(`Missing seeded user: ${username}`)
|
||||
}
|
||||
return userId
|
||||
}
|
||||
|
||||
function getHostId(ownerUsername: string, hostName: string): number {
|
||||
const id = hostIds.get(hostKey(ownerUsername, hostName))
|
||||
if (!id) {
|
||||
throw new Error(`Missing seeded host: ${ownerUsername}/${hostName}`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
async function buildPasswordHash(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, LOCAL_PASSWORD_SALT)
|
||||
}
|
||||
|
||||
async function ensureUser(spec: SeedUserSpec): Promise<number> {
|
||||
const existing = await db.findUserByUsername(spec.username)
|
||||
const passwordHash = spec.password ? await buildPasswordHash(spec.password) : undefined
|
||||
|
||||
if (!existing) {
|
||||
if (!passwordHash) {
|
||||
throw new Error(`Cannot create user without password: ${spec.username}`)
|
||||
}
|
||||
|
||||
const userId = await db.createUser(spec.username, spec.email, passwordHash, spec.role)
|
||||
|
||||
if (spec.quota) {
|
||||
await db.updateUserQuotaLimits(userId, {
|
||||
hostLimit: spec.quota.hostLimit,
|
||||
packageLimit: spec.quota.packageLimit
|
||||
})
|
||||
}
|
||||
|
||||
stats.users.created += 1
|
||||
userIds.set(spec.username, userId)
|
||||
return userId
|
||||
}
|
||||
|
||||
// 幂等处理:账号存在时只同步本脚本关心的字段,不重复创建脏数据。
|
||||
await db.updateUser(existing.id, {
|
||||
email: spec.email,
|
||||
role: spec.role,
|
||||
status: 'active',
|
||||
...(passwordHash ? { passwordHash } : {})
|
||||
})
|
||||
|
||||
const quota = await db.getUserQuota(existing.id)
|
||||
if (!quota) {
|
||||
await db.createUserQuota(existing.id, {
|
||||
hostLimit: spec.quota?.hostLimit ?? 8,
|
||||
packageLimit: spec.quota?.packageLimit ?? 8
|
||||
})
|
||||
} else if (spec.quota) {
|
||||
await db.updateUserQuotaLimits(existing.id, {
|
||||
hostLimit: spec.quota.hostLimit,
|
||||
packageLimit: spec.quota.packageLimit
|
||||
})
|
||||
}
|
||||
|
||||
stats.users.updated += 1
|
||||
userIds.set(spec.username, existing.id)
|
||||
return existing.id
|
||||
}
|
||||
|
||||
async function ensureHost(spec: SeedHostSpec): Promise<number> {
|
||||
const ownerId = getUserId(spec.ownerUsername)
|
||||
const existing = await db.getHostByUserAndName(ownerId, spec.name)
|
||||
|
||||
if (!existing) {
|
||||
const hostId = await db.createHost({
|
||||
userId: ownerId,
|
||||
name: spec.name,
|
||||
url: spec.url,
|
||||
location: spec.location,
|
||||
countryCode: spec.countryCode,
|
||||
architecture: spec.architecture,
|
||||
instanceType: spec.instanceType,
|
||||
tags: spec.tags,
|
||||
natPublicIp: spec.natPublicIp,
|
||||
natPortStart: spec.natPortStart,
|
||||
natPortEnd: spec.natPortEnd,
|
||||
ipAddress: spec.ipAddress,
|
||||
storageDriver: spec.storageDriver,
|
||||
storageType: spec.storageType,
|
||||
storageSize: spec.storageSize,
|
||||
enableApi: spec.enableApi,
|
||||
isInstalled: spec.isInstalled,
|
||||
cpuAllowanceMax: spec.cpuAllowanceMax,
|
||||
memoryMax: spec.memoryMax
|
||||
})
|
||||
|
||||
await db.updateHostStatus(hostId, spec.status)
|
||||
await db.updateHostResources(hostId, {
|
||||
architecture: spec.architecture,
|
||||
instanceType: spec.instanceType,
|
||||
cpuUsed: 0,
|
||||
memoryUsed: 0,
|
||||
diskUsed: 0,
|
||||
cpuAllowanceMax: spec.cpuAllowanceMax,
|
||||
memoryMax: spec.memoryMax
|
||||
})
|
||||
|
||||
stats.hosts.created += 1
|
||||
hostIds.set(hostKey(spec.ownerUsername, spec.name), hostId)
|
||||
return hostId
|
||||
}
|
||||
|
||||
// 关键流程:优先复用现有 helper 同步节点展示信息、状态和配额上限。
|
||||
await db.updateHost(existing.id, {
|
||||
url: spec.url,
|
||||
location: spec.location,
|
||||
countryCode: spec.countryCode,
|
||||
tags: spec.tags,
|
||||
natPublicIp: spec.natPublicIp,
|
||||
natPortStart: spec.natPortStart,
|
||||
natPortEnd: spec.natPortEnd
|
||||
})
|
||||
await db.updateHostStatus(existing.id, spec.status)
|
||||
await db.updateHostResources(existing.id, {
|
||||
architecture: spec.architecture,
|
||||
instanceType: spec.instanceType,
|
||||
cpuUsed: 0,
|
||||
memoryUsed: 0,
|
||||
diskUsed: 0,
|
||||
cpuAllowanceMax: spec.cpuAllowanceMax,
|
||||
memoryMax: spec.memoryMax
|
||||
})
|
||||
|
||||
stats.hosts.updated += 1
|
||||
hostIds.set(hostKey(spec.ownerUsername, spec.name), existing.id)
|
||||
return existing.id
|
||||
}
|
||||
|
||||
async function ensurePackage(spec: SeedPackageSpec): Promise<number> {
|
||||
const ownerId = getUserId(spec.ownerUsername)
|
||||
const owner = await db.findUserByUsername(spec.ownerUsername)
|
||||
|
||||
if (!owner) {
|
||||
throw new Error(`Missing package owner: ${spec.ownerUsername}`)
|
||||
}
|
||||
|
||||
const hostIdsForPackage = spec.hostNames.map(hostName => getHostId(spec.ownerUsername, hostName))
|
||||
const existing = await db.getPackageByUserAndName(ownerId, spec.name)
|
||||
const packageData = {
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
cpuMax: spec.cpuMax,
|
||||
memoryMax: spec.memoryMax,
|
||||
diskMax: spec.diskMax,
|
||||
bandwidthMax: spec.bandwidthMax,
|
||||
networkMode: spec.networkMode,
|
||||
instanceType: spec.instanceType,
|
||||
hostIds: hostIdsForPackage,
|
||||
privileged: spec.privileged,
|
||||
nested: spec.nested,
|
||||
active: spec.active,
|
||||
portLimit: spec.portLimit,
|
||||
snapshotLimit: spec.snapshotLimit,
|
||||
backupLimit: spec.backupLimit,
|
||||
siteLimit: spec.siteLimit,
|
||||
monthlyTrafficLimit: spec.monthlyTrafficLimit,
|
||||
ioLimitMode: spec.ioLimitMode,
|
||||
limitsRead: spec.limitsRead,
|
||||
limitsWrite: spec.limitsWrite,
|
||||
limitsReadIops: spec.limitsReadIops,
|
||||
limitsWriteIops: spec.limitsWriteIops,
|
||||
limitsIngress: spec.limitsIngress,
|
||||
limitsEgress: spec.limitsEgress,
|
||||
limitsProcesses: spec.limitsProcesses,
|
||||
limitsCpuPriority: spec.limitsCpuPriority,
|
||||
bootAutostart: spec.bootAutostart,
|
||||
bootAutostartPriority: spec.bootAutostartPriority,
|
||||
bootAutostartDelay: spec.bootAutostartDelay,
|
||||
bootHostShutdownTimeout: spec.bootHostShutdownTimeout,
|
||||
globalShared: spec.globalShared,
|
||||
globalQuotaMultiplier: spec.globalQuotaMultiplier,
|
||||
globalMaxInstances: spec.globalMaxInstances,
|
||||
allowInstanceDeletion: spec.allowInstanceDeletion
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
const packageId = await db.createPackage(
|
||||
{
|
||||
userId: ownerId,
|
||||
...packageData
|
||||
},
|
||||
owner.role === 'admin'
|
||||
)
|
||||
|
||||
stats.packages.created += 1
|
||||
return packageId
|
||||
}
|
||||
|
||||
await db.updatePackage(existing.id, packageData, owner.role === 'admin')
|
||||
stats.packages.updated += 1
|
||||
return existing.id
|
||||
}
|
||||
|
||||
async function ensurePlans(packageId: number, plans: SeedPlanSpec[]): Promise<void> {
|
||||
const existingPlans = await db.getPlansByPackageId(packageId)
|
||||
const existingByName = new Map(existingPlans.map(plan => [plan.name, plan]))
|
||||
const expectedNames = new Set<string>()
|
||||
|
||||
// 关键流程:方案使用 packageId + name 做唯一识别,重复执行时只更新不重复插入。
|
||||
for (const plan of plans) {
|
||||
expectedNames.add(plan.name)
|
||||
const existing = existingByName.get(plan.name)
|
||||
|
||||
if (!existing) {
|
||||
await db.createPlan({
|
||||
packageId,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
cpu: plan.cpu,
|
||||
memory: plan.memory,
|
||||
disk: plan.disk,
|
||||
portLimit: plan.portLimit,
|
||||
snapshotLimit: plan.snapshotLimit,
|
||||
backupLimit: plan.backupLimit,
|
||||
siteLimit: plan.siteLimit,
|
||||
swapSize: plan.swapSize,
|
||||
trafficLimit: plan.trafficLimit,
|
||||
trafficLimitSpeed: plan.trafficLimitSpeed,
|
||||
price: plan.price,
|
||||
billingCycle: plan.billingCycle,
|
||||
setupFee: plan.setupFee,
|
||||
isActive: plan.isActive,
|
||||
sortOrder: plan.sortOrder,
|
||||
slaGuarantee: plan.slaGuarantee
|
||||
})
|
||||
stats.plans.created += 1
|
||||
continue
|
||||
}
|
||||
|
||||
await db.updatePlan(existing.id, {
|
||||
description: plan.description,
|
||||
cpu: plan.cpu,
|
||||
memory: plan.memory,
|
||||
disk: plan.disk,
|
||||
portLimit: plan.portLimit,
|
||||
snapshotLimit: plan.snapshotLimit,
|
||||
backupLimit: plan.backupLimit,
|
||||
siteLimit: plan.siteLimit,
|
||||
swapSize: plan.swapSize,
|
||||
trafficLimit: plan.trafficLimit,
|
||||
trafficLimitSpeed: plan.trafficLimitSpeed,
|
||||
price: plan.price,
|
||||
billingCycle: plan.billingCycle,
|
||||
setupFee: plan.setupFee,
|
||||
isActive: plan.isActive,
|
||||
sortOrder: plan.sortOrder,
|
||||
slaGuarantee: plan.slaGuarantee
|
||||
})
|
||||
stats.plans.updated += 1
|
||||
}
|
||||
|
||||
for (const existing of existingPlans) {
|
||||
if (!expectedNames.has(existing.name) && existing.isActive) {
|
||||
await db.updatePlan(existing.id, {
|
||||
isActive: false
|
||||
})
|
||||
stats.plans.updated += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seedUsers: SeedUserSpec[] = [
|
||||
{
|
||||
username: 'admin',
|
||||
email: 'admin@incudal.local',
|
||||
role: 'admin'
|
||||
},
|
||||
{
|
||||
username: 'seed-market-alpha',
|
||||
email: 'seed-market-alpha@incudal.local',
|
||||
role: 'user',
|
||||
password: 'SeedAlpha123!',
|
||||
quota: {
|
||||
hostLimit: 8,
|
||||
packageLimit: 8
|
||||
}
|
||||
},
|
||||
{
|
||||
username: 'seed-market-beta',
|
||||
email: 'seed-market-beta@incudal.local',
|
||||
role: 'user',
|
||||
password: 'SeedBeta123!',
|
||||
quota: {
|
||||
hostLimit: 8,
|
||||
packageLimit: 8
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const seedHosts: SeedHostSpec[] = [
|
||||
{
|
||||
ownerUsername: 'admin',
|
||||
name: 'seed-official-us-west',
|
||||
url: 'https://seed-official-us-west.local',
|
||||
location: 'Los Angeles, US',
|
||||
countryCode: 'us',
|
||||
architecture: 'x86_64',
|
||||
instanceType: 'both',
|
||||
status: 'online',
|
||||
tags: ['seed', 'official', 'us-west'],
|
||||
natPublicIp: '198.51.100.10',
|
||||
natPortStart: 20000,
|
||||
natPortEnd: 24999,
|
||||
ipAddress: '10.10.0.10',
|
||||
storageDriver: 'zfs',
|
||||
storageType: 'loop',
|
||||
storageSize: 400,
|
||||
enableApi: true,
|
||||
isInstalled: true,
|
||||
cpuAllowanceMax: 3200,
|
||||
memoryMax: 131072
|
||||
},
|
||||
{
|
||||
ownerUsername: 'admin',
|
||||
name: 'seed-official-eu-vm',
|
||||
url: 'https://seed-official-eu-vm.local',
|
||||
location: 'Frankfurt, DE',
|
||||
countryCode: 'de',
|
||||
architecture: 'x86_64',
|
||||
instanceType: 'vm',
|
||||
status: 'online',
|
||||
tags: ['seed', 'official', 'eu-central'],
|
||||
natPublicIp: '198.51.100.11',
|
||||
natPortStart: 25000,
|
||||
natPortEnd: 29999,
|
||||
ipAddress: '10.10.0.11',
|
||||
storageDriver: 'zfs',
|
||||
storageType: 'loop',
|
||||
storageSize: 600,
|
||||
enableApi: true,
|
||||
isInstalled: true,
|
||||
cpuAllowanceMax: 4800,
|
||||
memoryMax: 196608
|
||||
},
|
||||
{
|
||||
ownerUsername: 'admin',
|
||||
name: 'seed-official-asia-arm',
|
||||
url: 'https://seed-official-asia-arm.local',
|
||||
location: 'Singapore, SG',
|
||||
countryCode: 'sg',
|
||||
architecture: 'aarch64',
|
||||
instanceType: 'container',
|
||||
status: 'online',
|
||||
tags: ['seed', 'official', 'apac'],
|
||||
natPublicIp: '198.51.100.12',
|
||||
natPortStart: 30000,
|
||||
natPortEnd: 34999,
|
||||
ipAddress: '10.10.0.12',
|
||||
storageDriver: 'zfs',
|
||||
storageType: 'loop',
|
||||
storageSize: 320,
|
||||
enableApi: true,
|
||||
isInstalled: true,
|
||||
cpuAllowanceMax: 2400,
|
||||
memoryMax: 98304
|
||||
},
|
||||
{
|
||||
ownerUsername: 'seed-market-alpha',
|
||||
name: 'seed-market-jp-tokyo',
|
||||
url: 'https://seed-market-jp-tokyo.local',
|
||||
location: 'Tokyo, JP',
|
||||
countryCode: 'jp',
|
||||
architecture: 'x86_64',
|
||||
instanceType: 'container',
|
||||
status: 'online',
|
||||
tags: ['seed', 'market', 'tokyo'],
|
||||
natPublicIp: '203.0.113.20',
|
||||
natPortStart: 35000,
|
||||
natPortEnd: 38999,
|
||||
ipAddress: '10.20.0.20',
|
||||
storageDriver: 'zfs',
|
||||
storageType: 'loop',
|
||||
storageSize: 240,
|
||||
enableApi: true,
|
||||
isInstalled: true,
|
||||
cpuAllowanceMax: 2000,
|
||||
memoryMax: 65536
|
||||
},
|
||||
{
|
||||
ownerUsername: 'seed-market-alpha',
|
||||
name: 'seed-market-sg-latency',
|
||||
url: 'https://seed-market-sg-latency.local',
|
||||
location: 'Singapore, SG',
|
||||
countryCode: 'sg',
|
||||
architecture: 'x86_64',
|
||||
instanceType: 'container',
|
||||
status: 'online',
|
||||
tags: ['seed', 'market', 'singapore'],
|
||||
natPublicIp: '203.0.113.21',
|
||||
natPortStart: 39000,
|
||||
natPortEnd: 42999,
|
||||
ipAddress: '10.20.0.21',
|
||||
storageDriver: 'zfs',
|
||||
storageType: 'loop',
|
||||
storageSize: 220,
|
||||
enableApi: true,
|
||||
isInstalled: true,
|
||||
cpuAllowanceMax: 1800,
|
||||
memoryMax: 49152
|
||||
},
|
||||
{
|
||||
ownerUsername: 'seed-market-beta',
|
||||
name: 'seed-market-us-east',
|
||||
url: 'https://seed-market-us-east.local',
|
||||
location: 'Ashburn, US',
|
||||
countryCode: 'us',
|
||||
architecture: 'x86_64',
|
||||
instanceType: 'both',
|
||||
status: 'online',
|
||||
tags: ['seed', 'market', 'us-east'],
|
||||
natPublicIp: '203.0.113.22',
|
||||
natPortStart: 43000,
|
||||
natPortEnd: 46999,
|
||||
ipAddress: '10.30.0.22',
|
||||
storageDriver: 'zfs',
|
||||
storageType: 'loop',
|
||||
storageSize: 260,
|
||||
enableApi: true,
|
||||
isInstalled: true,
|
||||
cpuAllowanceMax: 2200,
|
||||
memoryMax: 65536
|
||||
}
|
||||
]
|
||||
|
||||
const seedPackages: SeedPackageSpec[] = [
|
||||
{
|
||||
ownerUsername: 'admin',
|
||||
name: 'SEED Official Edge Container',
|
||||
description: 'Public official container package for local smoke testing across US and APAC.',
|
||||
hostNames: ['seed-official-us-west', 'seed-official-asia-arm'],
|
||||
cpuMax: 300,
|
||||
memoryMax: 8192,
|
||||
diskMax: 102400,
|
||||
bandwidthMax: 2000,
|
||||
networkMode: 'nat_ipv6',
|
||||
instanceType: 'container',
|
||||
privileged: true,
|
||||
nested: true,
|
||||
active: true,
|
||||
portLimit: 40,
|
||||
snapshotLimit: 8,
|
||||
backupLimit: 4,
|
||||
siteLimit: 12,
|
||||
monthlyTrafficLimit: bytesFromGb(800),
|
||||
ioLimitMode: 'throughput',
|
||||
limitsRead: '250MB',
|
||||
limitsWrite: '250MB',
|
||||
limitsReadIops: 1200,
|
||||
limitsWriteIops: 1200,
|
||||
limitsIngress: '1Gbit',
|
||||
limitsEgress: '1Gbit',
|
||||
limitsProcesses: 1200,
|
||||
limitsCpuPriority: 10,
|
||||
bootAutostart: true,
|
||||
bootAutostartPriority: 20,
|
||||
bootAutostartDelay: 15,
|
||||
bootHostShutdownTimeout: 45,
|
||||
globalShared: true,
|
||||
globalQuotaMultiplier: 2,
|
||||
globalMaxInstances: 6,
|
||||
allowInstanceDeletion: true,
|
||||
plans: [
|
||||
{
|
||||
name: 'Mini',
|
||||
description: '25% CPU / 512MB memory / 15GB disk',
|
||||
cpu: 25,
|
||||
memory: 512,
|
||||
disk: 15360,
|
||||
portLimit: 10,
|
||||
snapshotLimit: 3,
|
||||
backupLimit: 1,
|
||||
siteLimit: 2,
|
||||
swapSize: 0,
|
||||
trafficLimit: bytesFromGb(120),
|
||||
trafficLimitSpeed: '3Mbit',
|
||||
price: 690,
|
||||
billingCycle: 1,
|
||||
setupFee: 0,
|
||||
isActive: true,
|
||||
sortOrder: 10,
|
||||
slaGuarantee: 99.5
|
||||
},
|
||||
{
|
||||
name: 'Standard',
|
||||
description: '60% CPU / 2GB memory / 40GB disk',
|
||||
cpu: 60,
|
||||
memory: 2048,
|
||||
disk: 40960,
|
||||
portLimit: 20,
|
||||
snapshotLimit: 5,
|
||||
backupLimit: 2,
|
||||
siteLimit: 5,
|
||||
swapSize: 512,
|
||||
trafficLimit: bytesFromGb(260),
|
||||
trafficLimitSpeed: '5Mbit',
|
||||
price: 1990,
|
||||
billingCycle: 1,
|
||||
setupFee: 0,
|
||||
isActive: true,
|
||||
sortOrder: 20,
|
||||
slaGuarantee: 99.9
|
||||
},
|
||||
{
|
||||
name: 'Compute',
|
||||
description: '120% CPU / 4GB memory / 80GB disk',
|
||||
cpu: 120,
|
||||
memory: 4096,
|
||||
disk: 81920,
|
||||
portLimit: 35,
|
||||
snapshotLimit: 8,
|
||||
backupLimit: 4,
|
||||
siteLimit: 8,
|
||||
swapSize: 1024,
|
||||
trafficLimit: bytesFromGb(520),
|
||||
trafficLimitSpeed: '10Mbit',
|
||||
price: 3990,
|
||||
billingCycle: 1,
|
||||
setupFee: 0,
|
||||
isActive: true,
|
||||
sortOrder: 30,
|
||||
slaGuarantee: 99.95
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
ownerUsername: 'admin',
|
||||
name: 'SEED Official EU VM',
|
||||
description: 'Public official VM package for local billing and scheduler testing.',
|
||||
hostNames: ['seed-official-eu-vm'],
|
||||
cpuMax: 600,
|
||||
memoryMax: 16384,
|
||||
diskMax: 204800,
|
||||
bandwidthMax: 3000,
|
||||
networkMode: 'nat_ipv6',
|
||||
instanceType: 'vm',
|
||||
privileged: false,
|
||||
nested: false,
|
||||
active: true,
|
||||
portLimit: 20,
|
||||
snapshotLimit: 6,
|
||||
backupLimit: 4,
|
||||
siteLimit: 2,
|
||||
monthlyTrafficLimit: bytesFromGb(1000),
|
||||
ioLimitMode: 'iops',
|
||||
limitsRead: '300MB',
|
||||
limitsWrite: '300MB',
|
||||
limitsReadIops: 2000,
|
||||
limitsWriteIops: 2000,
|
||||
limitsIngress: '1Gbit',
|
||||
limitsEgress: '1Gbit',
|
||||
limitsProcesses: 600,
|
||||
limitsCpuPriority: 8,
|
||||
bootAutostart: true,
|
||||
bootAutostartPriority: 15,
|
||||
bootAutostartDelay: 20,
|
||||
bootHostShutdownTimeout: 60,
|
||||
globalShared: true,
|
||||
globalQuotaMultiplier: 1.5,
|
||||
globalMaxInstances: 4,
|
||||
allowInstanceDeletion: true,
|
||||
plans: [
|
||||
{
|
||||
name: 'VM Starter',
|
||||
description: '100% CPU / 2GB memory / 40GB disk',
|
||||
cpu: 100,
|
||||
memory: 2048,
|
||||
disk: 40960,
|
||||
portLimit: 6,
|
||||
snapshotLimit: 2,
|
||||
backupLimit: 1,
|
||||
siteLimit: 0,
|
||||
swapSize: 0,
|
||||
trafficLimit: bytesFromGb(200),
|
||||
trafficLimitSpeed: '5Mbit',
|
||||
price: 2990,
|
||||
billingCycle: 1,
|
||||
setupFee: 500,
|
||||
isActive: true,
|
||||
sortOrder: 10,
|
||||
slaGuarantee: 99.9
|
||||
},
|
||||
{
|
||||
name: 'VM Business',
|
||||
description: '200% CPU / 4GB memory / 80GB disk',
|
||||
cpu: 200,
|
||||
memory: 4096,
|
||||
disk: 81920,
|
||||
portLimit: 12,
|
||||
snapshotLimit: 4,
|
||||
backupLimit: 2,
|
||||
siteLimit: 1,
|
||||
swapSize: 0,
|
||||
trafficLimit: bytesFromGb(400),
|
||||
trafficLimitSpeed: '10Mbit',
|
||||
price: 5990,
|
||||
billingCycle: 1,
|
||||
setupFee: 1000,
|
||||
isActive: true,
|
||||
sortOrder: 20,
|
||||
slaGuarantee: 99.95
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
ownerUsername: 'seed-market-alpha',
|
||||
name: 'SEED Market APAC Burst',
|
||||
description: 'Hosted public package for local market testing in Tokyo and Singapore.',
|
||||
hostNames: ['seed-market-jp-tokyo', 'seed-market-sg-latency'],
|
||||
cpuMax: 240,
|
||||
memoryMax: 6144,
|
||||
diskMax: 81920,
|
||||
bandwidthMax: 1500,
|
||||
networkMode: 'nat_ipv6_nat',
|
||||
instanceType: 'container',
|
||||
privileged: true,
|
||||
nested: true,
|
||||
active: true,
|
||||
portLimit: 32,
|
||||
snapshotLimit: 6,
|
||||
backupLimit: 3,
|
||||
siteLimit: 6,
|
||||
monthlyTrafficLimit: bytesFromGb(500),
|
||||
ioLimitMode: 'throughput',
|
||||
limitsRead: '180MB',
|
||||
limitsWrite: '180MB',
|
||||
limitsReadIops: 1000,
|
||||
limitsWriteIops: 1000,
|
||||
limitsIngress: '600Mbit',
|
||||
limitsEgress: '600Mbit',
|
||||
limitsProcesses: 900,
|
||||
limitsCpuPriority: 9,
|
||||
bootAutostart: true,
|
||||
bootAutostartPriority: 25,
|
||||
bootAutostartDelay: 10,
|
||||
bootHostShutdownTimeout: 40,
|
||||
globalShared: true,
|
||||
globalQuotaMultiplier: 1.5,
|
||||
globalMaxInstances: 5,
|
||||
allowInstanceDeletion: true,
|
||||
plans: [
|
||||
{
|
||||
name: 'Burst Nano',
|
||||
description: '30% CPU / 768MB memory / 20GB disk',
|
||||
cpu: 30,
|
||||
memory: 768,
|
||||
disk: 20480,
|
||||
portLimit: 8,
|
||||
snapshotLimit: 2,
|
||||
backupLimit: 1,
|
||||
siteLimit: 1,
|
||||
swapSize: 0,
|
||||
trafficLimit: bytesFromGb(80),
|
||||
trafficLimitSpeed: '2Mbit',
|
||||
price: 880,
|
||||
billingCycle: 1,
|
||||
setupFee: 0,
|
||||
isActive: true,
|
||||
sortOrder: 10,
|
||||
slaGuarantee: 99.5
|
||||
},
|
||||
{
|
||||
name: 'Burst Plus',
|
||||
description: '80% CPU / 2GB memory / 45GB disk',
|
||||
cpu: 80,
|
||||
memory: 2048,
|
||||
disk: 46080,
|
||||
portLimit: 16,
|
||||
snapshotLimit: 4,
|
||||
backupLimit: 2,
|
||||
siteLimit: 3,
|
||||
swapSize: 512,
|
||||
trafficLimit: bytesFromGb(180),
|
||||
trafficLimitSpeed: '5Mbit',
|
||||
price: 2280,
|
||||
billingCycle: 1,
|
||||
setupFee: 0,
|
||||
isActive: true,
|
||||
sortOrder: 20,
|
||||
slaGuarantee: 99.8
|
||||
},
|
||||
{
|
||||
name: 'Burst Pro',
|
||||
description: '140% CPU / 4GB memory / 70GB disk',
|
||||
cpu: 140,
|
||||
memory: 4096,
|
||||
disk: 71680,
|
||||
portLimit: 24,
|
||||
snapshotLimit: 6,
|
||||
backupLimit: 3,
|
||||
siteLimit: 5,
|
||||
swapSize: 1024,
|
||||
trafficLimit: bytesFromGb(320),
|
||||
trafficLimitSpeed: '8Mbit',
|
||||
price: 3680,
|
||||
billingCycle: 1,
|
||||
setupFee: 0,
|
||||
isActive: true,
|
||||
sortOrder: 30,
|
||||
slaGuarantee: 99.9
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
ownerUsername: 'seed-market-beta',
|
||||
name: 'SEED Market US Balanced',
|
||||
description: 'Hosted public package for local market testing in the US East region.',
|
||||
hostNames: ['seed-market-us-east'],
|
||||
cpuMax: 260,
|
||||
memoryMax: 8192,
|
||||
diskMax: 102400,
|
||||
bandwidthMax: 1800,
|
||||
networkMode: 'nat',
|
||||
instanceType: 'container',
|
||||
privileged: true,
|
||||
nested: true,
|
||||
active: true,
|
||||
portLimit: 28,
|
||||
snapshotLimit: 5,
|
||||
backupLimit: 3,
|
||||
siteLimit: 4,
|
||||
monthlyTrafficLimit: bytesFromGb(420),
|
||||
ioLimitMode: 'throughput',
|
||||
limitsRead: '200MB',
|
||||
limitsWrite: '200MB',
|
||||
limitsReadIops: 1100,
|
||||
limitsWriteIops: 1100,
|
||||
limitsIngress: '700Mbit',
|
||||
limitsEgress: '700Mbit',
|
||||
limitsProcesses: 950,
|
||||
limitsCpuPriority: 9,
|
||||
bootAutostart: true,
|
||||
bootAutostartPriority: 18,
|
||||
bootAutostartDelay: 12,
|
||||
bootHostShutdownTimeout: 40,
|
||||
globalShared: true,
|
||||
globalQuotaMultiplier: 1.2,
|
||||
globalMaxInstances: 4,
|
||||
allowInstanceDeletion: true,
|
||||
plans: [
|
||||
{
|
||||
name: 'Balanced S',
|
||||
description: '40% CPU / 1GB memory / 25GB disk',
|
||||
cpu: 40,
|
||||
memory: 1024,
|
||||
disk: 25600,
|
||||
portLimit: 10,
|
||||
snapshotLimit: 2,
|
||||
backupLimit: 1,
|
||||
siteLimit: 1,
|
||||
swapSize: 0,
|
||||
trafficLimit: bytesFromGb(100),
|
||||
trafficLimitSpeed: '3Mbit',
|
||||
price: 1090,
|
||||
billingCycle: 1,
|
||||
setupFee: 0,
|
||||
isActive: true,
|
||||
sortOrder: 10,
|
||||
slaGuarantee: 99.6
|
||||
},
|
||||
{
|
||||
name: 'Balanced M',
|
||||
description: '90% CPU / 3GB memory / 60GB disk',
|
||||
cpu: 90,
|
||||
memory: 3072,
|
||||
disk: 61440,
|
||||
portLimit: 20,
|
||||
snapshotLimit: 4,
|
||||
backupLimit: 2,
|
||||
siteLimit: 2,
|
||||
swapSize: 512,
|
||||
trafficLimit: bytesFromGb(220),
|
||||
trafficLimitSpeed: '6Mbit',
|
||||
price: 2490,
|
||||
billingCycle: 1,
|
||||
setupFee: 0,
|
||||
isActive: true,
|
||||
sortOrder: 20,
|
||||
slaGuarantee: 99.85
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await db.initPrismaDatabase()
|
||||
|
||||
// 先准备账号,再按账号归属写入节点、公开套餐和套餐方案。
|
||||
for (const user of seedUsers) {
|
||||
await ensureUser(user)
|
||||
}
|
||||
|
||||
for (const host of seedHosts) {
|
||||
await ensureHost(host)
|
||||
}
|
||||
|
||||
for (const pkg of seedPackages) {
|
||||
const packageId = await ensurePackage(pkg)
|
||||
await ensurePlans(packageId, pkg.plans)
|
||||
}
|
||||
|
||||
console.log('[seed:test-data] completed')
|
||||
console.log(
|
||||
`[seed:test-data] users created=${stats.users.created} updated=${stats.users.updated}, ` +
|
||||
`hosts created=${stats.hosts.created} updated=${stats.hosts.updated}, ` +
|
||||
`packages created=${stats.packages.created} updated=${stats.packages.updated}, ` +
|
||||
`plans created=${stats.plans.created} updated=${stats.plans.updated}`
|
||||
)
|
||||
console.log(
|
||||
`[seed:test-data] seeded targets users=${seedUsers.length}, hosts=${seedHosts.length}, ` +
|
||||
`packages=${seedPackages.length}, plans=${countPlans(seedPackages)}`
|
||||
)
|
||||
}
|
||||
|
||||
void main().catch(async error => {
|
||||
console.error('[seed:test-data] failed:', error)
|
||||
process.exitCode = 1
|
||||
}).finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
createAgentBodyHash,
|
||||
createAgentSignature,
|
||||
generateAgentSecret,
|
||||
isAgentTimestampFresh,
|
||||
stableStringifyAgentBody,
|
||||
verifyAgentSignature
|
||||
} from '../src/lib/agent-auth.js'
|
||||
|
||||
const bodyA = {
|
||||
version: '0.1.0',
|
||||
resources: {
|
||||
memory: 1024,
|
||||
cpu: 8
|
||||
},
|
||||
capabilities: ['heartbeat', 'report']
|
||||
}
|
||||
|
||||
const bodyB = {
|
||||
capabilities: ['heartbeat', 'report'],
|
||||
resources: {
|
||||
cpu: 8,
|
||||
memory: 1024
|
||||
},
|
||||
version: '0.1.0'
|
||||
}
|
||||
|
||||
assert.equal(stableStringifyAgentBody(bodyA), stableStringifyAgentBody(bodyB))
|
||||
assert.equal(createAgentBodyHash(bodyA), createAgentBodyHash(bodyB))
|
||||
|
||||
const secret = generateAgentSecret()
|
||||
const payload = {
|
||||
method: 'POST',
|
||||
path: '/api/agent/heartbeat',
|
||||
timestamp: String(Date.now()),
|
||||
nonce: 'nonce-123456',
|
||||
bodyHash: createAgentBodyHash(bodyA)
|
||||
}
|
||||
const signature = createAgentSignature(secret, payload)
|
||||
|
||||
assert.equal(verifyAgentSignature(secret, payload, signature), true)
|
||||
assert.equal(verifyAgentSignature(secret, { ...payload, path: '/api/agent/report' }, signature), false)
|
||||
assert.equal(isAgentTimestampFresh(payload.timestamp), true)
|
||||
assert.equal(isAgentTimestampFresh(String(Date.now() - 10 * 60 * 1000)), false)
|
||||
|
||||
console.log('agent-auth self-test passed')
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { checkJwtConfig } from '../src/lib/security-config.js'
|
||||
|
||||
const originalEnv = {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
JWT_SECRET: process.env.JWT_SECRET,
|
||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY
|
||||
}
|
||||
|
||||
function setEnv(input: {
|
||||
nodeEnv: string
|
||||
jwtSecret?: string
|
||||
encryptionKey?: string
|
||||
}) {
|
||||
process.env.NODE_ENV = input.nodeEnv
|
||||
if (input.jwtSecret === undefined) {
|
||||
delete process.env.JWT_SECRET
|
||||
} else {
|
||||
process.env.JWT_SECRET = input.jwtSecret
|
||||
}
|
||||
if (input.encryptionKey === undefined) {
|
||||
delete process.env.ENCRYPTION_KEY
|
||||
} else {
|
||||
process.env.ENCRYPTION_KEY = input.encryptionKey
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnv() {
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const strongJwtSecret = 'JwT!AlphaBeta2026_Long_Value_ABCDE'
|
||||
const strongEncryptionKey = 'EncK!AlphaBeta2026_Long_Value_ABCDE'
|
||||
|
||||
try {
|
||||
setEnv({
|
||||
nodeEnv: 'production',
|
||||
jwtSecret: strongJwtSecret
|
||||
})
|
||||
let result = checkJwtConfig()
|
||||
assert.equal(result.valid, false)
|
||||
assert.ok(result.warnings.includes('ENCRYPTION_KEY must be configured in production'))
|
||||
|
||||
setEnv({
|
||||
nodeEnv: 'production',
|
||||
jwtSecret: strongJwtSecret,
|
||||
encryptionKey: 'ShortEnc1!'
|
||||
})
|
||||
result = checkJwtConfig()
|
||||
assert.equal(result.valid, false)
|
||||
assert.ok(result.warnings.includes('ENCRYPTION_KEY must be at least 32 characters in production'))
|
||||
|
||||
setEnv({
|
||||
nodeEnv: 'production',
|
||||
jwtSecret: strongJwtSecret,
|
||||
encryptionKey: strongEncryptionKey
|
||||
})
|
||||
result = checkJwtConfig()
|
||||
assert.equal(result.valid, true)
|
||||
assert.deepEqual(result.warnings, [])
|
||||
|
||||
setEnv({
|
||||
nodeEnv: 'development',
|
||||
jwtSecret: strongJwtSecret
|
||||
})
|
||||
result = checkJwtConfig()
|
||||
assert.equal(result.valid, true)
|
||||
assert.ok(result.warnings.includes('ENCRYPTION_KEY not configured, will use JWT_SECRET as fallback'))
|
||||
|
||||
console.log('security-config self-test passed')
|
||||
} finally {
|
||||
restoreEnv()
|
||||
}
|
||||
Reference in New Issue
Block a user