diff --git a/src/core/dokku.test.ts b/src/core/dokku.test.ts index 28ac2c1..bfb7dba 100644 --- a/src/core/dokku.test.ts +++ b/src/core/dokku.test.ts @@ -33,6 +33,32 @@ describe('createRunner with host', () => { expect(controlPersistIdx).toBeGreaterThan(-1) }) + it('passes plain args to SSH unquoted', async () => { + const runner = createRunner({ host: 'myserver.com' }) + mockExecaFn.mockClear() + await runner.query('git:report', 'myapp', '--git-deploy-branch') + const callArgs = mockExecaFn.mock.calls[0][1] as string[] + // dokku 0.36's SSH wrapper rejects literal-quoted args for some commands + // (notably git:report). Plain identifiers and flags must be passed as-is. + expect(callArgs).toContain('git:report') + expect(callArgs).toContain('myapp') + expect(callArgs).toContain('--git-deploy-branch') + expect(callArgs).not.toContain("'git:report'") + }) + + it('shell-quotes only args containing whitespace or special chars', async () => { + const runner = createRunner({ host: 'myserver.com' }) + mockExecaFn.mockClear() + await runner.run('cron:set', 'myapp', '--schedule', '0 5 * * *') + const callArgs = mockExecaFn.mock.calls[0][1] as string[] + // Plain args stay unquoted; the schedule (with spaces) gets single-quoted + // so the remote shell parses it as one arg. + expect(callArgs).toContain('cron:set') + expect(callArgs).toContain('myapp') + expect(callArgs).toContain('--schedule') + expect(callArgs).toContain("'0 5 * * *'") + }) + it('runner has a close() method', () => { const runner = createRunner({ host: 'myserver.com' }) expect(typeof runner.close).toBe('function') diff --git a/src/core/dokku.ts b/src/core/dokku.ts index 31762da..62685cf 100644 --- a/src/core/dokku.ts +++ b/src/core/dokku.ts @@ -36,6 +36,11 @@ export function createRunner(opts: RunnerOptions = {}): Runner { : [] function shellQuote(arg: string): string { + // Only quote args containing shell-significant characters. Wrapping every arg in + // single quotes confuses the dokku SSH command wrapper (notably git:report on + // dokku 0.36), which can pass SSH_ORIGINAL_COMMAND through to dokku without + // stripping the literal quotes for some subcommands. + if (/^[A-Za-z0-9_\-:.,/=@]+$/.test(arg)) return arg return `'${arg.replace(/'/g, "'\\''")}'` } diff --git a/src/resources/lists.test.ts b/src/resources/lists.test.ts index 9304e0a..3cda1f6 100644 --- a/src/resources/lists.test.ts +++ b/src/resources/lists.test.ts @@ -57,17 +57,23 @@ describe('Storage resource', () => { } it('mounts new and unmounts removed storage', async () => { - const ctx = makeCtx('/old:/app/old') + const ctx = makeCtx('-v /old:/app/old') await reconcile(Storage, ctx, 'myapp', ['/new:/app/new']) expect(ctx.commands).toContainEqual(['storage:unmount', 'myapp', '/old:/app/old']) expect(ctx.commands).toContainEqual(['storage:mount', 'myapp', '/new:/app/new']) }) it('skips when storage matches', async () => { - const ctx = makeCtx('/data:/app/data') + const ctx = makeCtx('-v /data:/app/data') await reconcile(Storage, ctx, 'myapp', ['/data:/app/data']) expect(ctx.commands).toEqual([]) }) + + it('parses multiple `-v` mounts on one line', async () => { + const ctx = makeCtx('-v /a:/app/a -v /b:/app/b') + await reconcile(Storage, ctx, 'myapp', ['/a:/app/a', '/b:/app/b']) + expect(ctx.commands).toEqual([]) + }) }) describe('readAll (bulk)', () => { @@ -105,10 +111,12 @@ describe('readAll (bulk)', () => { it('Storage.readAll returns per-app mount arrays', async () => { const ctx = makeCtx( '=====> app1 storage information\n' + - ' Storage mounts: /data:/app/data\n' + ' Storage build mounts:\n' + + ' Storage deploy mounts: -v /data:/app/data -v /logs:/app/logs\n' + + ' Storage run mounts: -v /data:/app/data -v /logs:/app/logs\n' ) const result = await Storage.readAll!(ctx) - expect(result.get('app1')).toEqual(['/data:/app/data']) + expect(result.get('app1')).toEqual(['/data:/app/data', '/logs:/app/logs']) }) it('handles empty field values as empty arrays', async () => { diff --git a/src/resources/lists.ts b/src/resources/lists.ts index e18224d..de8b760 100644 --- a/src/resources/lists.ts +++ b/src/resources/lists.ts @@ -54,18 +54,23 @@ export const Domains: Resource = { }, } +function parseDeployMounts(raw: string): string[] { + // Dokku reports deploy mounts as space-separated `-v PATH:PATH` pairs on one line. + return raw.split(/\s+/).filter(s => s && s !== '-v') +} + export const Storage: Resource = { key: 'storage', read: async (ctx, target) => { - const raw = await ctx.query('storage:report', target, '--storage-mounts') - return splitLines(raw) + const raw = await ctx.query('storage:report', target, '--storage-deploy-mounts') + return parseDeployMounts(raw) }, readAll: async (ctx: Context) => { const raw = await ctx.query('storage:report') const bulk = parseBulkReport(raw, 'storage') const result = new Map() for (const [app, report] of bulk) { - result.set(app, report['mounts'] ? splitLines(report['mounts']) : []) + result.set(app, report['deploy-mounts'] ? parseDeployMounts(report['deploy-mounts']) : []) } return result },