feat: customer detail page, size snapshots table, Spaces provisioning, Redis status cache
Some checks failed
Build & Release / build (push) Has been cancelled
Some checks failed
Build & Release / build (push) Has been cancelled
This commit is contained in:
87
src/services/sizeCollector.ts
Normal file
87
src/services/sizeCollector.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import postgres from "postgres";
|
||||
import { db } from "../db/manager";
|
||||
import { config } from "../lib/config";
|
||||
import { getSecret } from "../lib/k8s";
|
||||
import { getSpacesUsage } from "./spaces";
|
||||
|
||||
async function collectSizes() {
|
||||
const customers = await db`SELECT slug, spaces_key FROM customers WHERE status = 'provisioned'`;
|
||||
if (customers.length === 0) return;
|
||||
|
||||
for (const customer of customers) {
|
||||
const { slug } = customer;
|
||||
try {
|
||||
// DB size
|
||||
let dbSizeBytes: number | null = null;
|
||||
try {
|
||||
const sql = postgres(config.doadminDbUrl.replace(/\/([^/?]+)(\?|$)/, `/${slug}$2`), { max: 1 });
|
||||
try {
|
||||
const [row] = await sql<[{ bytes: string }]>`SELECT pg_database_size(${slug}::text)::text AS bytes`;
|
||||
dbSizeBytes = Number(row.bytes);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Spaces size
|
||||
let spacesSizeBytes: number | null = null;
|
||||
let spacesObjectCount: number | null = null;
|
||||
if (customer.spaces_key) {
|
||||
try {
|
||||
const namespace = `customer-${slug}`;
|
||||
const secrets = await getSecret(namespace, "lunarfront-secrets");
|
||||
const result = await getSpacesUsage(
|
||||
customer.spaces_key,
|
||||
secrets["spaces-secret"],
|
||||
config.spacesBucket,
|
||||
config.spacesRegion,
|
||||
`${slug}/`,
|
||||
);
|
||||
spacesSizeBytes = result.sizeBytes;
|
||||
spacesObjectCount = result.objectCount;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Upsert today's snapshot (one row per day per customer)
|
||||
await db`
|
||||
INSERT INTO customer_size_snapshots (slug, recorded_at, db_size_bytes, spaces_size_bytes, spaces_object_count)
|
||||
VALUES (${slug}, CURRENT_DATE, ${dbSizeBytes}, ${spacesSizeBytes}, ${spacesObjectCount})
|
||||
ON CONFLICT (slug, recorded_at) DO UPDATE SET
|
||||
db_size_bytes = EXCLUDED.db_size_bytes,
|
||||
spaces_size_bytes = EXCLUDED.spaces_size_bytes,
|
||||
spaces_object_count = EXCLUDED.spaces_object_count
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error(`[sizeCollector] failed for ${slug}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function msUntilNext12h(): number {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
const h = now.getUTCHours();
|
||||
// next run at 00:00 or 12:00 UTC, whichever is sooner
|
||||
if (h < 12) {
|
||||
next.setUTCHours(12, 0, 0, 0);
|
||||
} else {
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
next.setUTCHours(0, 0, 0, 0);
|
||||
}
|
||||
return next.getTime() - now.getTime();
|
||||
}
|
||||
|
||||
export function startSizeCollector(log: { info: (msg: string) => void }) {
|
||||
// Run immediately on startup
|
||||
collectSizes().then(() => log.info("Initial size snapshot collected")).catch(() => {});
|
||||
|
||||
// Then schedule for 00:00 and 12:00 UTC
|
||||
function scheduleNext() {
|
||||
const delay = msUntilNext12h();
|
||||
setTimeout(() => {
|
||||
collectSizes().then(() => log.info("Size snapshot collected")).catch(() => {});
|
||||
scheduleNext();
|
||||
}, delay);
|
||||
}
|
||||
scheduleNext();
|
||||
}
|
||||
219
src/services/spaces.ts
Normal file
219
src/services/spaces.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import crypto from "crypto";
|
||||
import { config } from "../lib/config";
|
||||
|
||||
const DO_API = "https://api.digitalocean.com/v2";
|
||||
|
||||
// ── DO Spaces key management ─────────────────────────────────────────────────
|
||||
|
||||
export async function createSpacesKey(
|
||||
name: string,
|
||||
bucket: string,
|
||||
): Promise<{ accessKey: string; secretKey: string }> {
|
||||
const res = await fetch(`${DO_API}/spaces/keys`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.doToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
grants: [{ bucket, permission: "readwrite" }],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`DO API POST /v2/spaces/keys → ${res.status}: ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as { key: { access_key: string; secret_key: string } };
|
||||
return { accessKey: data.key.access_key, secretKey: data.key.secret_key };
|
||||
}
|
||||
|
||||
export async function deleteSpacesKey(accessKey: string): Promise<void> {
|
||||
const res = await fetch(`${DO_API}/spaces/keys/${accessKey}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.doToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const body = await res.text();
|
||||
throw new Error(`DO API DELETE /v2/spaces/keys/${accessKey} → ${res.status}: ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── AWS4 signing helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function sha256hex(data: string | Buffer): string {
|
||||
return crypto.createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
function hmac(key: string | Buffer, data: string): Buffer {
|
||||
return crypto.createHmac("sha256", key).update(data).digest();
|
||||
}
|
||||
|
||||
function awsSign(
|
||||
method: string,
|
||||
url: URL,
|
||||
headers: Record<string, string>,
|
||||
body: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
region: string,
|
||||
): Record<string, string> {
|
||||
const service = "s3";
|
||||
const now = new Date();
|
||||
const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const amzDate = now.toISOString().replace(/[:-]/g, "").replace(/\.\d{3}/, "");
|
||||
|
||||
const payloadHash = sha256hex(body);
|
||||
const allHeaders: Record<string, string> = {
|
||||
...headers,
|
||||
host: url.host,
|
||||
"x-amz-date": amzDate,
|
||||
"x-amz-content-sha256": payloadHash,
|
||||
};
|
||||
|
||||
// Canonical headers (sorted lowercase)
|
||||
const sortedKeys = Object.keys(allHeaders).sort();
|
||||
const canonicalHeaders = sortedKeys.map(k => `${k.toLowerCase()}:${allHeaders[k].trim()}`).join("\n") + "\n";
|
||||
const signedHeaders = sortedKeys.map(k => k.toLowerCase()).join(";");
|
||||
|
||||
const canonicalUri = url.pathname || "/";
|
||||
const canonicalQueryString = [...url.searchParams.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||
.join("&");
|
||||
|
||||
const canonicalRequest = [method, canonicalUri, canonicalQueryString, canonicalHeaders, signedHeaders, payloadHash].join("\n");
|
||||
|
||||
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
||||
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, credentialScope, sha256hex(canonicalRequest)].join("\n");
|
||||
|
||||
const signingKey = hmac(
|
||||
hmac(hmac(hmac(`AWS4${secretKey}`, dateStamp), region), service),
|
||||
"aws4_request",
|
||||
);
|
||||
const signature = crypto.createHmac("sha256", signingKey).update(stringToSign).digest("hex");
|
||||
|
||||
const authHeader = `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
|
||||
return {
|
||||
...allHeaders,
|
||||
Authorization: authHeader,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Spaces usage ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSpacesUsage(
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
bucket: string,
|
||||
region: string,
|
||||
prefix: string,
|
||||
): Promise<{ objectCount: number; sizeBytes: number; sizePretty: string }> {
|
||||
const endpoint = `https://${bucket}.${region}.digitaloceanspaces.com/`;
|
||||
const url = new URL(endpoint);
|
||||
url.searchParams.set("list-type", "2");
|
||||
url.searchParams.set("prefix", prefix);
|
||||
url.searchParams.set("max-keys", "1000");
|
||||
|
||||
const signedHeaders = awsSign("GET", url, {}, "", accessKey, secretKey, region);
|
||||
|
||||
const res = await fetch(url.toString(), { headers: signedHeaders });
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Spaces ListObjectsV2 → ${res.status}: ${body}`);
|
||||
}
|
||||
|
||||
const xml = await res.text();
|
||||
|
||||
const sizeMatches = xml.match(/<Size>(\d+)<\/Size>/g) ?? [];
|
||||
const sizeBytes = sizeMatches.reduce((sum, m) => {
|
||||
const n = m.match(/<Size>(\d+)<\/Size>/);
|
||||
return sum + (n ? Number(n[1]) : 0);
|
||||
}, 0);
|
||||
|
||||
const keyMatches = xml.match(/<Key>/g) ?? [];
|
||||
const objectCount = keyMatches.length;
|
||||
|
||||
return { objectCount, sizeBytes, sizePretty: formatBytes(sizeBytes) };
|
||||
}
|
||||
|
||||
// ── Spaces object deletion ────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteSpacesObjects(
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
bucket: string,
|
||||
region: string,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
// List all objects under prefix
|
||||
const endpoint = `https://${bucket}.${region}.digitaloceanspaces.com/`;
|
||||
const listUrl = new URL(endpoint);
|
||||
listUrl.searchParams.set("list-type", "2");
|
||||
listUrl.searchParams.set("prefix", prefix);
|
||||
listUrl.searchParams.set("max-keys", "1000");
|
||||
|
||||
const listHeaders = awsSign("GET", listUrl, {}, "", accessKey, secretKey, region);
|
||||
const listRes = await fetch(listUrl.toString(), { headers: listHeaders });
|
||||
if (!listRes.ok) {
|
||||
const body = await listRes.text();
|
||||
throw new Error(`Spaces ListObjectsV2 → ${listRes.status}: ${body}`);
|
||||
}
|
||||
|
||||
const xml = await listRes.text();
|
||||
const keyMatches = [...xml.matchAll(/<Key>([^<]+)<\/Key>/g)];
|
||||
if (keyMatches.length === 0) return;
|
||||
|
||||
const keys = keyMatches.map(m => m[1]);
|
||||
|
||||
// Delete in batches of 1000
|
||||
const batchSize = 1000;
|
||||
for (let i = 0; i < keys.length; i += batchSize) {
|
||||
const batch = keys.slice(i, i + batchSize);
|
||||
const deleteXml =
|
||||
`<?xml version="1.0" encoding="UTF-8"?><Delete>` +
|
||||
batch.map(k => `<Object><Key>${escapeXml(k)}</Key></Object>`).join("") +
|
||||
`</Delete>`;
|
||||
|
||||
const deleteUrl = new URL(endpoint);
|
||||
deleteUrl.searchParams.set("delete", "");
|
||||
|
||||
const contentMd5 = crypto.createHash("md5").update(deleteXml).digest("base64");
|
||||
const deleteHeaders = awsSign(
|
||||
"POST",
|
||||
deleteUrl,
|
||||
{ "content-md5": contentMd5, "content-type": "application/xml" },
|
||||
deleteXml,
|
||||
accessKey,
|
||||
secretKey,
|
||||
region,
|
||||
);
|
||||
|
||||
const deleteRes = await fetch(deleteUrl.toString(), {
|
||||
method: "POST",
|
||||
headers: deleteHeaders,
|
||||
body: deleteXml,
|
||||
});
|
||||
if (!deleteRes.ok) {
|
||||
const body = await deleteRes.text();
|
||||
throw new Error(`Spaces DeleteObjects → ${deleteRes.status}: ${body}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
Reference in New Issue
Block a user