Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Dev**: Tests for `utils/db.ts`, which sat at 2.85% coverage — the persistence layer behind scale-to-zero, essentially untested. 23 tests, aimed at the boundaries that decide behaviour: `updateProjectAccess` must not clobber a status set elsewhere, or a start in flight would be reset by a request arriving on the waiting page; `setProjectStatus` must leave `last_access` alone, or stopping a project would look like activity and defer the next sweep; `getIdleProjects` treats a project used exactly at the cutoff as active and ignores anything not `running`; and `cleanOldLogs` keeps an entry exactly at the cutoff ([#51])

## [Unreleased]

### Added

- **Dev**: A coverage floor in CI. `test:ci` now fails when statements, branches, functions or lines drop below a threshold set just under the current numbers, so a regression fails while an improvement does not. Verified to actually fail rather than pass silently: raising the bar above the current figure exits 1 with `Coverage for statements (76.24%) does not meet global threshold`. This is the gate for the problem behind several fixes this cycle — `loadProjectList` and `routePath` each had no tests and each hid a bug for nine releases ([#52])
- **Dev**: Tests for the request handlers, taking agent coverage of `server.ts` from 5.8% to 66.7%. The handlers now receive a `ServerDeps` seam holding the project index, the per-project configs and the side effects, so a test drives them with a known world and records what they did — no socket, no database, no DDEV. The cases worth having: a project's own `auth_policy` overriding the global one while credentials stay server-wide, a second request during a start not queueing another `ddev start`, a failed start recording `stopped` rather than leaving the project wedged on `starting`, and `/__auth__?s=term` reaching the auth handler — the query-string routing bug from 0.1.33, now covered ([#52])
- **Dev**: Tests for the upgrade sequence, taking `setup/upgrade.ts` from 18.9% to 66.7%. `runUpgrade` takes an `UpgradeIo`, which is what makes the re-exec path testable without replacing the process. Covers the two guards that matter: no re-exec when npm served a stale cache and left the old version in place, and no second re-exec once one has happened — either would loop. Also that an unreachable registry does not stop migrations that are already due ([#52])

## [0.1.38] - 2026.09.07

### Security
Expand Down Expand Up @@ -498,6 +506,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#49]: https://github.com/studiometa/trafic/pull/49
[#50]: https://github.com/studiometa/trafic/pull/50
[#51]: https://github.com/studiometa/trafic/pull/51
[#52]: https://github.com/studiometa/trafic/pull/52
[#31]: https://github.com/studiometa/trafic/pull/31
[GHSA-mw96-cpmx-2vgc]: https://github.com/advisories/GHSA-mw96-cpmx-2vgc
[ddev/ddev#2696]: https://github.com/ddev/ddev/issues/2696
Expand Down
103 changes: 75 additions & 28 deletions packages/trafic-agent/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,28 @@ let hostnameIndex: Map<string, string>;
// Cache of per-project configs (project name -> config)
const projectConfigs = new Map<string, ReturnType<typeof loadProjectConfig>>();

/**
* What the request handlers need from the outside world.
*
* Gathered into one seam so a test can drive the handlers with a known
* project list and record what they did, without a listening socket, a real
* database or a DDEV install. `startServer` builds the real one.
*/
export interface ServerDeps {
auth: AuthConfig;
/** hostname -> project name */
hostnameIndex: Map<string, string>;
/** project name -> its own config, where it has one */
projectConfigs: Map<string, ReturnType<typeof loadProjectConfig>>;
loadTemplate: (name: string) => string;
updateProjectAccess: (name: string) => void;
logAccess: (log: Parameters<typeof logAccess>[0]) => void;
getProject: (name: string) => ReturnType<typeof getProject>;
setProjectStatus: (name: string, status: "running" | "stopped" | "starting") => void;
startProject: (name: string) => Promise<boolean>;
getProjectInfo: (name: string) => ReturnType<typeof getProjectInfo>;
}

/**
* Load HTML template
*/
Expand All @@ -45,15 +67,18 @@ function loadTemplate(name: string): string {
/**
* Get effective auth config for a project (merges global + per-project)
*/
function getEffectiveAuthConfig(projectName: string | undefined): AuthConfig {
if (!projectName) return config.auth;
export function getEffectiveAuthConfig(
projectName: string | undefined,
deps: Pick<ServerDeps, "auth" | "projectConfigs">,
): AuthConfig {
if (!projectName) return deps.auth;

const projectConfig = projectConfigs.get(projectName);
if (!projectConfig?.auth_policy) return config.auth;
const projectConfig = deps.projectConfigs.get(projectName);
if (!projectConfig?.auth_policy) return deps.auth;

// Override default policy with project-specific policy
return {
...config.auth,
...deps.auth,
defaultPolicy: projectConfig.auth_policy,
};
}
Expand All @@ -62,7 +87,11 @@ function getEffectiveAuthConfig(projectName: string | undefined): AuthConfig {
* Handle forward auth requests from Traefik
* Traefik sends the original request headers, we return 200 (allow) or 401 (deny)
*/
function handleAuth(req: IncomingMessage, res: ServerResponse): void {
export function handleAuth(
req: IncomingMessage,
res: ServerResponse,
deps: ServerDeps,
): void {
const hostname = req.headers["x-forwarded-host"] as string ?? "";
// Keep the two apart: the socket peer cannot be forged, X-Forwarded-For
// partly can. checkAuth decides which entry to trust.
Expand All @@ -72,10 +101,10 @@ function handleAuth(req: IncomingMessage, res: ServerResponse): void {
const path = req.headers["x-forwarded-uri"] as string ?? "/";

// Find project from hostname
const projectName = hostnameIndex.get(hostname);
const projectName = deps.hostnameIndex.get(hostname);

// Get effective auth config (global + per-project overrides)
const authConfig = getEffectiveAuthConfig(projectName);
const authConfig = getEffectiveAuthConfig(projectName, deps);

const clientIp = resolveClientIp(
socketIp,
Expand All @@ -96,8 +125,8 @@ function handleAuth(req: IncomingMessage, res: ServerResponse): void {
if (result.allowed) {
// Log access and update last access time
if (projectName) {
updateProjectAccess(projectName);
logAccess({
deps.updateProjectAccess(projectName);
deps.logAccess({
project: projectName,
timestamp: Date.now(),
ip: clientIp,
Expand All @@ -121,26 +150,27 @@ function handleAuth(req: IncomingMessage, res: ServerResponse): void {
* Handle errors middleware requests (502 from Traefik)
* When a project is stopped, Traefik returns 502. We show a waiting page and start the project.
*/
async function handleErrors(
export async function handleErrors(
req: IncomingMessage,
res: ServerResponse,
deps: ServerDeps,
): Promise<void> {
const hostname = req.headers["x-forwarded-host"] as string ?? req.headers.host ?? "";
const projectName = hostnameIndex.get(hostname);
const projectName = deps.hostnameIndex.get(hostname);

if (!projectName) {
// Unknown project
const template = loadTemplate("error");
const template = deps.loadTemplate("error");
res.writeHead(404, { "Content-Type": "text/html" });
res.end(template.replace("{{message}}", "Project not found"));
return;
}

// Check if project is already starting
const record = getProject(projectName);
const record = deps.getProject(projectName);
if (record?.status === "starting") {
// Show waiting page
const template = loadTemplate("wait");
const template = deps.loadTemplate("wait");
res.writeHead(503, {
"Content-Type": "text/html",
"Retry-After": "5",
Expand All @@ -154,10 +184,10 @@ async function handleErrors(
}

// Mark as starting
setProjectStatus(projectName, "starting");
deps.setProjectStatus(projectName, "starting");

// Show waiting page immediately
const template = loadTemplate("wait");
const template = deps.loadTemplate("wait");
res.writeHead(503, {
"Content-Type": "text/html",
"Retry-After": "5",
Expand All @@ -173,23 +203,24 @@ async function handleErrors(
// serving forward auth for every other project while this runs. The status
// is recorded when it settles, which is what stops a second request from
// starting the same project again.
void startProject(projectName)
void deps.startProject(projectName)
.then((success) => {
setProjectStatus(projectName, success ? "running" : "stopped");
deps.setProjectStatus(projectName, success ? "running" : "stopped");
})
.catch((error: unknown) => {
// Leaving it "starting" forever would wedge the waiting page
setProjectStatus(projectName, "stopped");
deps.setProjectStatus(projectName, "stopped");
console.error(`Could not start ${projectName}:`, error);
});
}

/**
* Handle status polling requests
*/
async function handleStatus(
export async function handleStatus(
req: IncomingMessage,
res: ServerResponse,
deps: ServerDeps,
): Promise<void> {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
const projectName = url.searchParams.get("project");
Expand All @@ -200,8 +231,8 @@ async function handleStatus(
return;
}

const info = await getProjectInfo(projectName);
const record = getProject(projectName);
const info = await deps.getProjectInfo(projectName);
const record = deps.getProject(projectName);

res.writeHead(200, { "Content-Type": "application/json" });
res.end(
Expand Down Expand Up @@ -236,9 +267,10 @@ export function routePath(target: string | undefined): string {
/**
* Request handler
*/
async function handleRequest(
export async function handleRequest(
req: IncomingMessage,
res: ServerResponse,
deps: ServerDeps,
): Promise<void> {
// Route on the path alone. Matching the raw URL meant a query string threw
// every internal route off: Traefik's catch-all and errors middleware pass
Expand All @@ -251,15 +283,15 @@ async function handleRequest(
try {
// Route requests
if (path === "/__auth__" || path.startsWith("/__auth__/")) {
handleAuth(req, res);
handleAuth(req, res, deps);
} else if (path === "/__status__" || path.startsWith("/__status__/")) {
await handleStatus(req, res);
await handleStatus(req, res, deps);
} else if (path === "/__health__") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", version: "__VERSION__" }));
} else {
// Default: errors middleware
await handleErrors(req, res);
await handleErrors(req, res, deps);
}
} catch (error) {
console.error("Request error:", error);
Expand Down Expand Up @@ -310,9 +342,24 @@ export function startServer(agentConfig: AgentConfig): void {
reloadProjects();
});

// The real dependencies. Rebuilt per request for the two maps, which
// reloadProjects replaces wholesale when the project list changes.
const deps = (): ServerDeps => ({
auth: config.auth,
hostnameIndex,
projectConfigs,
loadTemplate,
updateProjectAccess,
logAccess,
getProject,
setProjectStatus,
startProject,
getProjectInfo,
});

// Create HTTP server
const server = createServer((req, res) => {
handleRequest(req, res).catch((error) => {
handleRequest(req, res, deps()).catch((error) => {
console.error("Unhandled error:", error);
if (!res.headersSent) {
res.writeHead(500);
Expand Down
60 changes: 50 additions & 10 deletions packages/trafic-agent/src/setup/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,42 @@ export function restartAgentService(dryRun: boolean): void {
exec("systemctl restart trafic-agent", { silent: !dryRun });
}

/**
* What the upgrade sequence needs from the outside world.
*
* Injected so the orchestration can be tested without reaching npm, writing
* to the filesystem or replacing the running process — the last of which is
* why `reExecNewBinary` is here rather than called directly.
*/
export interface UpgradeIo {
/** The version of the process running the upgrade. */
currentVersion: string;
/** Whether an earlier run already re-execed, guarding against a loop. */
alreadyReExeced: boolean;
isRoot: () => boolean;
fetchLatestVersion: () => string | null;
installLatestAgent: (dryRun: boolean) => void;
getInstalledVersion: () => string | null;
reExecNewBinary: (args: string[]) => never;
restartAgentService: (dryRun: boolean) => void;
runPendingMigrations: (dryRun: boolean) => void;
}

/** The real collaborators, used unless a caller passes their own. */
export function nodeUpgradeIo(): UpgradeIo {
return {
currentVersion: __VERSION__,
alreadyReExeced: process.env["TRAFIC_UPGRADE_REEXEC"] === "1",
isRoot,
fetchLatestVersion,
installLatestAgent,
getInstalledVersion,
reExecNewBinary,
restartAgentService,
runPendingMigrations,
};
}

/**
* Full upgrade sequence:
* 1. Check for a new version on npm
Expand All @@ -102,23 +138,27 @@ export function restartAgentService(dryRun: boolean): void {
* 3. Run pending migrations
* 4. Restart the systemd service
*/
export function runUpgrade(dryRun = false, reExecArgs?: string[]): void {
if (!isRoot() && !dryRun) {
export function runUpgrade(
dryRun = false,
reExecArgs?: string[],
io: UpgradeIo = nodeUpgradeIo(),
): void {
if (!io.isRoot() && !dryRun) {
console.error(" \x1b[31m✗\x1b[0m This command must be run as root");
console.log(" Run: sudo trafic-agent upgrade");
process.exit(1);
}

// Guard against infinite re-exec loops: only re-exec once per upgrade run.
const alreadyReExeced = process.env["TRAFIC_UPGRADE_REEXEC"] === "1";
const alreadyReExeced = io.alreadyReExeced;

// ── Step 1: Check for updates ─────────────────────────────────────────────
step("Check for updates");

const current = __VERSION__;
const current = io.currentVersion;
info(`Current version: ${current}`);

const latest = fetchLatestVersion();
const latest = io.fetchLatestVersion();

if (!latest) {
warn("Could not reach npm registry — skipping version check");
Expand All @@ -127,19 +167,19 @@ export function runUpgrade(dryRun = false, reExecArgs?: string[]): void {

// ── Step 2: Install ───────────────────────────────────────────────────
step("Install latest version");
installLatestAgent(dryRun);
io.installLatestAgent(dryRun);

if (!dryRun) {
// Verify the installed version actually changed before re-execing —
// npm can serve stale cache and leave the old binary in place.
const installedVersion = getInstalledVersion();
const installedVersion = io.getInstalledVersion();

if (!alreadyReExeced && installedVersion && isNewer(current, installedVersion)) {
success(`Installed @studiometa/trafic-agent@${installedVersion}`);
// Re-exec the newly installed binary so steps 3 and 4 run with the
// new migration registry — the current process only knows about
// migrations that existed at the time it was compiled.
reExecNewBinary(reExecArgs ?? ["upgrade"]);
io.reExecNewBinary(reExecArgs ?? ["upgrade"]);
} else if (installedVersion) {
success(`Installed @studiometa/trafic-agent@${installedVersion}`);
}
Expand All @@ -150,11 +190,11 @@ export function runUpgrade(dryRun = false, reExecArgs?: string[]): void {

// ── Step 3: Run pending migrations ───────────────────────────────────────
step("Run pending migrations");
runPendingMigrations(dryRun);
io.runPendingMigrations(dryRun);

// ── Step 4: Restart service ───────────────────────────────────────────────
step("Restart trafic-agent service");
restartAgentService(dryRun);
io.restartAgentService(dryRun);
if (!dryRun) {
success("Service restarted");
}
Expand Down
Loading
Loading