All checks were successful
CD / Build and push images (push) Successful in 2m41s
CI / Lint, typecheck, test (push) Successful in 3m20s
CI / Auth e2e pack (push) Successful in 4m9s
CI / Import/export fidelity gate (push) Successful in 54s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Has been skipped
The Test stage crash-looped: PluginWatcherService.onModuleInit did `mkdir`
on the default `./data/plugins` (→ /app/data, not writable by the non-root
user) and an unhandled EACCES aborted bootstrap. Two fixes:
- Harden the watcher: its dropzone is an optional convenience over the GUI
upload, so a setup failure now logs a warning and disables drop-to-install
instead of taking down the api.
- Bake writable defaults (UPLOADS_DIR/PLUGINS_DIR=/data/…) into the api image
so it works out of the box even where compose does not set them; compose
still mounts named volumes there for persistence.
Migrations applied cleanly ("No pending migrations"); this was purely the
boot-time directory permission.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
117 lines
4.5 KiB
TypeScript
117 lines
4.5 KiB
TypeScript
import { watch, type FSWatcher } from 'node:fs';
|
|
import { readFile, rename, rm } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
|
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
|
import { PinoLogger } from 'nestjs-pino';
|
|
|
|
import { ClockService } from '../common/clock.service';
|
|
import { AppConfig } from '../config/app-config.service';
|
|
|
|
import { PluginStorageService } from './plugin-storage.service';
|
|
import { PluginPackageError } from './plugin.constants';
|
|
import { PluginsService } from './plugins.service';
|
|
|
|
/** Debounce window: an editor/copy may fire several `rename` events per file. */
|
|
const DEBOUNCE_MS = 300;
|
|
|
|
/**
|
|
* Registers plugin ZIPs a Site Admin drops into `<PLUGINS_DIR>/_dropzone/`
|
|
* (ADR 0008 lifecycle). Each dropped file runs the exact same validation as the
|
|
* GUI upload; a valid package installs and the ZIP is consumed, an invalid one
|
|
* moves to `_quarantine/` with its error logged. The watcher is inert under
|
|
* `NODE_ENV=test` — tests call {@link processDropped} directly for determinism.
|
|
*/
|
|
@Injectable()
|
|
export class PluginWatcherService implements OnModuleInit, OnModuleDestroy {
|
|
private watcher: FSWatcher | undefined;
|
|
private readonly pendingTimers = new Map<string, NodeJS.Timeout>();
|
|
|
|
constructor(
|
|
private readonly plugins: PluginsService,
|
|
private readonly storage: PluginStorageService,
|
|
private readonly config: AppConfig,
|
|
private readonly clock: ClockService,
|
|
private readonly logger: PinoLogger,
|
|
) {
|
|
this.logger.setContext(PluginWatcherService.name);
|
|
}
|
|
|
|
async onModuleInit(): Promise<void> {
|
|
if (this.config.env.NODE_ENV === 'test') return;
|
|
// The dropzone is an optional convenience over the GUI upload. If its
|
|
// directory cannot be created or watched (e.g. PLUGINS_DIR is not writable
|
|
// on this deployment), log and carry on — it must never take down the api.
|
|
try {
|
|
await this.storage.ensureServiceDirs();
|
|
this.watcher = watch(this.storage.dropzoneDir, (_event, filename) => {
|
|
if (!filename || !filename.endsWith('.zip')) return;
|
|
this.schedule(filename.toString());
|
|
});
|
|
this.logger.info({ dir: this.storage.dropzoneDir }, 'watching plugin dropzone');
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
{ err: error, dir: this.storage.dropzoneDir },
|
|
'plugin dropzone unavailable; drop-to-install disabled (GUI upload still works)',
|
|
);
|
|
}
|
|
}
|
|
|
|
onModuleDestroy(): void {
|
|
this.watcher?.close();
|
|
for (const timer of this.pendingTimers.values()) clearTimeout(timer);
|
|
this.pendingTimers.clear();
|
|
}
|
|
|
|
private schedule(filename: string): void {
|
|
const existing = this.pendingTimers.get(filename);
|
|
if (existing) clearTimeout(existing);
|
|
this.pendingTimers.set(
|
|
filename,
|
|
setTimeout(() => {
|
|
this.pendingTimers.delete(filename);
|
|
void this.processDropped(join(this.storage.dropzoneDir, filename));
|
|
}, DEBOUNCE_MS),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Installs one dropped ZIP. On success the file is removed from the dropzone;
|
|
* on any validation failure it is quarantined and the error logged. Returns
|
|
* the outcome so tests can assert without relying on filesystem events.
|
|
*/
|
|
async processDropped(
|
|
filePath: string,
|
|
): Promise<{ installed: true; id: string } | { installed: false; code: string }> {
|
|
let zip: Buffer;
|
|
try {
|
|
zip = await readFile(filePath);
|
|
} catch {
|
|
// The file vanished between the event and the read — nothing to do.
|
|
return { installed: false, code: 'plugin_invalid_zip' };
|
|
}
|
|
|
|
try {
|
|
const view = await this.plugins.install(zip);
|
|
// The package is now unpacked under PLUGINS_DIR; consume the dropped ZIP.
|
|
await rm(filePath, { force: true });
|
|
this.logger.info({ id: view.id, version: view.version }, 'installed plugin from dropzone');
|
|
return { installed: true, id: view.id };
|
|
} catch (error) {
|
|
const code = error instanceof PluginPackageError ? error.code : 'plugin_bad_structure';
|
|
await this.quarantine(filePath);
|
|
this.logger.warn({ code, file: filePath }, 'quarantined invalid plugin drop');
|
|
return { installed: false, code };
|
|
}
|
|
}
|
|
|
|
private async quarantine(filePath: string): Promise<void> {
|
|
const name = filePath.split('/').pop() ?? 'package.zip';
|
|
const stamp = this.clock.now().toISOString().replace(/[:.]/g, '-');
|
|
await this.storage.ensureServiceDirs();
|
|
await rename(filePath, join(this.storage.quarantineDir, `${stamp}-${name}`)).catch(
|
|
() => undefined,
|
|
);
|
|
}
|
|
}
|