Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ With Proxmox 9's native OCI container support, the easiest installation method i

```bash
# Pull and run the container from GHCR
pct create <VMID> ghcr.io/mieweb/opensource-server:latest \
pct create <VMID> ghcr.io/mieweb/opensource-server:latest \
--hostname opensource-server \
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
--features nesting=1 \
Expand Down
247 changes: 247 additions & 0 deletions create-a-container/bin/oci-build-push-pull.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
#!/usr/bin/env node
/**
* oci-build-push-pull.js
*
* Combined OCI image build, push, and pull job.
*
* This utility:
* 1. Builds site-specific OCI images using Docker with --build-arg DOMAIN
* 2. Pushes site images to a local registry
* 3. Pulls pre-built OCI images (Debian 13, Rocky 9) to all Proxmox nodes
* 4. Pulls site-specific images to all Proxmox nodes
*
* Environment variables:
* - LOCAL_REGISTRY (default: localhost:5000)
* - OCI_REPO (default: opensource-server)
* - BUILD_CONTEXT (default: /opt/opensource-server)
* - DOCKERFILE_PATH (default: /opt/opensource-server/templates/debian.Dockerfile)
* - IMAGE_TAG_SUFFIX (default: latest)
* - OCI_IMAGE_TAG (default: latest, for pre-built images)
*/

const { spawn } = require('child_process');
const db = require('../models');
const ProxmoxApi = require('../utils/proxmox-api');

/**
* Sanitize domain/site name into valid Docker tag.
* Converts to lowercase, replaces invalid characters with hyphens, and limits length.
* @param {string} s - Input domain or site name
* @returns {string} Sanitized tag suitable for Docker image naming
*/
function sanitizeTag(s) {
return (s || 'site').toLowerCase().replace(/[^a-z0-9._-]/g, '-').replace(/-+/g, '-').slice(0, 128);
}

/**
* Execute a command and stream output to console, returning a promise.
* @param {string} cmd - Command to execute (e.g., 'docker')
* @param {string[]} args - Command arguments
* @param {object} [opts={}] - Additional options for spawn (e.g., cwd, env)
* @returns {Promise<void>} Resolves on success, rejects if command exits with non-zero code
*/
function runCommandStreamed(cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
const p = spawn(cmd, args, Object.assign({ stdio: 'inherit' }, opts));
p.on('error', reject);
p.on('close', code => {
if (code === 0) resolve();
else reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
});
});
}

/**
* Build and push a site-specific OCI image using Docker.
* @param {object} site - Site database object with id, name, domain, internalDomain properties
* @param {string} registry - Container registry URL (e.g., localhost:5000)
* @param {string} repoBase - Repository base path in registry (e.g., opensource-server)
* @param {string} buildContext - Docker build context path
* @param {string} dockerfilePath - Path to Dockerfile to use for build
* @param {string} [tagSuffix='latest'] - Image tag suffix (appended after domain)
* @returns {Promise<{imageRef: string, domain: string}>} Built image reference and domain used
* @throws {Error} If docker build or push fails
*/
async function buildAndPushImageForSite(site, registry, repoBase, buildContext, dockerfilePath, tagSuffix = 'latest') {
const domain = site.internalDomain;
const sanitized = sanitizeTag(domain);
const imageRef = `${registry}/${repoBase}/${sanitized}:${tagSuffix}`;

console.log(`[oci-build-push-pull] Building image for site ${site.id} (${domain}) -> ${imageRef}`);

// docker build --build-arg DOMAIN=${domain} -f <dockerfile> -t <imageRef> <context>
await runCommandStreamed('docker', [
'build',
'--build-arg', `DOMAIN=${domain}`,
'-f', dockerfilePath,
'-t', imageRef,
buildContext
]);

console.log(`[oci-build-push-pull] Pushing image ${imageRef} to registry ${registry}`);
await runCommandStreamed('docker', ['push', imageRef]);

return { imageRef, domain };
}

/**
* Main job execution: orchestrate three phases of OCI image management.
* Phase 1: Build site-specific images from Dockerfile and push to registry.
* Phase 2: Prepare list of all images (site + pre-built) to pull.
* Phase 3: Pull all images to all Proxmox nodes concurrently.
*
* Configuration may be supplied via CLI args (preferred) or environment variables as fallbacks.
* @param {object} [opts]
* @param {string} [opts.registry] - Container registry (overrides LOCAL_REGISTRY env)
* @param {string} [opts.repoBase] - Repository base path in registry (overrides OCI_REPO env)
* @param {string} [opts.buildContext] - Docker build context path (overrides BUILD_CONTEXT env)
* @param {string} [opts.dockerfilePath] - Path to Dockerfile (overrides DOCKERFILE_PATH env)
* @param {string} [opts.tagSuffix] - Image tag suffix (overrides IMAGE_TAG_SUFFIX env)
* @returns {Promise<void>} Resolves on completion, calls process.exit(0) or process.exit(1)
*/
async function run(opts = {}) {
const registry = opts.registry || process.env.LOCAL_REGISTRY || 'localhost:5000';
const repoBase = opts.repoBase || process.env.OCI_REPO || 'opensource-server';
const buildContext = opts.buildContext || process.env.BUILD_CONTEXT || '/opt/opensource-server';
const dockerfilePath = opts.dockerfilePath || process.env.DOCKERFILE_PATH || '/opt/opensource-server/templates/debian.Dockerfile';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should default to /opt/opensource-server/create-a-container/templates/debian.Dockerfile

const tagSuffix = opts.tagSuffix || process.env.IMAGE_TAG_SUFFIX || 'latest';

try {
await db.sequelize.authenticate();
console.log('[oci-build-push-pull] Database connected');

// ========== PHASE 1: Build and push site-specific images ==========
console.log('[oci-build-push-pull] ========== PHASE 1: Build & Push Site Images ==========');

const sites = await db.Site.findAll();
const siteImages = [];

if (!sites || sites.length === 0) {
console.warn('[oci-build-push-pull] No sites found in DB; skipping site image builds');
} else {
console.log(`[oci-build-push-pull] Found ${sites.length} site(s) to build`);

// Run all site builds in parallel and collect results
const buildPromises = sites.map(site => buildAndPushImageForSite(site, registry, repoBase, buildContext, dockerfilePath, tagSuffix));
const buildResults = await Promise.allSettled(buildPromises);

buildResults.forEach((res, idx) => {
const site = sites[idx];
if (res.status === 'fulfilled') {
siteImages.push(res.value.imageRef);
} else {
console.error(`[oci-build-push-pull] Error building/pushing for site ${site.id}: ${res.reason && res.reason.message ? res.reason.message : res.reason}`);
}
});

console.log(`[oci-build-push-pull] Successfully built and pushed ${siteImages.length}/${sites.length} site images`);
}

// ========== PHASE 2: Prepare all images to pull ==========
// We only pull site-built images; pre-built templates are not handled here.
const allImagesToPull = [...siteImages];
console.log(`[oci-build-push-pull] Will pull ${allImagesToPull.length} site image(s):`);
allImagesToPull.forEach(img => console.log(` - ${img}`));

// ========== PHASE 3: Pull all images to all nodes ==========
console.log('[oci-build-push-pull] ========== PHASE 3: Pull Images to Nodes ==========');

const nodes = await db.Node.findAll();
if (!nodes || nodes.length === 0) {
console.warn('[oci-build-push-pull] No Proxmox nodes found in DB; skipping pull operations');
console.log('[oci-build-push-pull] Job completed successfully');
process.exit(0);
}

console.log(`[oci-build-push-pull] Found ${nodes.length} Proxmox node(s)`);

let totalSuccess = 0;
let totalFailure = 0;

for (const imageRef of allImagesToPull) {
console.log(`[oci-build-push-pull] Pulling image: ${imageRef}`);

const pulls = nodes.map(async (node) => {
if (!node.apiUrl || !node.tokenId || !node.secret) {
console.warn(`[oci-build-push-pull] Node ${node.name} missing API credentials, skipping pull`);
return false;
}

try {
const api = new ProxmoxApi(node.apiUrl, node.tokenId, node.secret, {
httpsAgent: { rejectUnauthorized: node.tlsVerify !== false }
});

const targetStorage = await api.chooseStorageForVztmpl(node.name, node.defaultStorage);
if (!targetStorage) {
console.warn(`[oci-build-push-pull] No suitable storage on node ${node.name}, skipping`);
return false;
}

console.log(`[oci-build-push-pull] Instructing node ${node.name} to pull ${imageRef} into storage ${targetStorage}`);
await api.pullImageAndWait(node.name, imageRef, targetStorage);
return true;
} catch (err) {
console.error(`[oci-build-push-pull] Failed to pull ${imageRef} on ${node.name}: ${err.message}`);
return false;
}
});

const results = await Promise.allSettled(pulls);
const success = results.filter(r => r.status === 'fulfilled' && r.value === true).length;
const failed = results.length - success;

totalSuccess += success;
totalFailure += failed;

console.log(`[oci-build-push-pull] Image ${imageRef} pulled to ${success}/${nodes.length} nodes (${failed} failures)`);
}

// ========== Final Summary ==========
console.log('[oci-build-push-pull] ========== Job Summary ==========');
console.log(`[oci-build-push-pull] Site images built: ${siteImages.length}`);
console.log(`[oci-build-push-pull] Total pull operations: ${totalSuccess + totalFailure}`);
console.log(`[oci-build-push-pull] Successful pulls: ${totalSuccess}`);
console.log(`[oci-build-push-pull] Failed pulls: ${totalFailure}`);

if (totalFailure === 0 || totalSuccess > 0) {
console.log('[oci-build-push-pull] OCI build, push, and pull job completed successfully');
process.exit(0);
} else {
throw new Error('All pull operations failed');
}
} catch (err) {
console.error('[oci-build-push-pull] Fatal error:', err.message);
process.exit(1);
}
}

// Simple CLI arg parser supporting `--key=value` and `--key value` forms
function parseCliArgs() {
const argv = process.argv.slice(2);
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) continue;
const eq = a.indexOf('=');
if (eq !== -1) {
const key = a.slice(2, eq);
const val = a.slice(eq + 1);
out[key] = val;
} else {
const key = a.slice(2);
const next = argv[i + 1];
if (next && !next.startsWith('--')) {
out[key] = next;
i++;
} else {
out[key] = 'true';
}
}
}
return out;
}

// Execute the job when this file is loaded by the scheduler, using CLI args if provided
const parsedOptions = parseCliArgs();
run(parsedOptions);
59 changes: 59 additions & 0 deletions create-a-container/job-runner.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node
/**
* job-runner.js
* - Checks ScheduledJobs and creates pending Jobs when schedule conditions are met
* - Polls the Jobs table for pending jobs
* - Claims a job (transactionally), sets status to 'running'
* - Spawns the configured command and streams stdout/stderr into JobStatuses
Expand All @@ -9,6 +10,7 @@

const { spawn } = require('child_process');
const path = require('path');
const parser = require('cron-parser');
const db = require('./models');

const POLL_INTERVAL_MS = parseInt(process.env.JOB_RUNNER_POLL_MS || '2000', 10);
Expand All @@ -17,6 +19,59 @@ const WORKDIR = process.env.JOB_RUNNER_CWD || process.cwd();
let shuttingDown = false;
// Map of jobId -> child process for active/running jobs
const activeChildren = new Map();
// Track last scheduled job execution time to avoid duplicate runs
const lastScheduledExecution = new Map();

async function shouldScheduledJobRun(scheduledJob) {
try {
const interval = parser.parseExpression(scheduledJob.schedule);
const now = new Date();
const lastExecution = lastScheduledExecution.get(scheduledJob.id);

// Get the next occurrence from the schedule
const nextExecution = interval.next().toDate();
const currentMinute = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes());
const nextMinute = new Date(nextExecution.getFullYear(), nextExecution.getMonth(), nextExecution.getDate(), nextExecution.getHours(), nextExecution.getMinutes());

// If the next scheduled time is now and we haven't executed in this minute
if (currentMinute.getTime() === nextMinute.getTime()) {
if (!lastExecution || lastExecution.getTime() < currentMinute.getTime()) {
return true;
}
}
return false;
} catch (err) {
console.error(`Error parsing schedule for job ${scheduledJob.id}: ${err.message}`);
return false;
}
}

async function processScheduledJobs() {
try {
const scheduledJobs = await db.ScheduledJob.findAll();

for (const scheduledJob of scheduledJobs) {
if (await shouldScheduledJobRun(scheduledJob)) {
console.log(`JobRunner: Creating job from scheduled job ${scheduledJob.id}: ${scheduledJob.schedule}`);

try {
await db.Job.create({
command: scheduledJob.command,
status: 'pending',
createdBy: `ScheduledJob#${scheduledJob.id}`
});

// Mark that we've executed this scheduled job at this time
lastScheduledExecution.set(scheduledJob.id, new Date());
} catch (err) {
console.error(`Error creating job from scheduled job ${scheduledJob.id}:`, err);
}
}
}
} catch (err) {
console.error('Error processing scheduled jobs:', err);
}
}

async function claimPendingJob() {
const sequelize = db.sequelize;
Expand Down Expand Up @@ -139,6 +194,10 @@ async function shutdownAndCancelJobs(signal) {
async function loop() {
if (shuttingDown) return;
try {
// Check for scheduled jobs that should run (run async so it doesn't block the loop)
processScheduledJobs().catch(err => console.error('processScheduledJobs error', err));

// Check for pending jobs
const job = await claimPendingJob();
if (job) {
// Run job but don't block polling loop; we will wait for job to update
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('ScheduledJobs', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
schedule: {
type: Sequelize.STRING(255),
allowNull: false,
comment: 'Cron-style schedule expression (e.g., "0 2 * * *" for daily at 2 AM)'
},
command: {
type: Sequelize.STRING(2000),
allowNull: false
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('ScheduledJobs');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('Nodes', 'defaultStorage', {
type: Sequelize.STRING(255),
allowNull: true,
comment: 'Default storage target for container templates and images'
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('Nodes', 'defaultStorage');
}
};
Loading
Loading