chore: initial commit for phase03
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { nodes, allocations } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { createAuditLog } from '../../lib/audit.js';
|
||||
import {
|
||||
NodeParamSchema,
|
||||
CreateNodeSchema,
|
||||
UpdateNodeSchema,
|
||||
CreateAllocationSchema,
|
||||
} from './schemas.js';
|
||||
|
||||
export default async function nodeRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
// GET /api/organizations/:orgId/nodes
|
||||
app.get('/', async (request) => {
|
||||
const { orgId } = request.params as { orgId: string };
|
||||
await requirePermission(request, orgId, 'node.read');
|
||||
|
||||
const nodeList = await app.db
|
||||
.select()
|
||||
.from(nodes)
|
||||
.where(eq(nodes.organizationId, orgId))
|
||||
.orderBy(nodes.createdAt);
|
||||
|
||||
return { data: nodeList };
|
||||
});
|
||||
|
||||
// POST /api/organizations/:orgId/nodes
|
||||
app.post('/', { schema: CreateNodeSchema }, async (request, reply) => {
|
||||
const { orgId } = request.params as { orgId: string };
|
||||
await requirePermission(request, orgId, 'node.manage');
|
||||
|
||||
const body = request.body as {
|
||||
name: string;
|
||||
fqdn: string;
|
||||
daemonPort?: number;
|
||||
grpcPort?: number;
|
||||
location?: string;
|
||||
memoryTotal: number;
|
||||
diskTotal: number;
|
||||
memoryOveralloc?: number;
|
||||
diskOveralloc?: number;
|
||||
};
|
||||
|
||||
const daemonToken = randomBytes(32).toString('hex');
|
||||
|
||||
const [node] = await app.db
|
||||
.insert(nodes)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
...body,
|
||||
daemonToken,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
action: 'node.create',
|
||||
metadata: { nodeId: node!.id, name: body.name },
|
||||
});
|
||||
|
||||
return reply.code(201).send(node);
|
||||
});
|
||||
|
||||
// GET /api/organizations/:orgId/nodes/:nodeId
|
||||
app.get('/:nodeId', { schema: NodeParamSchema }, async (request) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.read');
|
||||
|
||||
const node = await app.db.query.nodes.findFirst({
|
||||
where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)),
|
||||
});
|
||||
if (!node) throw AppError.notFound('Node not found');
|
||||
|
||||
return node;
|
||||
});
|
||||
|
||||
// PATCH /api/organizations/:orgId/nodes/:nodeId
|
||||
app.patch('/:nodeId', { schema: { ...NodeParamSchema, ...UpdateNodeSchema } }, async (request) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.manage');
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(nodes)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)))
|
||||
.returning();
|
||||
|
||||
if (!updated) throw AppError.notFound('Node not found');
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
action: 'node.update',
|
||||
metadata: { nodeId, ...body },
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// DELETE /api/organizations/:orgId/nodes/:nodeId
|
||||
app.delete('/:nodeId', { schema: NodeParamSchema }, async (request, reply) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.manage');
|
||||
|
||||
const node = await app.db.query.nodes.findFirst({
|
||||
where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)),
|
||||
});
|
||||
if (!node) throw AppError.notFound('Node not found');
|
||||
|
||||
await app.db.delete(nodes).where(eq(nodes.id, nodeId));
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
action: 'node.delete',
|
||||
metadata: { nodeId, name: node.name },
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// === Allocations ===
|
||||
|
||||
// GET /api/organizations/:orgId/nodes/:nodeId/allocations
|
||||
app.get('/:nodeId/allocations', { schema: NodeParamSchema }, async (request) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.read');
|
||||
|
||||
const allocs = await app.db
|
||||
.select()
|
||||
.from(allocations)
|
||||
.where(eq(allocations.nodeId, nodeId))
|
||||
.orderBy(allocations.port);
|
||||
|
||||
return { data: allocs };
|
||||
});
|
||||
|
||||
// POST /api/organizations/:orgId/nodes/:nodeId/allocations
|
||||
app.post('/:nodeId/allocations', { schema: { ...NodeParamSchema, ...CreateAllocationSchema } }, async (request, reply) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.manage');
|
||||
|
||||
const { ip, ports } = request.body as { ip: string; ports: number[] };
|
||||
|
||||
const values = ports.map((port) => ({
|
||||
nodeId,
|
||||
ip,
|
||||
port,
|
||||
}));
|
||||
|
||||
const created = await app.db
|
||||
.insert(allocations)
|
||||
.values(values)
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
action: 'allocation.create',
|
||||
metadata: { nodeId, ip, ports },
|
||||
});
|
||||
|
||||
return reply.code(201).send({ data: created });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
|
||||
export const NodeParamSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
nodeId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const CreateNodeSchema = {
|
||||
body: Type.Object({
|
||||
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
fqdn: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
daemonPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535, default: 8443 })),
|
||||
grpcPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535, default: 50051 })),
|
||||
location: Type.Optional(Type.String({ maxLength: 255 })),
|
||||
memoryTotal: Type.Number({ minimum: 0 }),
|
||||
diskTotal: Type.Number({ minimum: 0 }),
|
||||
memoryOveralloc: Type.Optional(Type.Number({ minimum: 0, default: 0 })),
|
||||
diskOveralloc: Type.Optional(Type.Number({ minimum: 0, default: 0 })),
|
||||
}),
|
||||
};
|
||||
|
||||
export const UpdateNodeSchema = {
|
||||
body: Type.Object({
|
||||
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
fqdn: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
daemonPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })),
|
||||
grpcPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })),
|
||||
location: Type.Optional(Type.String({ maxLength: 255 })),
|
||||
memoryTotal: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
diskTotal: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
memoryOveralloc: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
diskOveralloc: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
}),
|
||||
};
|
||||
|
||||
export const CreateAllocationSchema = {
|
||||
body: Type.Object({
|
||||
ip: Type.String({ minLength: 1, maxLength: 45 }),
|
||||
ports: Type.Array(Type.Number({ minimum: 1, maximum: 65535 }), { minItems: 1 }),
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user