diff --git a/lib/docker.ts b/lib/docker.ts index 1cafa8c6..4fc4921c 100644 --- a/lib/docker.ts +++ b/lib/docker.ts @@ -17,6 +17,12 @@ import { User } from './User.ts' const SERVER_IMAGE = 'ghcr.io/nextcloud/continuous-integration-shallow-server' +// The server image ships PHP but no Composer, so it is downloaded on demand +const COMPOSER_VERSION = process.env.NEXTCLOUD_E2E_COMPOSER_VERSION || 'latest-stable' +const COMPOSER_PHAR = '/tmp/composer.phar' +/** `COMPOSER_HOME` used inside the server container, must be writable by `www-data` */ +const COMPOSER_HOME = '/tmp/composer-home' + export const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET ?? '/var/run/docker.sock' }) // Store the container name, different names are used to prevent conflicts when testing multiple apps locally @@ -230,6 +236,9 @@ function pullImage() { /** * Configure Nextcloud * + * Shipped apps that are missing from the server image are cloned and their Composer dependencies + * are installed. Set `NEXTCLOUD_E2E_COMPOSER_VERSION` to pin the Composer version used for that. + * * @param apps List of default apps to install (default is ['viewer']) * @param vendoredBranch The branch used for vendored apps, should match server (defaults to latest branch used for `startNextcloud` or fallsback to `master`) * @param container Optional server container to use (defaults to current container) @@ -261,10 +270,6 @@ export async function configureNextcloud(apps = ['viewer'], vendoredBranch?: str } console.log('│ └─ OK !') - // Build app list - const { stdout: json } = await runOcc(['app:list', '--output', 'json'], { container }) - const applist = JSON.parse(json) - console.log('├─ Using "apps-writable" folder for mounted apps') await runExec(['mkdir', '-p', '/var/www/html/apps-writable'], { container }) await runExec(['chown', 'www-data:www-data', '/var/www/html/apps-writable'], { container, user: 'root' }) @@ -288,6 +293,10 @@ export async function configureNextcloud(apps = ['viewer'], vendoredBranch?: str stream.finalize() await container.putArchive(stream, { path: '/var/www/html/config' }) + // Build app list, only now that "apps-writable" is a known apps path so that mounted apps show up + const { stdout: json } = await runOcc(['app:list', '--output', 'json'], { container }) + const applist = JSON.parse(json) + // Enable apps and give status for (const app of apps) { if (app in applist.enabled) { @@ -304,6 +313,7 @@ export async function configureNextcloud(apps = ['viewer'], vendoredBranch?: str ['git', 'clone', '--depth=1', ...branchOption, `https://github.com/nextcloud/${encodeURIComponent(app)}.git`, `apps-writable/${app}`], { container, verbose: true }, ) + await installComposerDependencies(app, container) await runOcc(['app:enable', '--force', app], { container, verbose: true }) } else { // try appstore @@ -314,6 +324,58 @@ export async function configureNextcloud(apps = ['viewer'], vendoredBranch?: str console.log('└─ Nextcloud is now ready to use 🎉') } +/** + * Check whether a path exists inside the container + * + * @param path Absolute path to check + * @param container The server container to use + */ +async function pathExists(path: string, container: Container): Promise { + const { exitCode } = await runExec(['test', '-e', path], { container, failOnError: false }) + return exitCode === 0 +} + +/** + * Install the Composer dependencies of a cloned app + * + * A bare `git clone` is only usable as long as the app commits its dependencies. Since Nextcloud 34 + * `notifications` does not, and `OC_App::registerAutoloading()` then fatals on the missing + * `vendor/autoload.php`. Scripts are run on purpose, apps like that one only assemble the prefixed + * copies of their dependencies (`lib/Vendor`) in `post-install-cmd`. + * + * @param app The app id, cloned to `apps-writable/` + * @param container The server container to use + */ +async function installComposerDependencies(app: string, container: Container) { + const appPath = `/var/www/html/apps-writable/${app}` + if (!await pathExists(`${appPath}/composer.json`, container)) { + return + } + + await ensureComposer(container) + console.log(`│ ├─ Running 'composer install' for ${app}…`) + await runExec( + ['php', COMPOSER_PHAR, 'install', '--no-dev', '--no-interaction', '--no-progress', '--no-ansi'], + { container, workingDir: appPath, env: [`COMPOSER_HOME=${COMPOSER_HOME}`] }, + ) + console.log('│ └─ Done') +} + +/** + * Download the Composer binary into the container, unless it is already there + * + * @param container The server container to use + */ +async function ensureComposer(container: Container) { + if (await pathExists(COMPOSER_PHAR, container)) { + return + } + + console.log(`│ ├─ Downloading Composer ${COMPOSER_VERSION} into the container…`) + const url = `https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar` + await runExec(['curl', '--silent', '--show-error', '--location', '--fail', '--output', COMPOSER_PHAR, url], { container }) +} + /** * Setup test users * @@ -439,6 +501,10 @@ export interface RunExecOptions { * If true, the command's output will be printed to the console. Defaults to false. */ verbose: boolean + /** + * Working directory to run the command in. Defaults to the Nextcloud root. + */ + workingDir: string } export type RunExecResult = { @@ -457,10 +523,11 @@ export type RunExecResult = { * @param options.verbose - If true, the command's output will be printed to the console. Defaults to false. * @param options.env - Environment variables to set for the command. Defaults to an empty array. * @param options.failOnError - The command will throw an error if it exits with a non-zero exit code. Defaults to true. + * @param options.workingDir - Working directory to run the command in. Defaults to the Nextcloud root. */ export async function runExec( command: string | string[], - { container, user = 'www-data', verbose = false, env = [], failOnError = true }: Partial = {}, + { container, user = 'www-data', verbose = false, env = [], failOnError = true, workingDir }: Partial = {}, ): Promise { container = container || getContainer() const exec = await container.exec({ @@ -469,6 +536,7 @@ export async function runExec( AttachStderr: true, User: user, Env: env, + WorkingDir: workingDir, }) return new Promise((resolve, reject) => { diff --git a/tests/docker.spec.ts b/tests/docker.spec.ts index 7183d6c4..e4588c39 100644 --- a/tests/docker.spec.ts +++ b/tests/docker.spec.ts @@ -11,7 +11,7 @@ describe('Docker: Pre-installation of apps', async () => { before(async () => { const ip = await startNextcloud('master', false, { forceRecreate: true, exposePort: 8088 }) await waitOnNextcloud(ip) - await configureNextcloud(['viewer', 'text', 'forms']) + await configureNextcloud(['viewer', 'text', 'forms', 'notifications']) }) after(async () => { @@ -40,6 +40,14 @@ describe('Docker: Pre-installation of apps', async () => { const { enabled } = await getAppsList() expect.equal('forms' in enabled, true, 'Forms app should be enabled') }) + + await test('Additional apps: apps that do not commit their dependencies get them installed', async () => { + const container = getContainer() + // this must not throw + await runExec(['test', '-f', 'apps-writable/notifications/vendor/autoload.php'], { container }) + const { enabled } = await getAppsList() + expect.equal('notifications' in enabled, true, 'Notifications app should be enabled') + }) }) async function getAppsList(): Promise<{ enabled: Record, disabled: Record }> {