podletjs
Version:
JavaScript port of Podlet - Generate Podman Quadlet files from Docker run commands and compose files
500 lines (427 loc) • 13.3 kB
JavaScript
/**
* Docker Compose File Parser
* Parses docker-compose YAML files into Container configurations
* Based on the Rust implementation from podlet
*/
import yaml from 'yaml';
import { Container } from './container.js';
/**
* Main parser class for docker-compose files
*/
export class ComposeParser {
constructor() {
// Initialize supported compose features
this.supportedFeatures = this._initializeSupportedFeatures();
}
/**
* Parse a compose YAML content into Container objects
*/
parse(yamlContent) {
const compose = yaml.parse(yamlContent);
if (!compose || typeof compose !== 'object') {
throw new Error('Invalid compose file format');
}
this._validateCompose(compose);
const containers = {};
// Parse services into containers
if (compose.services) {
for (const [serviceName, service] of Object.entries(compose.services)) {
containers[serviceName] = this._parseService(serviceName, service, compose);
}
}
return containers;
}
/**
* Parse a compose file from filesystem
*/
async parseFile(filePath) {
const fs = await import('fs-extra');
const yamlContent = await fs.default.readFile(filePath, 'utf8');
return this.parse(yamlContent);
}
/**
* Parse a single service into a Container object
*/
_parseService(serviceName, service, compose) {
const container = new Container();
// Set image
if (service.image) {
container.setImage(service.image);
} else if (service.build) {
// For build context, use a build tag
container.setImage(`${serviceName}.build`);
} else {
throw new Error(`Service '${serviceName}' must have either 'image' or 'build'`);
}
// Set container name
if (service.container_name) {
container.setContainerName(service.container_name);
} else {
container.setContainerName(serviceName);
}
// Handle command/entrypoint
if (service.command) {
const cmd = Array.isArray(service.command)
? service.command.join(' ')
: service.command;
container.setExec(cmd);
}
if (service.entrypoint) {
const entrypoint = Array.isArray(service.entrypoint)
? service.entrypoint.join(' ')
: service.entrypoint;
container.entrypoint = entrypoint;
}
// Handle ports
if (service.ports) {
this._parsePorts(service.ports, container);
}
// Handle volumes
if (service.volumes) {
this._parseVolumes(service.volumes, container, compose.volumes);
}
// Handle environment
if (service.environment) {
this._parseEnvironment(service.environment, container);
}
// Handle env_file
if (service.env_file) {
const envFiles = Array.isArray(service.env_file) ? service.env_file : [service.env_file];
envFiles.forEach(file => container.environmentFile.push(file));
}
// Handle labels
if (service.labels) {
this._parseLabels(service.labels, container);
}
// Handle networks
if (service.networks) {
this._parseNetworks(service.networks, container);
}
// Handle hostname
if (service.hostname) {
container.hostName = service.hostname;
}
// Handle user
if (service.user) {
const userSpec = String(service.user);
if (userSpec.includes(':')) {
const [user, group] = userSpec.split(':');
container.user = user;
container.group = group;
} else {
container.user = userSpec;
}
}
// Handle working directory
if (service.working_dir) {
container.workingDir = service.working_dir;
}
// Handle restart policy
if (service.restart) {
// This will be handled in the Service section
container._restart = service.restart;
}
// Handle security options
if (service.security_opt) {
this._parseSecurityOptions(service.security_opt, container);
}
// Handle capabilities
if (service.cap_add) {
const caps = Array.isArray(service.cap_add) ? service.cap_add : [service.cap_add];
container.addCapability.push(...caps);
}
if (service.cap_drop) {
const caps = Array.isArray(service.cap_drop) ? service.cap_drop : [service.cap_drop];
container.dropCapability.push(...caps);
}
// Handle devices
if (service.devices) {
const devices = Array.isArray(service.devices) ? service.devices : [service.devices];
container.addDevice.push(...devices);
}
// Handle DNS
if (service.dns) {
const dns = Array.isArray(service.dns) ? service.dns : [service.dns];
container.dns.push(...dns);
}
// Handle read-only
if (service.read_only) {
container.readOnly = true;
}
// Handle init
if (service.init) {
container.runInit = true;
}
// Handle tmpfs
if (service.tmpfs) {
const tmpfs = Array.isArray(service.tmpfs) ? service.tmpfs : [service.tmpfs];
container.tmpfs.push(...tmpfs);
}
// Handle privileged
if (service.privileged) {
this._addToPodmanArgs(container, '--privileged');
}
// Handle tty
if (service.tty) {
this._addToPodmanArgs(container, '--tty');
}
// Handle stdin_open
if (service.stdin_open) {
this._addToPodmanArgs(container, '--interactive');
}
// Handle memory limits
if (service.mem_limit) {
this._addToPodmanArgs(container, '--memory', service.mem_limit);
}
// Handle CPU limits
if (service.cpus) {
this._addToPodmanArgs(container, '--cpus', String(service.cpus));
}
// Handle healthcheck
if (service.healthcheck) {
this._parseHealthcheck(service.healthcheck, container);
}
// Handle depends_on (will be converted to systemd dependencies)
if (service.depends_on) {
container._dependsOn = Array.isArray(service.depends_on)
? service.depends_on
: Object.keys(service.depends_on);
}
return container;
}
/**
* Parse ports configuration
*/
_parsePorts(ports, container) {
const portList = Array.isArray(ports) ? ports : [ports];
for (const port of portList) {
if (typeof port === 'string' || typeof port === 'number') {
container.addPublishPort(String(port));
} else if (typeof port === 'object') {
// Long form: { target: 80, published: 8080, protocol: tcp }
let portSpec = '';
if (port.published) {
portSpec += port.published + ':';
}
portSpec += port.target;
if (port.protocol && port.protocol !== 'tcp') {
portSpec += '/' + port.protocol;
}
container.addPublishPort(portSpec);
}
}
}
/**
* Parse volumes configuration
*/
_parseVolumes(volumes, container, composeVolumes = {}) {
const volumeList = Array.isArray(volumes) ? volumes : [volumes];
for (const volume of volumeList) {
if (typeof volume === 'string') {
// Short form: "host:container" or "volume:container"
container.addVolume(volume);
} else if (typeof volume === 'object') {
// Long form: { type: bind, source: ./app, target: /app }
let volumeSpec = '';
if (volume.type === 'tmpfs') {
// Handle tmpfs separately
let tmpfsSpec = volume.target;
if (volume.tmpfs) {
if (volume.tmpfs.size) {
tmpfsSpec += ':size=' + volume.tmpfs.size;
}
}
container.tmpfs.push(tmpfsSpec);
continue;
}
if (volume.source) {
volumeSpec += volume.source + ':';
}
volumeSpec += volume.target;
if (volume.read_only) {
volumeSpec += ':ro';
}
container.addVolume(volumeSpec);
}
}
}
/**
* Parse environment variables
*/
_parseEnvironment(environment, container) {
if (Array.isArray(environment)) {
// Array format: ["NODE_ENV=production", "PORT=3000"]
environment.forEach(env => container.addEnvironment(env));
} else if (typeof environment === 'object') {
// Object format: { NODE_ENV: "production", PORT: 3000 }
for (const [key, value] of Object.entries(environment)) {
container.addEnvironment(`${key}=${value}`);
}
}
}
/**
* Parse labels
*/
_parseLabels(labels, container) {
if (Array.isArray(labels)) {
// Array format: ["app=web", "version=1.0"]
labels.forEach(label => container.addLabel(label));
} else if (typeof labels === 'object') {
// Object format: { app: "web", version: "1.0" }
for (const [key, value] of Object.entries(labels)) {
container.addLabel(`${key}=${value}`);
}
}
}
/**
* Parse networks configuration
*/
_parseNetworks(networks, container) {
if (Array.isArray(networks)) {
networks.forEach(network => {
if (typeof network === 'string') {
container.network.push(network);
}
});
} else if (typeof networks === 'object') {
for (const [networkName, config] of Object.entries(networks)) {
let networkSpec = networkName;
if (config && typeof config === 'object') {
const options = [];
if (config.ipv4_address) {
options.push(`ip=${config.ipv4_address}`);
}
if (config.aliases && config.aliases.length > 0) {
config.aliases.forEach(alias => {
container.networkAlias.push(alias);
});
}
if (options.length > 0) {
networkSpec += ':' + options.join(',');
}
}
container.network.push(networkSpec);
}
}
}
/**
* Parse security options
*/
_parseSecurityOptions(securityOpts, container) {
const opts = Array.isArray(securityOpts) ? securityOpts : [securityOpts];
for (const opt of opts) {
if (opt === 'no-new-privileges:true') {
container.noNewPrivileges = true;
} else if (opt.startsWith('label=disable')) {
container.securityLabelDisable = true;
} else {
this._addToPodmanArgs(container, '--security-opt', opt);
}
}
}
/**
* Parse healthcheck configuration
*/
_parseHealthcheck(healthcheck, container) {
if (healthcheck.disable) {
container.healthCmd = 'none';
return;
}
if (healthcheck.test) {
const test = Array.isArray(healthcheck.test)
? healthcheck.test.join(' ')
: healthcheck.test;
container.healthCmd = test;
}
if (healthcheck.interval) {
container.healthInterval = healthcheck.interval;
}
if (healthcheck.timeout) {
container.healthTimeout = healthcheck.timeout;
}
if (healthcheck.retries) {
container.healthRetries = healthcheck.retries;
}
if (healthcheck.start_period) {
container.healthStartPeriod = healthcheck.start_period;
}
}
/**
* Add arguments to podmanArgs field
*/
_addToPodmanArgs(container, flag, value = null) {
let args = container.podmanArgs || '';
if (args) args += ' ';
args += flag;
if (value !== null) {
args += ' ' + this._escapeArg(value);
}
container.podmanArgs = args;
}
/**
* Escape argument for shell safety
*/
_escapeArg(arg) {
if (typeof arg !== 'string') {
arg = String(arg);
}
if (/[\s"'`$\\|&;()<>]/.test(arg)) {
return `"${arg.replace(/["\\]/g, '\\$&')}"`;
}
return arg;
}
/**
* Validate compose file structure
*/
_validateCompose(compose) {
if (!compose.services || Object.keys(compose.services).length === 0) {
throw new Error('Compose file must contain at least one service');
}
// Check for unsupported top-level features
const unsupported = ['configs', 'secrets'];
for (const feature of unsupported) {
if (compose[feature] && Object.keys(compose[feature]).length > 0) {
throw new Error(`Compose feature '${feature}' is not yet supported`);
}
}
// Validate each service
for (const [serviceName, service] of Object.entries(compose.services)) {
this._validateService(serviceName, service);
}
}
/**
* Validate a single service
*/
_validateService(serviceName, service) {
// Check for required fields
if (!service.image && !service.build) {
throw new Error(`Service '${serviceName}' must have either 'image' or 'build'`);
}
// Check for unsupported features
const unsupported = [
'external_links', 'links', 'network_mode',
'secrets', 'configs', 'deploy'
];
for (const feature of unsupported) {
if (service[feature]) {
console.warn(`Warning: Service '${serviceName}' uses unsupported feature '${feature}' - ignoring`);
}
}
}
/**
* Initialize supported compose features
*/
_initializeSupportedFeatures() {
return {
services: true,
volumes: true,
networks: true,
version: true,
name: true,
// Unsupported
configs: false,
secrets: false,
extensions: false
};
}
}