feat: add Phase 4 x402 metering and Phase 5 hardening
Phase 4: Usage metrics service (container stats, post count, storage), billing calculator with pro-rated monthly pricing, usage + payment routes, x402 middleware wired to provisioning endpoint. Phase 5: In-memory rate limiter (general 10/min + provision 2/hour), admin routes (force teardown, suspend/resume), Dockerfile hardened with non-root user and healthcheck. Suspended instance status added. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
908e2257e4
commit
a536a9bc0f
|
|
@ -2,17 +2,28 @@ FROM oven/bun:1-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install deps as root
|
||||||
COPY package.json bun.lock* ./
|
COPY package.json bun.lock* ./
|
||||||
RUN bun install --frozen-lockfile 2>/dev/null || bun install
|
RUN bun install --frozen-lockfile 2>/dev/null || bun install
|
||||||
|
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
|
|
||||||
RUN mkdir -p /data
|
# Create data dir and non-root user
|
||||||
|
RUN mkdir -p /data && \
|
||||||
|
addgroup -g 1001 rsocials && \
|
||||||
|
adduser -u 1001 -G rsocials -s /bin/sh -D rsocials && \
|
||||||
|
chown -R rsocials:rsocials /app /data
|
||||||
|
|
||||||
|
USER rsocials
|
||||||
|
|
||||||
ENV PORT=3001
|
ENV PORT=3001
|
||||||
ENV DATABASE_PATH=/data/instances.db
|
ENV DATABASE_PATH=/data/instances.db
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:3001/health || exit 1
|
||||||
|
|
||||||
CMD ["bun", "run", "src/index.ts"]
|
CMD ["bun", "run", "src/index.ts"]
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,14 @@ import { initDb } from "./db/schema.js";
|
||||||
import { InstanceStore } from "./db/queries.js";
|
import { InstanceStore } from "./db/queries.js";
|
||||||
import { siweAuth } from "./middleware/siwe-auth.js";
|
import { siweAuth } from "./middleware/siwe-auth.js";
|
||||||
import { tokenGate } from "./middleware/token-gate.js";
|
import { tokenGate } from "./middleware/token-gate.js";
|
||||||
|
import { rateLimiter, provisionRateLimiter } from "./middleware/rate-limit.js";
|
||||||
|
import { setupX402FromEnv } from "./middleware/x402.js";
|
||||||
import { healthRoutes } from "./routes/health.js";
|
import { healthRoutes } from "./routes/health.js";
|
||||||
import { authRoutes } from "./routes/auth.js";
|
import { authRoutes } from "./routes/auth.js";
|
||||||
import { spacesRoutes } from "./routes/spaces.js";
|
import { spacesRoutes } from "./routes/spaces.js";
|
||||||
import { provisionRoutes } from "./routes/provision.js";
|
import { provisionRoutes } from "./routes/provision.js";
|
||||||
|
import { usageRoutes } from "./routes/usage.js";
|
||||||
|
import { adminRoutes } from "./routes/admin.js";
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT ?? "3001", 10);
|
const PORT = parseInt(process.env.PORT ?? "3001", 10);
|
||||||
const DB_PATH = process.env.DATABASE_PATH ?? "./data/instances.db";
|
const DB_PATH = process.env.DATABASE_PATH ?? "./data/instances.db";
|
||||||
|
|
@ -31,8 +35,30 @@ app.route("/v1/auth", authRoutes());
|
||||||
// Protected routes (SIWE wallet or API key)
|
// Protected routes (SIWE wallet or API key)
|
||||||
app.use("/v1/spaces/*", siweAuth);
|
app.use("/v1/spaces/*", siweAuth);
|
||||||
app.use("/v1/spaces/*", tokenGate);
|
app.use("/v1/spaces/*", tokenGate);
|
||||||
|
app.use("/v1/spaces/*", rateLimiter);
|
||||||
|
|
||||||
|
// x402 payment gate on provisioning (only if X402_PAY_TO is set)
|
||||||
|
const x402Middleware = setupX402FromEnv();
|
||||||
|
if (x402Middleware) {
|
||||||
|
// Apply x402 only to POST (provisioning) and pay endpoint
|
||||||
|
app.use("/v1/spaces", async (c, next) => {
|
||||||
|
if (c.req.method === "POST") return x402Middleware(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision rate limiter (stricter — 2/hour)
|
||||||
|
app.post("/v1/spaces", provisionRateLimiter);
|
||||||
|
|
||||||
|
// Space routes
|
||||||
app.route("/v1/spaces", provisionRoutes(store));
|
app.route("/v1/spaces", provisionRoutes(store));
|
||||||
app.route("/v1/spaces", spacesRoutes(store));
|
app.route("/v1/spaces", spacesRoutes(store));
|
||||||
|
app.route("/v1/spaces", usageRoutes(store));
|
||||||
|
|
||||||
|
// Admin routes (API key only)
|
||||||
|
app.use("/v1/admin/*", siweAuth); // reuse auth (checks API key first)
|
||||||
|
app.use("/v1/admin/*", rateLimiter);
|
||||||
|
app.route("/v1/admin", adminRoutes(store));
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.notFound((c) => c.json({ error: "Not found" }, 404));
|
app.notFound((c) => c.json({ error: "Not found" }, 404));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
/**
|
||||||
|
* In-memory rate limiter per wallet/IP.
|
||||||
|
*
|
||||||
|
* Configurable:
|
||||||
|
* RATE_LIMIT_WINDOW_MS=60000 (1 minute default)
|
||||||
|
* RATE_LIMIT_MAX_REQUESTS=10 (10 requests per window)
|
||||||
|
* RATE_LIMIT_PROVISION_MAX=2 (2 provisions per hour)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context, Next } from "hono";
|
||||||
|
|
||||||
|
interface RateEntry {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? "60000", 10);
|
||||||
|
const MAX_REQUESTS = parseInt(
|
||||||
|
process.env.RATE_LIMIT_MAX_REQUESTS ?? "10",
|
||||||
|
10
|
||||||
|
);
|
||||||
|
const PROVISION_MAX = parseInt(
|
||||||
|
process.env.RATE_LIMIT_PROVISION_MAX ?? "2",
|
||||||
|
10
|
||||||
|
);
|
||||||
|
const PROVISION_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
const generalLimits = new Map<string, RateEntry>();
|
||||||
|
const provisionLimits = new Map<string, RateEntry>();
|
||||||
|
|
||||||
|
function cleanExpired(store: Map<string, RateEntry>) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of store) {
|
||||||
|
if (now > entry.resetAt) store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIdentifier(c: Context): string {
|
||||||
|
const owner = c.get("owner" as never) as string | undefined;
|
||||||
|
if (owner && owner !== "admin") return owner;
|
||||||
|
return (
|
||||||
|
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||||
|
c.req.header("x-real-ip") ??
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkLimit(
|
||||||
|
store: Map<string, RateEntry>,
|
||||||
|
key: string,
|
||||||
|
max: number,
|
||||||
|
windowMs: number
|
||||||
|
): { allowed: boolean; remaining: number; resetAt: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = store.get(key);
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetAt) {
|
||||||
|
const resetAt = now + windowMs;
|
||||||
|
store.set(key, { count: 1, resetAt });
|
||||||
|
return { allowed: true, remaining: max - 1, resetAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
if (entry.count > max) {
|
||||||
|
return { allowed: false, remaining: 0, resetAt: entry.resetAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: max - entry.count,
|
||||||
|
resetAt: entry.resetAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General rate limiter for all API routes.
|
||||||
|
*/
|
||||||
|
export async function rateLimiter(c: Context, next: Next) {
|
||||||
|
const id = getIdentifier(c);
|
||||||
|
|
||||||
|
// Admin API key bypasses rate limiting
|
||||||
|
const authMethod = c.get("authMethod" as never) as string | undefined;
|
||||||
|
if (authMethod === "api-key") return next();
|
||||||
|
|
||||||
|
// Clean expired entries periodically
|
||||||
|
if (Math.random() < 0.1) cleanExpired(generalLimits);
|
||||||
|
|
||||||
|
const result = checkLimit(generalLimits, id, MAX_REQUESTS, WINDOW_MS);
|
||||||
|
|
||||||
|
c.header("X-RateLimit-Limit", String(MAX_REQUESTS));
|
||||||
|
c.header("X-RateLimit-Remaining", String(result.remaining));
|
||||||
|
c.header("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));
|
||||||
|
|
||||||
|
if (!result.allowed) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: "Too many requests",
|
||||||
|
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
|
||||||
|
},
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stricter rate limiter specifically for provisioning.
|
||||||
|
* 2 provisions per hour per wallet.
|
||||||
|
*/
|
||||||
|
export async function provisionRateLimiter(c: Context, next: Next) {
|
||||||
|
const id = getIdentifier(c);
|
||||||
|
|
||||||
|
const authMethod = c.get("authMethod" as never) as string | undefined;
|
||||||
|
if (authMethod === "api-key") return next();
|
||||||
|
|
||||||
|
if (Math.random() < 0.1) cleanExpired(provisionLimits);
|
||||||
|
|
||||||
|
const result = checkLimit(
|
||||||
|
provisionLimits,
|
||||||
|
id,
|
||||||
|
PROVISION_MAX,
|
||||||
|
PROVISION_WINDOW_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.allowed) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: "Provisioning rate limit exceeded",
|
||||||
|
detail: `Max ${PROVISION_MAX} provisions per hour`,
|
||||||
|
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
|
||||||
|
},
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
/**
|
||||||
|
* Admin-only routes for managing the provisioning platform.
|
||||||
|
*
|
||||||
|
* All routes require X-API-Key authentication (admin only).
|
||||||
|
*
|
||||||
|
* GET /v1/admin/instances — List all instances (any owner)
|
||||||
|
* DELETE /v1/admin/instances/:slug — Force teardown (bypass owner check)
|
||||||
|
* POST /v1/admin/instances/:slug/suspend — Suspend an instance
|
||||||
|
* POST /v1/admin/instances/:slug/resume — Resume a suspended instance
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { InstanceStore } from "../db/queries.js";
|
||||||
|
import {
|
||||||
|
teardownSpace,
|
||||||
|
checkContainerHealth,
|
||||||
|
} from "../services/docker-deployer.js";
|
||||||
|
import {
|
||||||
|
removeTunnelHostnames,
|
||||||
|
restartCloudflared,
|
||||||
|
} from "../services/tunnel-config.js";
|
||||||
|
|
||||||
|
export function adminRoutes(store: InstanceStore) {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Admin guard — only API key auth allowed
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
const authMethod = c.get("authMethod" as never) as string;
|
||||||
|
if (authMethod !== "api-key") {
|
||||||
|
return c.json({ error: "Admin access required" }, 403);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all instances (all owners)
|
||||||
|
app.get("/instances", (c) => {
|
||||||
|
const instances = store.list();
|
||||||
|
const summary = {
|
||||||
|
total: instances.length,
|
||||||
|
active: instances.filter((i) => i.status === "active").length,
|
||||||
|
provisioning: instances.filter((i) => i.status === "provisioning").length,
|
||||||
|
failed: instances.filter((i) => i.status === "failed").length,
|
||||||
|
};
|
||||||
|
return c.json({ summary, instances });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force teardown (bypass owner check)
|
||||||
|
app.delete("/instances/:slug", async (c) => {
|
||||||
|
const slug = c.req.param("slug");
|
||||||
|
const instance = store.getBySlug(slug);
|
||||||
|
if (!instance) {
|
||||||
|
return c.json({ error: "Instance not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instance.status === "destroyed") {
|
||||||
|
return c.json({ error: "Already destroyed" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
store.updateStatus(instance.id, "teardown");
|
||||||
|
store.addLog(instance.id, "admin_force_teardown");
|
||||||
|
|
||||||
|
// Run teardown in background
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
removeTunnelHostnames(instance.primaryDomain, instance.fallbackDomain);
|
||||||
|
await restartCloudflared();
|
||||||
|
await teardownSpace(instance.slug);
|
||||||
|
store.updateStatus(instance.id, "destroyed");
|
||||||
|
store.addLog(instance.id, "teardown_complete");
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
store.addLog(instance.id, "teardown_error", msg);
|
||||||
|
store.updateStatus(instance.id, "failed");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return c.json({ message: "Force teardown started", instance });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Suspend an instance (stop containers but keep data)
|
||||||
|
app.post("/instances/:slug/suspend", async (c) => {
|
||||||
|
const slug = c.req.param("slug");
|
||||||
|
const instance = store.getBySlug(slug);
|
||||||
|
if (!instance) {
|
||||||
|
return c.json({ error: "Instance not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instance.status !== "active") {
|
||||||
|
return c.json({ error: `Cannot suspend: status is ${instance.status}` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["docker", "compose", "-f", instance.composePath!, "stop"],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" }
|
||||||
|
);
|
||||||
|
await proc.exited;
|
||||||
|
|
||||||
|
store.updateStatus(instance.id, "suspended" as never);
|
||||||
|
store.addLog(instance.id, "admin_suspend");
|
||||||
|
|
||||||
|
return c.json({ message: "Instance suspended", slug });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return c.json({ error: `Suspend failed: ${msg}` }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resume a suspended instance
|
||||||
|
app.post("/instances/:slug/resume", async (c) => {
|
||||||
|
const slug = c.req.param("slug");
|
||||||
|
const instance = store.getBySlug(slug);
|
||||||
|
if (!instance) {
|
||||||
|
return c.json({ error: "Instance not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["docker", "compose", "-f", instance.composePath!, "start"],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" }
|
||||||
|
);
|
||||||
|
await proc.exited;
|
||||||
|
|
||||||
|
const healthy = await checkContainerHealth(slug);
|
||||||
|
if (!healthy) {
|
||||||
|
store.addLog(instance.id, "resume_unhealthy");
|
||||||
|
return c.json({ error: "Containers started but unhealthy" }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
store.updateStatus(instance.id, "active");
|
||||||
|
store.addLog(instance.id, "admin_resume");
|
||||||
|
|
||||||
|
return c.json({ message: "Instance resumed", slug });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return c.json({ error: `Resume failed: ${msg}` }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* Usage & billing routes.
|
||||||
|
*
|
||||||
|
* GET /v1/spaces/:slug/usage — Container stats, post count, storage, billing estimate
|
||||||
|
* POST /v1/spaces/:slug/pay — Settle balance via x402 payment
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { InstanceStore } from "../db/queries.js";
|
||||||
|
import { collectMetrics } from "../services/metrics.js";
|
||||||
|
import { calculateBill } from "../services/billing.js";
|
||||||
|
import { setupX402FromEnv } from "../middleware/x402.js";
|
||||||
|
|
||||||
|
export function usageRoutes(store: InstanceStore) {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Get usage + billing estimate for an instance
|
||||||
|
app.get("/:slug/usage", async (c) => {
|
||||||
|
const slug = c.req.param("slug");
|
||||||
|
const instance = store.getBySlug(slug);
|
||||||
|
if (!instance) {
|
||||||
|
return c.json({ error: "Instance not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = c.get("owner" as never) as string;
|
||||||
|
if (owner !== "admin" && instance.owner !== owner) {
|
||||||
|
return c.json({ error: "Not authorized" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = await collectMetrics(slug, instance.status);
|
||||||
|
const billing = calculateBill(
|
||||||
|
slug,
|
||||||
|
metrics.postCount ?? 0,
|
||||||
|
metrics.storageMB ?? 0,
|
||||||
|
instance.createdAt
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ usage: metrics, billing });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settle balance — x402 payment endpoint
|
||||||
|
app.post("/:slug/pay", async (c) => {
|
||||||
|
const slug = c.req.param("slug");
|
||||||
|
const instance = store.getBySlug(slug);
|
||||||
|
if (!instance) {
|
||||||
|
return c.json({ error: "Instance not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = c.get("owner" as never) as string;
|
||||||
|
if (owner !== "admin" && instance.owner !== owner) {
|
||||||
|
return c.json({ error: "Not authorized" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If x402 is enabled, the middleware will have already verified payment
|
||||||
|
const payment = c.get("x402Payment" as never) as string | undefined;
|
||||||
|
|
||||||
|
if (payment) {
|
||||||
|
store.addLog(
|
||||||
|
instance.id,
|
||||||
|
"payment_received",
|
||||||
|
`x402 payment verified`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
status: "paid",
|
||||||
|
instance: { slug: instance.slug, status: instance.status },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire x402 middleware to the payment endpoint (if configured).
|
||||||
|
*/
|
||||||
|
export function getX402PayMiddleware() {
|
||||||
|
return setupX402FromEnv({
|
||||||
|
description: "Monthly payment for Postiz space hosting",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Billing calculator for x402 metered usage.
|
||||||
|
*
|
||||||
|
* Pricing:
|
||||||
|
* Monthly base: $5.00 USDC (covers ~2.5GB RAM)
|
||||||
|
* Per post: $0.01 USDC
|
||||||
|
* Per GB storage: $0.50 USDC (above 1GB free tier)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BillingPeriod {
|
||||||
|
slug: string;
|
||||||
|
periodStart: string; // ISO date
|
||||||
|
periodEnd: string;
|
||||||
|
baseCostUSD: number;
|
||||||
|
postCount: number;
|
||||||
|
postCostUSD: number;
|
||||||
|
storageMB: number;
|
||||||
|
storageCostUSD: number;
|
||||||
|
totalUSD: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_MONTHLY_USD = parseFloat(
|
||||||
|
process.env.X402_BASE_MONTHLY_PRICE ?? "5.00"
|
||||||
|
);
|
||||||
|
const PER_POST_USD = parseFloat(process.env.X402_PER_POST_PRICE ?? "0.01");
|
||||||
|
const PER_GB_USD = parseFloat(process.env.X402_PER_GB_PRICE ?? "0.50");
|
||||||
|
const FREE_STORAGE_MB = 1024; // 1 GB free tier
|
||||||
|
|
||||||
|
export function calculateBill(
|
||||||
|
slug: string,
|
||||||
|
postCount: number,
|
||||||
|
storageMB: number,
|
||||||
|
createdAt: string
|
||||||
|
): BillingPeriod {
|
||||||
|
const now = new Date();
|
||||||
|
const created = new Date(createdAt);
|
||||||
|
|
||||||
|
// Pro-rate base cost if instance is less than a month old
|
||||||
|
const ageMs = now.getTime() - created.getTime();
|
||||||
|
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
||||||
|
const monthFraction = Math.min(ageDays / 30, 1);
|
||||||
|
const baseCostUSD = Math.round(BASE_MONTHLY_USD * monthFraction * 100) / 100;
|
||||||
|
|
||||||
|
const postCostUSD = Math.round(postCount * PER_POST_USD * 100) / 100;
|
||||||
|
|
||||||
|
const billableStorageMB = Math.max(0, storageMB - FREE_STORAGE_MB);
|
||||||
|
const billableStorageGB = billableStorageMB / 1024;
|
||||||
|
const storageCostUSD = Math.round(billableStorageGB * PER_GB_USD * 100) / 100;
|
||||||
|
|
||||||
|
const totalUSD =
|
||||||
|
Math.round((baseCostUSD + postCostUSD + storageCostUSD) * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
periodStart: created.toISOString(),
|
||||||
|
periodEnd: now.toISOString(),
|
||||||
|
baseCostUSD,
|
||||||
|
postCount,
|
||||||
|
postCostUSD,
|
||||||
|
storageMB,
|
||||||
|
storageCostUSD,
|
||||||
|
totalUSD,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
/**
|
||||||
|
* Instance usage metrics — queries Docker for container stats and
|
||||||
|
* the instance's PostgreSQL for post counts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UsageMetrics {
|
||||||
|
slug: string;
|
||||||
|
status: string;
|
||||||
|
containerStats: ContainerStats | null;
|
||||||
|
postCount: number | null;
|
||||||
|
storageMB: number | null;
|
||||||
|
uptimeHours: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContainerStats {
|
||||||
|
state: string;
|
||||||
|
memoryUsageMB: number;
|
||||||
|
cpuPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get container resource usage from Docker.
|
||||||
|
*/
|
||||||
|
export async function getContainerStats(
|
||||||
|
slug: string
|
||||||
|
): Promise<ContainerStats | null> {
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"stats",
|
||||||
|
`postiz-${slug}`,
|
||||||
|
"--no-stream",
|
||||||
|
"--format",
|
||||||
|
"{{.MemUsage}}\t{{.CPUPerc}}\t{{.Name}}",
|
||||||
|
],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" }
|
||||||
|
);
|
||||||
|
const output = await new Response(proc.stdout).text();
|
||||||
|
await proc.exited;
|
||||||
|
|
||||||
|
if (proc.exitCode !== 0) return null;
|
||||||
|
|
||||||
|
const line = output.trim();
|
||||||
|
if (!line) return null;
|
||||||
|
|
||||||
|
const parts = line.split("\t");
|
||||||
|
const memStr = parts[0]?.split("/")[0]?.trim() ?? "0MiB";
|
||||||
|
const cpuStr = parts[1]?.replace("%", "").trim() ?? "0";
|
||||||
|
|
||||||
|
// Parse memory (could be MiB or GiB)
|
||||||
|
let memoryUsageMB = 0;
|
||||||
|
if (memStr.includes("GiB")) {
|
||||||
|
memoryUsageMB = parseFloat(memStr) * 1024;
|
||||||
|
} else {
|
||||||
|
memoryUsageMB = parseFloat(memStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: "running",
|
||||||
|
memoryUsageMB: Math.round(memoryUsageMB),
|
||||||
|
cpuPercent: parseFloat(cpuStr),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get post count from instance's PostgreSQL container.
|
||||||
|
*/
|
||||||
|
export async function getPostCount(slug: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
`postiz-${slug}-db`,
|
||||||
|
"psql",
|
||||||
|
"-U",
|
||||||
|
"postiz",
|
||||||
|
"-d",
|
||||||
|
"postiz-db",
|
||||||
|
"-t",
|
||||||
|
"-c",
|
||||||
|
"SELECT COUNT(*) FROM posts;",
|
||||||
|
],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" }
|
||||||
|
);
|
||||||
|
const output = await new Response(proc.stdout).text();
|
||||||
|
await proc.exited;
|
||||||
|
|
||||||
|
if (proc.exitCode !== 0) return null;
|
||||||
|
return parseInt(output.trim(), 10) || 0;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage usage from Docker volumes.
|
||||||
|
*/
|
||||||
|
export async function getStorageMB(slug: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"system",
|
||||||
|
"df",
|
||||||
|
"-v",
|
||||||
|
"--format",
|
||||||
|
"{{.Name}}\t{{.Size}}",
|
||||||
|
],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" }
|
||||||
|
);
|
||||||
|
const output = await new Response(proc.stdout).text();
|
||||||
|
await proc.exited;
|
||||||
|
|
||||||
|
// Sum volumes matching the slug
|
||||||
|
let totalMB = 0;
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
if (line.includes(slug)) {
|
||||||
|
const sizeStr = line.split("\t")[1] ?? "0B";
|
||||||
|
if (sizeStr.includes("GB")) {
|
||||||
|
totalMB += parseFloat(sizeStr) * 1024;
|
||||||
|
} else if (sizeStr.includes("MB")) {
|
||||||
|
totalMB += parseFloat(sizeStr);
|
||||||
|
} else if (sizeStr.includes("kB")) {
|
||||||
|
totalMB += parseFloat(sizeStr) / 1024;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(totalMB);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get container uptime in hours.
|
||||||
|
*/
|
||||||
|
export async function getUptimeHours(slug: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
`postiz-${slug}`,
|
||||||
|
"--format",
|
||||||
|
"{{.State.StartedAt}}",
|
||||||
|
],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" }
|
||||||
|
);
|
||||||
|
const output = await new Response(proc.stdout).text();
|
||||||
|
await proc.exited;
|
||||||
|
|
||||||
|
if (proc.exitCode !== 0) return null;
|
||||||
|
|
||||||
|
const startedAt = new Date(output.trim());
|
||||||
|
const now = new Date();
|
||||||
|
const hours = (now.getTime() - startedAt.getTime()) / (1000 * 60 * 60);
|
||||||
|
return Math.round(hours * 10) / 10;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all usage metrics for an instance.
|
||||||
|
*/
|
||||||
|
export async function collectMetrics(
|
||||||
|
slug: string,
|
||||||
|
status: string
|
||||||
|
): Promise<UsageMetrics> {
|
||||||
|
if (status !== "active") {
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
status,
|
||||||
|
containerStats: null,
|
||||||
|
postCount: null,
|
||||||
|
storageMB: null,
|
||||||
|
uptimeHours: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [containerStats, postCount, storageMB, uptimeHours] =
|
||||||
|
await Promise.all([
|
||||||
|
getContainerStats(slug),
|
||||||
|
getPostCount(slug),
|
||||||
|
getStorageMB(slug),
|
||||||
|
getUptimeHours(slug),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { slug, status, containerStats, postCount, storageMB, uptimeHours };
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ export interface Instance {
|
||||||
export type InstanceStatus =
|
export type InstanceStatus =
|
||||||
| "provisioning"
|
| "provisioning"
|
||||||
| "active"
|
| "active"
|
||||||
|
| "suspended"
|
||||||
| "failed"
|
| "failed"
|
||||||
| "teardown"
|
| "teardown"
|
||||||
| "destroyed";
|
| "destroyed";
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,11 @@ const statusConfig: Record<
|
||||||
color: "bg-destructive/10 text-destructive",
|
color: "bg-destructive/10 text-destructive",
|
||||||
icon: <AlertCircle className="h-3 w-3" />,
|
icon: <AlertCircle className="h-3 w-3" />,
|
||||||
},
|
},
|
||||||
|
suspended: {
|
||||||
|
label: "Suspended",
|
||||||
|
color: "bg-blue-500/10 text-blue-600",
|
||||||
|
icon: <Clock className="h-3 w-3" />,
|
||||||
|
},
|
||||||
destroying: {
|
destroying: {
|
||||||
label: "Removing",
|
label: "Removing",
|
||||||
color: "bg-orange-500/10 text-orange-600",
|
color: "bg-orange-500/10 text-orange-600",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue