Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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> localhost:5000/mieweb/opensource-server:latest \
Comment thread
runleveldev marked this conversation as resolved.
Outdated
--hostname opensource-server \
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
--features nesting=1 \
Expand Down
12 changes: 12 additions & 0 deletions create-a-container/bin/oci-build-job.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env node
// Wrapper to run the oci-build job from the repository bin directory
const path = require('path');
const job = require(path.join(__dirname, '..', 'utils', 'oci-build-job'));
Comment thread
runleveldev marked this conversation as resolved.
Outdated

if (require.main === module) {
job.run().catch(err => {
console.error('OCI build job failed:', err);
process.exit(1);
});
}
module.exports = job;
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');
}
};
5 changes: 5 additions & 0 deletions create-a-container/models/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ module.exports = (sequelize, DataTypes) => {
tlsVerify: {
type: DataTypes.BOOLEAN,
allowNull: true
},
defaultStorage: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Default storage target for container templates and images'
}
}, {
sequelize,
Expand Down
24 changes: 24 additions & 0 deletions create-a-container/models/scheduled-job.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class ScheduledJob extends Model {
static associate(models) {
// ScheduledJob can be associated with created Jobs if needed
}
}
ScheduledJob.init({
schedule: {
type: DataTypes.STRING(255),
allowNull: false,
comment: 'Cron-style schedule expression (e.g., "0 2 * * *" for daily at 2 AM)'
},
command: {
type: DataTypes.STRING(2000),
allowNull: false
}
}, {
sequelize,
modelName: 'ScheduledJob'
});
return ScheduledJob;
};
1 change: 1 addition & 0 deletions create-a-container/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"argon2": "^0.44.0",
"axios": "^1.12.2",
"connect-flash": "^0.1.1",
"cron-parser": "^4.1.0",
"dotenv": "^17.2.3",
"ejs": "^3.1.10",
"express": "^5.2.1",
Expand Down
21 changes: 21 additions & 0 deletions create-a-container/seeders/20251203000000-seed-oci-build-job.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.bulkInsert('ScheduledJobs', [
{
schedule: '0 2 * * *',
command: 'node create-a-container/bin/oci-build-job.js',
createdAt: new Date(),
updatedAt: new Date()
}
], {});
},

async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('ScheduledJobs', {
command: { [Sequelize.Op.like]: '%oci-build-job%' }
}, {});
}
};
21 changes: 21 additions & 0 deletions create-a-container/seeders/20251204000000-seed-build-push-oci.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.bulkInsert('ScheduledJobs', [
{
schedule: '0 3 * * *',
command: 'node create-a-container/utils/build-push-oci.js',
createdAt: new Date(),
updatedAt: new Date()
}
], {});
},

async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('ScheduledJobs', {
command: { [Sequelize.Op.like]: '%build-push-oci%' }
}, {});
}
};
11 changes: 11 additions & 0 deletions create-a-container/templates/debian.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Debian OCI image template for site-specific builds
FROM debian:13
Comment thread
runleveldev marked this conversation as resolved.
Outdated
ARG DOMAIN

# Install curl, fetch the pown.sh installer, make it executable and run it
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://pown.sh/ -o /usr/local/bin/pown.sh \
&& chmod +x /usr/local/bin/pown.sh \
&& /usr/local/bin/pown.sh "$DOMAIN"
Loading
Loading