UNPKG

podletjs

Version:

JavaScript port of Podlet - Generate Podman Quadlet files from Docker run commands and compose files

738 lines (680 loc) 17.4 kB
import { ComposeParser } from '../../src/compose-parser.js'; import { Container } from '../../src/container.js'; import fs from 'fs-extra'; import path from 'path'; import os from 'os'; const minimalCompose = ` version: '3' services: web: image: nginx:latest ports: - "8080:80" environment: NODE_ENV: production volumes: - ./data:/data labels: app: web `; describe('ComposeParser', () => { let parser; beforeEach(() => { parser = new ComposeParser(); }); it('parses a minimal compose file', () => { const containers = parser.parse(minimalCompose); expect(containers).toHaveProperty('web'); const web = containers.web; expect(web).toBeInstanceOf(Container); expect(web.image).toBe('nginx:latest'); expect(web.publishPort).toContain('8080:80'); expect(web.environment).toContain('NODE_ENV=production'); expect(web.volume).toContain('./data:/data'); expect(web.label).toContain('app=web'); }); it('throws on missing services', () => { const badYaml = `version: '3'`; expect(() => parser.parse(badYaml)).toThrow('Compose file must contain at least one service'); }); it('throws on service missing image/build', () => { const badYaml = ` version: '3' services: foo: ports: - "80:80" `; expect(() => parser.parse(badYaml)).toThrow("Service 'foo' must have either 'image' or 'build'"); }); it('parses service with build context', () => { const yaml = ` version: '3' services: builder: build: . `; const containers = parser.parse(yaml); expect(containers).toHaveProperty('builder'); expect(containers.builder.image).toBe('builder.build'); }); it('parses array and object environment', () => { const yaml = ` version: '3' services: envtest: image: node environment: - FOO=bar - BAZ=qux `; const containers = parser.parse(yaml); expect(containers.envtest.environment).toContain('FOO=bar'); expect(containers.envtest.environment).toContain('BAZ=qux'); const yamlObj = ` version: '3' services: envtest: image: node environment: FOO: bar BAZ: qux `; const containersObj = parser.parse(yamlObj); expect(containersObj.envtest.environment).toContain('FOO=bar'); expect(containersObj.envtest.environment).toContain('BAZ=qux'); }); it('parses ports in long form', () => { const yaml = ` version: '3' services: web: image: nginx ports: - target: 80 published: 8080 protocol: tcp `; const containers = parser.parse(yaml); expect(containers.web.publishPort).toContain('8080:80'); }); it('parses volumes in long form', () => { const yaml = ` version: '3' services: web: image: nginx volumes: - type: bind source: ./src target: /app read_only: true `; const containers = parser.parse(yaml); expect(containers.web.volume).toContain('./src:/app:ro'); }); it('parses labels as array and object', () => { const yamlArr = ` version: '3' services: web: image: nginx labels: - foo=bar - baz=qux `; const containersArr = parser.parse(yamlArr); expect(containersArr.web.label).toContain('foo=bar'); expect(containersArr.web.label).toContain('baz=qux'); const yamlObj = ` version: '3' services: web: image: nginx labels: foo: bar baz: qux `; const containersObj = parser.parse(yamlObj); expect(containersObj.web.label).toContain('foo=bar'); expect(containersObj.web.label).toContain('baz=qux'); }); it('throws on unsupported top-level feature configs', () => { const yaml = ` version: '3' services: web: image: nginx configs: foo: {} `; expect(() => parser.parse(yaml)).toThrow("Compose feature 'configs' is not yet supported"); }); it('warns on unsupported service features', () => { const yaml = ` version: '3' services: web: image: nginx external_links: - foo links: - bar network_mode: host secrets: - mysecret configs: - myconfig deploy: replicas: 2 `; // This test checks that no error is thrown, but warnings are logged expect(() => parser.parse(yaml)).not.toThrow(); }); it('parses container_name', () => { const yaml = ` version: '3' services: web: image: nginx container_name: customname `; const containers = parser.parse(yaml); expect(containers.web.containerName).toBe('customname'); }); it('parses command and entrypoint as array and string', () => { const yamlArr = ` version: '3' services: web: image: nginx command: - echo - hello entrypoint: - sh - -c `; const containersArr = parser.parse(yamlArr); expect(containersArr.web.exec).toBe('echo hello'); expect(containersArr.web.entrypoint).toBe('sh -c'); const yamlStr = ` version: '3' services: web: image: nginx command: echo hello entrypoint: sh -c `; const containersStr = parser.parse(yamlStr); expect(containersStr.web.exec).toBe('echo hello'); expect(containersStr.web.entrypoint).toBe('sh -c'); }); it('parses env_file', () => { const yaml = ` version: '3' services: web: image: nginx env_file: - .env - .env.local `; const containers = parser.parse(yaml); expect(containers.web.environmentFile).toContain('.env'); expect(containers.web.environmentFile).toContain('.env.local'); }); it('parses hostname, user, working_dir, restart', () => { const yaml = ` version: '3' services: web: image: nginx hostname: myhost user: user1:group1 working_dir: /app restart: always `; const containers = parser.parse(yaml); expect(containers.web.hostName).toBe('myhost'); expect(containers.web.user).toBe('user1'); expect(containers.web.group).toBe('group1'); expect(containers.web.workingDir).toBe('/app'); expect(containers.web._restart).toBe('always'); }); it('parses security_opt', () => { const yaml = ` version: '3' services: web: image: nginx security_opt: - no-new-privileges:true - label=disable - seccomp=unconfined `; const containers = parser.parse(yaml); expect(containers.web.noNewPrivileges).toBe(true); expect(containers.web.securityLabelDisable).toBe(true); expect(containers.web.podmanArgs).toMatch(/--security-opt seccomp=unconfined/); }); it('parses cap_add, cap_drop, devices, dns', () => { const yaml = ` version: '3' services: web: image: nginx cap_add: - NET_ADMIN cap_drop: - SYS_ADMIN devices: - /dev/null dns: - 8.8.8.8 `; const containers = parser.parse(yaml); expect(containers.web.addCapability).toContain('NET_ADMIN'); expect(containers.web.dropCapability).toContain('SYS_ADMIN'); expect(containers.web.addDevice).toContain('/dev/null'); expect(containers.web.dns).toContain('8.8.8.8'); }); it('parses read_only, init, tmpfs', () => { const yaml = ` version: '3' services: web: image: nginx read_only: true init: true tmpfs: - /tmp `; const containers = parser.parse(yaml); expect(containers.web.readOnly).toBe(true); expect(containers.web.runInit).toBe(true); expect(containers.web.tmpfs).toContain('/tmp'); }); it('parses privileged, tty, stdin_open, mem_limit, cpus', () => { const yaml = ` version: '3' services: web: image: nginx privileged: true tty: true stdin_open: true mem_limit: 128m cpus: 2 `; const containers = parser.parse(yaml); expect(containers.web.podmanArgs).toMatch(/--privileged/); expect(containers.web.podmanArgs).toMatch(/--tty/); expect(containers.web.podmanArgs).toMatch(/--interactive/); expect(containers.web.podmanArgs).toMatch(/--memory 128m/); expect(containers.web.podmanArgs).toMatch(/--cpus 2/); }); it('parses healthcheck', () => { const yaml = ` version: '3' services: web: image: nginx healthcheck: test: ["CMD", "curl", "-f", "http://localhost"] interval: 1m30s timeout: 10s retries: 3 start_period: 40s `; const containers = parser.parse(yaml); expect(containers.web.healthCmd).toBe('CMD curl -f http://localhost'); expect(containers.web.healthInterval).toBe('1m30s'); expect(containers.web.healthTimeout).toBe('10s'); expect(containers.web.healthRetries).toBe(3); expect(containers.web.healthStartPeriod).toBe('40s'); }); it('parses healthcheck disable', () => { const yaml = ` version: '3' services: web: image: nginx healthcheck: disable: true `; const containers = parser.parse(yaml); expect(containers.web.healthCmd).toBe('none'); }); it('parses depends_on as array and object', () => { const yamlArr = ` version: '3' services: web: image: nginx depends_on: - db - cache `; const containersArr = parser.parse(yamlArr); expect(containersArr.web._dependsOn).toEqual(['db', 'cache']); const yamlObj = ` version: '3' services: web: image: nginx depends_on: db: { condition: service_healthy } cache: { condition: service_started } `; const containersObj = parser.parse(yamlObj); expect(containersObj.web._dependsOn).toEqual(['db', 'cache']); }); it('parses networks with ipv4_address and aliases', () => { const yaml = ` version: '3' services: web: image: nginx networks: custom: ipv4_address: 172.16.238.10 aliases: - alias1 - alias2 `; const containers = parser.parse(yaml); expect(containers.web.network).toContain('custom:ip=172.16.238.10'); expect(containers.web.networkAlias).toContain('alias1'); expect(containers.web.networkAlias).toContain('alias2'); }); it('throws on invalid YAML', () => { const invalidYaml = '{ invalid: yaml: content: ['; expect(() => parser.parse(invalidYaml)).toThrow(); }); it('throws on null or non-object compose content', () => { expect(() => parser.parse('null')).toThrow('Invalid compose file format'); expect(() => parser.parse('"string"')).toThrow('Invalid compose file format'); }); it('parses volumes with no source', () => { const yaml = ` version: '3' services: web: image: nginx volumes: - type: bind target: /app `; const containers = parser.parse(yaml); expect(containers.web.volume).toContain('/app'); }); it('parses tmpfs volumes', () => { const yaml = ` version: '3' services: web: image: nginx volumes: - type: tmpfs target: /tmp tmpfs: size: 100m `; const containers = parser.parse(yaml); expect(containers.web.tmpfs).toContain('/tmp:size=100m'); }); it('parses tmpfs volumes without size', () => { const yaml = ` version: '3' services: web: image: nginx volumes: - type: tmpfs target: /tmp `; const containers = parser.parse(yaml); expect(containers.web.tmpfs).toContain('/tmp'); }); it('parses complex image names', () => { const yaml = ` version: '3' services: web: image: registry.example.com/namespace/image:tag `; const containers = parser.parse(yaml); expect(containers.web.image).toBe('registry.example.com/namespace/image:tag'); expect(containers.web.containerName).toBe('web'); }); it('parses image with multiple slashes', () => { const yaml = ` version: '3' services: web: image: docker.io/library/nginx:latest `; const containers = parser.parse(yaml); expect(containers.web.image).toBe('docker.io/library/nginx:latest'); expect(containers.web.containerName).toBe('web'); }); it('escapes arguments with special characters', () => { const yaml = ` version: '3' services: web: image: nginx mem_limit: '128m with spaces' command: 'echo "hello world"' `; const containers = parser.parse(yaml); expect(containers.web.podmanArgs).toMatch(/--memory "128m with spaces"/); expect(containers.web.exec).toBe('echo "hello world"'); }); it('parses ports as numbers', () => { const yaml = ` version: '3' services: web: image: nginx ports: - 8080 `; const containers = parser.parse(yaml); expect(containers.web.publishPort).toContain('8080'); }); it('parses ports with protocol in long form', () => { const yaml = ` version: '3' services: web: image: nginx ports: - target: 80 published: 8080 protocol: udp `; const containers = parser.parse(yaml); expect(containers.web.publishPort).toContain('8080:80/udp'); }); it('parses ports without published port in long form', () => { const yaml = ` version: '3' services: web: image: nginx ports: - target: 80 protocol: tcp `; const containers = parser.parse(yaml); expect(containers.web.publishPort).toContain('80'); }); it('parses user without group', () => { const yaml = ` version: '3' services: web: image: nginx user: myuser `; const containers = parser.parse(yaml); expect(containers.web.user).toBe('myuser'); expect(containers.web.group).toBeNull(); }); it('parses numeric user', () => { const yaml = ` version: '3' services: web: image: nginx user: 1000 `; const containers = parser.parse(yaml); expect(containers.web.user).toBe('1000'); }); it('handles empty compose volumes', () => { const yaml = ` version: '3' services: web: image: nginx volumes: {} `; const containers = parser.parse(yaml); expect(containers.web.image).toBe('nginx'); }); it('parses single env_file as string', () => { const yaml = ` version: '3' services: web: image: nginx env_file: .env `; const containers = parser.parse(yaml); expect(containers.web.environmentFile).toContain('.env'); }); it('parses single cap_add and cap_drop as string', () => { const yaml = ` version: '3' services: web: image: nginx cap_add: NET_ADMIN cap_drop: SYS_ADMIN `; const containers = parser.parse(yaml); expect(containers.web.addCapability).toContain('NET_ADMIN'); expect(containers.web.dropCapability).toContain('SYS_ADMIN'); }); it('parses single device as string', () => { const yaml = ` version: '3' services: web: image: nginx devices: /dev/null `; const containers = parser.parse(yaml); expect(containers.web.addDevice).toContain('/dev/null'); }); it('parses single dns as string', () => { const yaml = ` version: '3' services: web: image: nginx dns: 8.8.8.8 `; const containers = parser.parse(yaml); expect(containers.web.dns).toContain('8.8.8.8'); }); it('parses single tmpfs as string', () => { const yaml = ` version: '3' services: web: image: nginx tmpfs: /tmp `; const containers = parser.parse(yaml); expect(containers.web.tmpfs).toContain('/tmp'); }); it('parses healthcheck with string test', () => { const yaml = ` version: '3' services: web: image: nginx healthcheck: test: "curl -f http://localhost" `; const containers = parser.parse(yaml); expect(containers.web.healthCmd).toBe('curl -f http://localhost'); }); it('parses networks as array of strings', () => { const yaml = ` version: '3' services: web: image: nginx networks: - frontend - backend `; const containers = parser.parse(yaml); expect(containers.web.network).toContain('frontend'); expect(containers.web.network).toContain('backend'); }); it('parses network with empty config', () => { const yaml = ` version: '3' services: web: image: nginx networks: frontend: {} `; const containers = parser.parse(yaml); expect(containers.web.network).toContain('frontend'); }); it('parses single security_opt as string', () => { const yaml = ` version: '3' services: web: image: nginx security_opt: no-new-privileges:true `; const containers = parser.parse(yaml); expect(containers.web.noNewPrivileges).toBe(true); }); describe('parseFile', () => { let tmpDir; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'podletjs-test-')); }); afterEach(async () => { await fs.remove(tmpDir); }); it('should parse a compose file from the filesystem', async () => { const composePath = path.join(tmpDir, 'docker-compose.yml'); await fs.writeFile(composePath, minimalCompose); const containers = await parser.parseFile(composePath); expect(containers).toHaveProperty('web'); const web = containers.web; expect(web.image).toBe('nginx:latest'); }); it('should throw an error if the file does not exist', async () => { const nonExistentPath = path.join(tmpDir, 'non-existent-file.yml'); await expect(parser.parseFile(nonExistentPath)).rejects.toThrow(); }); it('should throw an error for an empty file', async () => { const emptyPath = path.join(tmpDir, 'empty.yml'); await fs.writeFile(emptyPath, ''); await expect(parser.parseFile(emptyPath)).rejects.toThrow('Invalid compose file format'); }); }); });