From 880d2522b5777db30c48569a41dd09fb7e46a4b3 Mon Sep 17 00:00:00 2001 From: Alexander Zeitler Date: Sun, 11 Apr 2021 18:22:46 +0200 Subject: [PATCH] feat: make result for `ps` command type safe --- src/index.ts | 70 +++++++++++++++++++++++++++++++-- test/docker-compose.yml | 5 ++- test/index.test.ts | 86 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 153 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index bb859ef..1b20df1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,6 +62,61 @@ export type TypedDockerComposeResult = { const nonEmptyString = (v: string) => v !== '' +export type DockerComposePsResult = { + services: Array<{ + name: string + command: string + state: string + ports: Array<{ + mapped?: { address: string; port: number } + exposed: { port: number; protocol: string } + }> + }> +} + +export const mapPorts = ( + ports: string +): Array<{ + mapped?: { address: string; port: number } + exposed: { port: number; protocol: string } +}> => { + if (!ports) return [] + const allPorts = ports.split(',') + return allPorts.map((portString) => { + const exposedFragments = portString.trim().split('->') + const [port, protocol] = + exposedFragments.length === 1 + ? exposedFragments[0].split('/') + : exposedFragments[1].split('/') + const [address, mappedPort] = + exposedFragments.length === 2 ? exposedFragments[0].split(':') : [] + const result = { + exposed: { port: Number(port), protocol }, + ...(address && + mappedPort && { mapped: { port: Number(mappedPort), address } }) + } + return result + }) +} + +export const mapPsOutput = (output: string): DockerComposePsResult => { + const lines = output.split(`\n`).filter(nonEmptyString) + const lines2 = lines.filter((line, index) => index > 1) + const services = lines2.map((line) => { + const [nameString, commandString, stateString, allPortsString] = line.split( + /\s{3,}/ + ) + + return { + name: nameString.trim(), + command: commandString.trim(), + state: stateString.trim(), + ports: mapPorts(allPortsString.trim()) + } + }) + return { services } +} + /** * Converts supplied yml files to cli arguments * https://docs.docker.com/compose/reference/overview/#use--f-to-specify-name-and-path-of-one-or-more-compose-files @@ -356,10 +411,19 @@ export const configVolumes = async function ( } } -export const ps = function ( +export const ps = async function ( options?: IDockerComposeOptions -): Promise { - return execCompose('ps', [], options) +): Promise> { + try { + const result = await execCompose('ps', [], options) + const data = mapPsOutput(result.out) + return { + ...result, + data + } + } catch (error) { + return Promise.reject(error) + } } export const push = function ( diff --git a/test/docker-compose.yml b/test/docker-compose.yml index 8edd35c..059a017 100644 --- a/test/docker-compose.yml +++ b/test/docker-compose.yml @@ -5,10 +5,13 @@ services: image: nginx:1.16.0 container_name: compose_test_web command: 'nginx -g "daemon off;"' + ports: + - 80:80 + - 443:443 proxy: image: nginx:1.19.9-alpine container_name: compose_test_proxy command: 'nginx -g "daemon off;"' hello: image: hello-world - container_name: compose_test_hello \ No newline at end of file + container_name: compose_test_hello diff --git a/test/index.test.ts b/test/index.test.ts index 8c45276..4b0d7ac 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2,6 +2,7 @@ import Docker from 'dockerode' import * as compose from '../src/index' import * as path from 'path' import { readFile } from 'fs' +import { mapPorts, mapPsOutput } from '../src/index' const docker = new Docker() // Docker commands, especially builds, can take some time. This makes sure that they can take the time they need. @@ -551,8 +552,16 @@ test('ps shows status data for started containers', async (): Promise => { const std = await compose.ps({ cwd: path.join(__dirname), log: logOutput }) expect(std.err).toBeFalsy() - expect(std.out.includes('compose_test_web')).toBeTruthy() - expect(std.out.includes('compose_test_proxy')).toBeTruthy() + expect(std.data.services.length).toBe(3) + const web = std.data.services.find( + (service) => service.name === 'compose_test_web' + ) + expect(std.data.services.length).toBe(3) + expect(web?.ports.length).toBe(2) + expect(web?.ports[0].exposed.port).toBe(443) + expect(web?.ports[0].exposed.protocol).toBe('tcp') + expect(web?.ports[0].mapped?.port).toBe(443) + expect(web?.ports[0].mapped?.address).toBe('0.0.0.0') await compose.down({ cwd: path.join(__dirname), log: logOutput }) }) @@ -563,8 +572,14 @@ test('ps does not show status data for stopped containers', async (): Promise service.name === 'compose_test_web' + ) + const proxy = std.data.services.find( + (service) => service.name === 'compose_test_proxy' + ) + expect(web?.name).toBe('compose_test_web') + expect(proxy).toBeFalsy() await compose.down({ cwd: path.join(__dirname), log: logOutput }) }) @@ -649,3 +664,66 @@ test('returns version information', async (): Promise => { expect(version).toMatch(/^(\d+\.)?(\d+\.)?(\*|\d+)$/) }) + +test('parse ps output', () => { + const output = ` Name Command State Ports \n-------------------------------------------------------------------------------------------------------\ncompose_test_hello /hello Exit 0 \ncompose_test_proxy /docker-entrypoint.sh ngin ... Up 80/tcp \ncompose_test_web nginx -g daemon off; Up 0.0.0.0:443->443/tcp, 0.0.0.0:80->80/tcp\n` + + const psOut = mapPsOutput(output) + expect(psOut.services[0]).toEqual({ + command: '/hello', + name: 'compose_test_hello', + state: 'Exit 0', + ports: [] + }) + + expect(psOut.services[1]).toEqual({ + command: '/docker-entrypoint.sh ngin ...', + name: 'compose_test_proxy', + state: 'Up', + ports: [{ exposed: { port: 80, protocol: 'tcp' } }] + }) + + expect(psOut.services[2]).toEqual({ + command: 'nginx -g daemon off;', + name: 'compose_test_web', + state: 'Up', + ports: [ + { + exposed: { port: 443, protocol: 'tcp' }, + mapped: { port: 443, address: '0.0.0.0' } + }, + { + exposed: { port: 80, protocol: 'tcp' }, + mapped: { port: 80, address: '0.0.0.0' } + } + ] + }) +}) + +test('parse ports', () => { + const noPort = '' + const exposedTcp = '80/tcp' + const mappedExposedTcp = '0.0.0.0:443->443/tcp' + const multipleExposedMappedTcp = '0.0.0.0:443->443/tcp, 0.0.0.0:80->80/tcp' + + expect(mapPorts(noPort)).toEqual([]) + expect(mapPorts(exposedTcp)).toEqual([ + { exposed: { port: 80, protocol: 'tcp' } } + ]) + expect(mapPorts(mappedExposedTcp)).toEqual([ + { + exposed: { port: 443, protocol: 'tcp' }, + mapped: { address: '0.0.0.0', port: 443 } + } + ]) + expect(mapPorts(multipleExposedMappedTcp)).toEqual([ + { + exposed: { port: 443, protocol: 'tcp' }, + mapped: { address: '0.0.0.0', port: 443 } + }, + { + exposed: { port: 80, protocol: 'tcp' }, + mapped: { address: '0.0.0.0', port: 80 } + } + ]) +})