UNPKG

podletjs

Version:

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

430 lines (379 loc) 17.5 kB
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; import { QuadletGenerator } from '../../src/quadlet-generator.js'; import { Container } from '../../src/container.js'; import fs from 'fs-extra'; import path from 'path'; import tmp from 'tmp'; describe('QuadletGenerator E2E Tests', () => { let tempDir; beforeEach(() => { tempDir = tmp.dirSync({ unsafeCleanup: true }); }); afterEach(() => { if (tempDir) { tempDir.removeCallback(); } }); describe('Complete Quadlet File Generation', () => { it('should generate a production-ready web server quadlet file', async () => { const container = new Container(); container.setImage('nginx:alpine'); container.setContainerName('production-web'); container.addPublishPort('443:443'); container.addPublishPort('80:80'); container.addVolume('/etc/ssl/certs:/etc/ssl/certs:ro'); container.addVolume('/var/www/html:/usr/share/nginx/html:ro'); container.addVolume('/etc/nginx/conf.d:/etc/nginx/conf.d:ro'); container.addEnvironment('NGINX_HOST=production.example.com'); container.addLabel('service=web'); container.addLabel('tier=frontend'); container.user = 'nginx:nginx'; container.readOnly = true; container.noNewPrivileges = true; container.dropCapability.push('ALL'); container.addCapability.push('CHOWN'); container.addCapability.push('SETGID'); container.addCapability.push('SETUID'); container.healthCmd = 'curl -f http://localhost/health || exit 1'; container.healthInterval = '30s'; container.healthTimeout = '10s'; container.healthRetries = 3; const options = { unit: { description: 'Production Web Server', after: ['network-online.target'], wants: ['network-online.target'], requires: ['network.target'] }, service: { restart: 'always', restartSec: '10' }, install: { wantedBy: ['multi-user.target'] }, globals: { podmanArgs: '--log-level=warn' } }; const quadletContent = QuadletGenerator.generateFile(container, options); // Write to file const outputFile = path.join(tempDir.name, 'production-web.container'); await fs.writeFile(outputFile, quadletContent); // Verify file exists and has correct content expect(await fs.pathExists(outputFile)).toBe(true); const fileContent = await fs.readFile(outputFile, 'utf8'); // Verify all sections are present expect(fileContent).toContain('[Unit]'); expect(fileContent).toContain('[Container]'); expect(fileContent).toContain('[GlobalArgs]'); expect(fileContent).toContain('[Service]'); expect(fileContent).toContain('[Install]'); // Verify Unit section expect(fileContent).toContain('Description=Production Web Server'); expect(fileContent).toContain('After=network-online.target'); expect(fileContent).toContain('Wants=network-online.target'); expect(fileContent).toContain('Requires=network.target'); // Verify Container section expect(fileContent).toContain('Image=nginx:alpine'); expect(fileContent).toContain('ContainerName=production-web'); expect(fileContent).toContain('PublishPort=443:443'); expect(fileContent).toContain('PublishPort=80:80'); expect(fileContent).toContain('Volume=/etc/ssl/certs:/etc/ssl/certs:ro'); expect(fileContent).toContain('Environment=NGINX_HOST=production.example.com'); expect(fileContent).toContain('Label=service=web'); expect(fileContent).toContain('User=nginx:nginx'); expect(fileContent).toContain('ReadOnly=true'); expect(fileContent).toContain('NoNewPrivileges=true'); expect(fileContent).toContain('DropCapability=ALL'); expect(fileContent).toContain('AddCapability=CHOWN SETGID SETUID'); expect(fileContent).toContain('HealthCmd=curl -f http://localhost/health || exit 1'); expect(fileContent).toContain('HealthInterval=30s'); // Verify Service section expect(fileContent).toContain('Restart=always'); expect(fileContent).toContain('RestartSec=10'); // Verify Install section expect(fileContent).toContain('WantedBy=multi-user.target'); // Verify GlobalArgs section expect(fileContent).toContain('PodmanArgs=--log-level=warn'); }); it('should generate a database quadlet with proper security settings', async () => { const container = new Container(); container.setImage('postgres:15-alpine'); container.setContainerName('production-db'); container.addPublishPort('5432:5432'); container.addVolume('postgres_data:/var/lib/postgresql/data:Z'); container.addVolume('/etc/postgresql/postgresql.conf:/etc/postgresql/postgresql.conf:ro'); container.addEnvironment('POSTGRES_DB=myapp'); container.addEnvironment('POSTGRES_USER=appuser'); container.addEnvironment('POSTGRES_PASSWORD=supersecret'); container.addEnvironment('PGDATA=/var/lib/postgresql/data/pgdata'); container.user = 'postgres:postgres'; container.noNewPrivileges = true; container.dropCapability.push('ALL'); container.addCapability.push('SETGID'); container.addCapability.push('SETUID'); container.addCapability.push('DAC_READ_SEARCH'); container.healthCmd = 'pg_isready -U appuser -d myapp'; container.healthInterval = '10s'; container.healthTimeout = '5s'; container.healthRetries = 5; container.healthStartPeriod = '60s'; container.shmSize = '256m'; container.ulimit.push('nofile=1024:1024'); container.sysctl.push('kernel.shmmax=134217728'); const options = { unit: { description: 'Production PostgreSQL Database', after: ['network.target'], requires: ['network.target'] }, service: { restart: 'on-failure', restartSec: '30' }, install: { wantedBy: ['multi-user.target'] } }; const quadletContent = QuadletGenerator.generateFile(container, options); // Write to file const outputFile = path.join(tempDir.name, 'production-db.container'); await fs.writeFile(outputFile, quadletContent); // Verify file content const fileContent = await fs.readFile(outputFile, 'utf8'); expect(fileContent).toContain('Image=postgres:15-alpine'); expect(fileContent).toContain('ContainerName=production-db'); expect(fileContent).toContain('Volume=postgres_data:/var/lib/postgresql/data:Z'); expect(fileContent).toContain('Environment=POSTGRES_DB=myapp'); expect(fileContent).toContain('User=postgres:postgres'); expect(fileContent).toContain('NoNewPrivileges=true'); expect(fileContent).toContain('DropCapability=ALL'); expect(fileContent).toContain('AddCapability=SETGID SETUID DAC_READ_SEARCH'); expect(fileContent).toContain('HealthCmd=pg_isready -U appuser -d myapp'); expect(fileContent).toContain('ShmSize=256m'); expect(fileContent).toContain('Ulimit=nofile=1024:1024'); expect(fileContent).toContain('Sysctl=kernel.shmmax=134217728'); }); it('should generate a microservice quadlet with networking and monitoring', async () => { const container = new Container(); container.setImage('node:18-alpine'); container.setContainerName('api-service'); container.addPublishPort('3000:3000'); container.addVolume('./app:/usr/src/app:ro'); container.addVolume('node_modules:/usr/src/app/node_modules'); container.addEnvironment('NODE_ENV=production'); container.addEnvironment('PORT=3000'); container.addEnvironment('DATABASE_URL=postgresql://user:pass@db:5432/myapp'); container.addLabel('service=api'); container.addLabel('version=1.0.0'); container.addLabel('maintainer=devops@example.com'); container.network.push('backend'); container.network.push('monitoring'); container.networkAlias.push('api'); container.workingDir = '/usr/src/app'; container.setExec('node server.js'); container.user = 'node:node'; container.readOnly = true; container.tmpfs.push('/tmp'); container.tmpfs.push('/usr/src/app/logs:size=100m'); container.noNewPrivileges = true; container.dropCapability.push('ALL'); container.healthCmd = 'curl -f http://localhost:3000/health'; container.healthInterval = '30s'; container.healthTimeout = '10s'; container.healthRetries = 3; container.healthStartPeriod = '40s'; container.stopSignal = 'SIGINT'; container.stopTimeout = 30; container.logDriver = 'journald'; container.logOpt.push('tag=api-service'); const options = { unit: { description: 'API Microservice', after: ['network.target', 'production-db.service'], wants: ['network-online.target'], requires: ['production-db.service'] }, service: { restart: 'on-failure', restartSec: '5' }, install: { wantedBy: ['multi-user.target'] } }; const quadletContent = QuadletGenerator.generateFile(container, options); // Write to file const outputFile = path.join(tempDir.name, 'api-service.container'); await fs.writeFile(outputFile, quadletContent); // Verify file content const fileContent = await fs.readFile(outputFile, 'utf8'); expect(fileContent).toContain('Image=node:18-alpine'); expect(fileContent).toContain('ContainerName=api-service'); expect(fileContent).toContain('Exec=node server.js'); expect(fileContent).toContain('WorkingDir=/usr/src/app'); expect(fileContent).toContain('Network=backend'); expect(fileContent).toContain('Network=monitoring'); expect(fileContent).toContain('NetworkAlias=api'); expect(fileContent).toContain('Tmpfs=/tmp'); expect(fileContent).toContain('Tmpfs=/usr/src/app/logs:size=100m'); expect(fileContent).toContain('StopSignal=SIGINT'); expect(fileContent).toContain('StopTimeout=30'); expect(fileContent).toContain('LogDriver=journald'); expect(fileContent).toContain('LogOpt=tag=api-service'); expect(fileContent).toContain('Requires=production-db.service'); }); }); describe('Multi-container Stack Generation', () => { it('should generate a complete LAMP stack', async () => { // Apache/PHP container const webContainer = new Container(); webContainer.setImage('php:8.1-apache'); webContainer.setContainerName('lamp-web'); webContainer.addPublishPort('80:80'); webContainer.addVolume('./www:/var/www/html'); webContainer.addVolume('./apache-config:/etc/apache2/sites-available'); webContainer.addEnvironment('APACHE_DOCUMENT_ROOT=/var/www/html'); webContainer.network.push('lamp-network'); webContainer.networkAlias.push('web'); // MySQL container const dbContainer = new Container(); dbContainer.setImage('mysql:8.0'); dbContainer.setContainerName('lamp-mysql'); dbContainer.addVolume('mysql_data:/var/lib/mysql'); dbContainer.addEnvironment('MYSQL_ROOT_PASSWORD=rootpass'); dbContainer.addEnvironment('MYSQL_DATABASE=webapp'); dbContainer.addEnvironment('MYSQL_USER=webuser'); dbContainer.addEnvironment('MYSQL_PASSWORD=webpass'); dbContainer.network.push('lamp-network'); dbContainer.networkAlias.push('mysql'); // PHPMyAdmin container const pmaContainer = new Container(); pmaContainer.setImage('phpmyadmin:latest'); pmaContainer.setContainerName('lamp-phpmyadmin'); pmaContainer.addPublishPort('8080:80'); pmaContainer.addEnvironment('PMA_HOST=mysql'); pmaContainer.addEnvironment('PMA_USER=webuser'); pmaContainer.addEnvironment('PMA_PASSWORD=webpass'); pmaContainer.network.push('lamp-network'); const containers = [ { name: 'lamp-web', container: webContainer }, { name: 'lamp-mysql', container: dbContainer }, { name: 'lamp-phpmyadmin', container: pmaContainer } ]; const commonOptions = { unit: { after: ['network.target'], wants: ['network-online.target'] }, service: { restart: 'on-failure', restartSec: '10' }, install: { wantedBy: ['multi-user.target'] } }; // Generate all container files for (const { name, container } of containers) { const options = { ...commonOptions, unit: { ...commonOptions.unit, description: `LAMP Stack - ${name}` } }; const quadletContent = QuadletGenerator.generateFile(container, options); const outputFile = path.join(tempDir.name, `${name}.container`); await fs.writeFile(outputFile, quadletContent); expect(await fs.pathExists(outputFile)).toBe(true); } // Verify web container const webContent = await fs.readFile(path.join(tempDir.name, 'lamp-web.container'), 'utf8'); expect(webContent).toContain('Image=php:8.1-apache'); expect(webContent).toContain('PublishPort=80:80'); expect(webContent).toContain('Network=lamp-network'); // Verify MySQL container const dbContent = await fs.readFile(path.join(tempDir.name, 'lamp-mysql.container'), 'utf8'); expect(dbContent).toContain('Image=mysql:8.0'); expect(dbContent).toContain('Environment=MYSQL_DATABASE=webapp'); expect(dbContent).toContain('Volume=mysql_data:/var/lib/mysql'); // Verify PHPMyAdmin container const pmaContent = await fs.readFile(path.join(tempDir.name, 'lamp-phpmyadmin.container'), 'utf8'); expect(pmaContent).toContain('Image=phpmyadmin:latest'); expect(pmaContent).toContain('PublishPort=8080:80'); expect(pmaContent).toContain('Environment=PMA_HOST=mysql'); }); }); describe('Validation and Best Practices', () => { it('should generate valid quadlet files that follow systemd conventions', async () => { const container = new Container(); container.setImage('redis:7-alpine'); container.setContainerName('cache-redis'); container.addPublishPort('6379:6379'); container.addVolume('redis_data:/data'); container.addEnvironment('REDIS_PASSWORD=cachepass'); const options = { unit: { description: 'Redis Cache Server', after: ['network.target'] }, service: { restart: 'always' }, install: { wantedBy: ['multi-user.target'] } }; const quadletContent = QuadletGenerator.generateFile(container, options); const outputFile = path.join(tempDir.name, 'cache-redis.container'); await fs.writeFile(outputFile, quadletContent); const fileContent = await fs.readFile(outputFile, 'utf8'); // Verify proper section ordering const unitIndex = fileContent.indexOf('[Unit]'); const containerIndex = fileContent.indexOf('[Container]'); const serviceIndex = fileContent.indexOf('[Service]'); const installIndex = fileContent.indexOf('[Install]'); expect(unitIndex).toBeLessThan(containerIndex); expect(containerIndex).toBeLessThan(serviceIndex); expect(serviceIndex).toBeLessThan(installIndex); // Verify no empty lines within sections const lines = fileContent.split('\n'); let inSection = false; let sectionName = ''; for (const line of lines) { if (line.startsWith('[') && line.endsWith(']')) { inSection = true; sectionName = line; continue; } if (inSection && line.trim() === '') { // Empty line should end the section inSection = false; continue; } if (inSection && !line.includes('=')) { // Invalid line in section (except for section headers) if (!line.startsWith('[')) { fail(`Invalid line in ${sectionName}: ${line}`); } } } }); it('should handle special characters and escaping properly', async () => { const container = new Container(); container.setImage('app:latest'); container.setContainerName('special-chars-app'); container.addEnvironment('MESSAGE=Hello "World" with spaces'); container.addEnvironment('PATH_VAR=/path/with spaces/and:colons'); container.addLabel('description=An app with "quotes" and spaces'); container.addLabel('build.args=--flag="value with spaces"'); const quadletContent = QuadletGenerator.generateContainerSection(container); expect(quadletContent).toContain('Environment="MESSAGE=Hello \\"World\\" with spaces"'); expect(quadletContent).toContain('Environment="PATH_VAR=/path/with spaces/and:colons"'); expect(quadletContent).toContain('Label="description=An app with \\"quotes\\" and spaces"'); expect(quadletContent).toContain('Label="build.args=--flag=\\"value with spaces\\""'); }); }); });