Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/tidy-snails-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rocket.chat/apps": patch
---

Fix Deno app startup for non-root containers by writing the generated runtime configuration to the writable temporary directory.
35 changes: 26 additions & 9 deletions packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function getDenoConfigPath(): string {
}

/**
* Generates a runtime deno.jsonc at `<tempDir>/deno_runtime.jsonc` by reading
* Generates a runtime deno.jsonc in the controller's temporary directory by reading
* the static config and injecting the resolved absolute path for
* `@rocket.chat/apps-engine/`. This makes deno-runtime location-independent:
* the path is always correct regardless of where this package is installed.
Expand All @@ -37,7 +37,16 @@ function generateEphemeralDenoConfig(targetPath: string, denoConfigPath: string,
const runtimeConfig = {
...staticConfig,
imports: {
...staticConfig.imports,
...Object.fromEntries(
Object.entries(staticConfig.imports ?? {}).map(([specifier, target]) => {
if (!target.startsWith('.')) {
return [specifier, target];
}

const resolvedTarget = path.resolve(path.dirname(denoConfigPath), target).split(path.sep).join(path.posix.sep);
return [specifier, `${resolvedTarget}${target.endsWith('/') ? '/' : ''}`];
}),
),
'@rocket.chat/apps-engine/': `${appsEnginePath}/`,
},
};
Expand All @@ -50,6 +59,8 @@ export class DenoRuntimeSubprocessController extends BaseRuntimeSubprocessContro

private readonly denoRuntimePath: string;

private readonly runtimeTempPath: string;

private readonly denoConfigPath: string;

private readonly denoEphemeralConfigPath: string;
Expand All @@ -66,23 +77,29 @@ export class DenoRuntimeSubprocessController extends BaseRuntimeSubprocessContro
this.packagePath = path.join(path.dirname(this.denoConfigPath), '..');
this.denoDir = process.env.DENO_DIR ?? path.join(this.packagePath, '.deno-cache');

this.denoRuntimePath = path.join(this.tempFilePath, 'deno-runtime', 'main.ts');
this.denoEphemeralConfigPath = this.denoConfigPath.replace('.jsonc', '.runtime.jsonc');
this.runtimeTempPath = fs.mkdtempSync(path.join(this.tempFilePath, 'deno-runtime-'));
this.denoRuntimePath = path.join(this.runtimeTempPath, 'runtime', 'main.ts');
this.denoEphemeralConfigPath = path.join(this.runtimeTempPath, 'deno.runtime.jsonc');

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Deno 2.x refuses to run scripts inside the node_modules, so we create a symlink to the deno runtime files in the temp directory
* The temp directory is the same we are given by the host to store temporary upload files
*/
try {
fs.symlinkSync(path.dirname(this.denoConfigPath), path.dirname(this.denoRuntimePath), 'dir');
generateEphemeralDenoConfig(this.denoEphemeralConfigPath, this.denoConfigPath, this.appsEnginePath);
} catch (reason: unknown) {
if ((reason as NodeJS.ErrnoException).code !== 'EEXIST') {
throw reason;
}
fs.rmSync(this.runtimeTempPath, { recursive: true, force: true });
throw reason;
}
}

// Generate a runtime config with the resolved absolute path for @rocket.chat/apps-engine/
generateEphemeralDenoConfig(this.denoEphemeralConfigPath, this.denoConfigPath, this.appsEnginePath);
public override async stopApp(): Promise<void> {
try {
await super.stopApp();
} finally {
fs.rmSync(this.runtimeTempPath, { recursive: true, force: true });
}
}

protected buildProcessConfiguration(): ProcessConfiguration {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ describe('DenoRuntimeSubprocessController', () => {
const appPackageBuffer = await fs.readFile(path.join(__dirname, '../../test-data/apps/hello-world-test_0.0.1.zip'));
appPackage = await manager.getParser().unpackageApp(appPackageBuffer);

await fs.unlink(path.join(manager.getTempFilePath(), 'deno-runtime')).catch(function noop() {});

appStorageItem = {
id: 'hello-world-test',
status: AppStatus.MANUALLY_ENABLED,
Expand All @@ -58,13 +56,39 @@ describe('DenoRuntimeSubprocessController', () => {
after(
async () => {
await controller?.stopApp();
await fs.unlink(path.join(manager.getTempFilePath(), 'deno-runtime')).catch((reason) => {
console.warn('Failed to delete temporary Deno runtime symlink', reason);
});
},
{ timeout: 30_000 },
);

it('generates the Deno config in the writable host temp directory', async () => {
// eslint-disable-next-line prefer-destructuring -- accessing a private field for testing
const runtimeTempPath = controller['runtimeTempPath'];
const configPath = path.join(runtimeTempPath, 'deno.runtime.jsonc');
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
const { args } = controller['buildProcessConfiguration']();
const packagePath = controller['packagePath'].split(path.sep).join(path.posix.sep);

assert.strictEqual(controller['denoEphemeralConfigPath'], configPath);
assert.strictEqual(path.dirname(runtimeTempPath), manager.getTempFilePath());
assert.strictEqual(config.imports['@rocket.chat/apps-engine/'], `${controller['appsEnginePath']}/`);
assert.strictEqual(config.imports['@rocket.chat/apps/base-runtime/'], `${packagePath}/base-runtime/src/`);
assert.strictEqual(config.imports['@rocket.chat/apps/'], `${packagePath}/`);
assert.strictEqual(config.imports['@std/io'], 'jsr:@std/io@^0.225.3');
assert.ok(args.includes(`--config=${configPath}`));
});

it('uses an isolated runtime directory for each controller and removes it on stop', async () => {
const secondController = new DenoRuntimeSubprocessController(manager, appPackage, appStorageItem);
Comment thread
AlgoArtist06 marked this conversation as resolved.
Outdated
// eslint-disable-next-line prefer-destructuring -- accessing a private field for testing
const runtimeTempPath = secondController['runtimeTempPath'];

assert.notStrictEqual(runtimeTempPath, controller['runtimeTempPath']);
assert.strictEqual((await fs.stat(runtimeTempPath)).isDirectory(), true);

await secondController.stopApp();
await assert.rejects(fs.stat(runtimeTempPath), { code: 'ENOENT' });
});

it('correctly identifies a call to the HTTP accessor', { timeout: 15_000 }, async () => {
const httpBridge = manager.getBridges().getHttpBridge();
const doCallSpy = mock.method(httpBridge, 'doCall');
Expand Down