first commit

This commit is contained in:
qwer-xyz
2026-06-20 14:22:31 +08:00
commit c2498911ab
793 changed files with 291660 additions and 0 deletions
@@ -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
+975
View File
@@ -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()
})
+47
View File
@@ -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')
+79
View File
@@ -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()
}