import { first } from "../db"; import { forbidden, notFoundError } from "../errors"; import { canAdministerAccount, requireRepoAccess } from "../repos"; import type { Viewer } from "~/lib/types"; import { containerLimits, containersEnabled, containerStub } from "./container-config"; import { closeOrphanSession, containersAllowedFor, containerUsage, listContainerSessions, loadContainerSession, } from "./container-meter"; import type { ContainerSessionRow, ContainerUsage } from "./container-meter"; export type ContainerOverview = { enabled: boolean; /** Whether the scope's plan (the account's, or the viewer's for a repo) includes containers. */ allowed: boolean; idleMinutes: number; capHours: number; sessions: ContainerSessionRow[]; /** Present for an account scope; a repo spans many billed accounts. */ usage: ContainerUsage | null; }; async function accountByName(name: string): Promise<{ id: string; name: string }> { const account = await first<{ id: string; name: string }>( "SELECT id, name FROM accounts WHERE name = ? COLLATE NOCASE", name, ); if (!account) throw notFoundError("Account not found"); return account; } /** The viewer themselves, or an org they own (site admins may see any account). */ export async function requireContainerAccount( viewer: Viewer, org: string | null, ): Promise<{ id: string; name: string }> { if (!org) return { id: viewer.id, name: viewer.name }; const account = await accountByName(org); if (!(await canAdministerAccount(account.id, viewer))) throw forbidden("Only organization owners can see containers"); return account; } /** Running rows get their idle time from the Durable Object; a stub that can't answer leaves it null. */ async function withActivity(sessions: ContainerSessionRow[]): Promise { return Promise.all( sessions.map(async (session) => { if (session.stoppedAt !== null) return session; const status = await containerStub(session.accountId, session.repoId, session.branch) .status() .catch(() => null); return { ...session, lastActivity: status?.lastActivity ?? null }; }), ); } export async function accountContainers(accountId: string): Promise { const limits = containerLimits(); const [sessions, usage] = await Promise.all([listContainerSessions({ accountId }), containerUsage(accountId)]); return { enabled: containersEnabled(), allowed: usage.allowed, ...limits, sessions: await withActivity(sessions), usage, }; } export async function repoContainers(owner: string, repo: string, viewer: Viewer | null): Promise { const access = await requireRepoAccess(owner, repo, viewer, "admin"); const [sessions, allowed] = await Promise.all([ withActivity(await listContainerSessions({ repoId: access.repo.id })), viewer ? containersAllowedFor(viewer.id) : Promise.resolve(false), ]); return { enabled: containersEnabled(), allowed, ...containerLimits(), sessions, usage: null }; } /** * Stops one session from a listing. The Durable Object settles the usage; when it has already forgotten * the session (storage reset, crash before the row was closed) the row is settled directly. */ async function stopSession(session: ContainerSessionRow, reason: string): Promise { if (session.stoppedAt !== null) return 0; const stub = containerStub(session.accountId, session.repoId, session.branch); // An RPC failure is not proof the container is gone; settling then would stop billing a live container. // Only the session the object is running (or still settling) is stopped there; any other open row is stale. if ((await stub.sessionId()) === session.id) return stub.stop(reason, session.id); return closeOrphanSession(session.id, reason); } /** Stops every running container billed to the account, e.g. when it is disabled. */ export async function stopAllAccountContainers(accountId: string, reason: string): Promise { for (const session of await listContainerSessions({ accountId })) { if (session.stoppedAt === null) await stopSession(session, reason); } } export async function stopAccountContainer(accountId: string, sessionId: string): Promise { const session = await loadContainerSession(sessionId); if (!session || session.accountId !== accountId) throw notFoundError("Container session not found"); return stopSession(session, "stopped"); } export async function stopRepoContainer( owner: string, repo: string, viewer: Viewer | null, sessionId: string, ): Promise { const access = await requireRepoAccess(owner, repo, viewer, "admin"); const session = await loadContainerSession(sessionId); if (!session || session.repoId !== access.repo.id) throw notFoundError("Container session not found"); return stopSession(session, "stopped by admin"); }