Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
187 changes: 187 additions & 0 deletions create-a-container/bin/configure-traefik.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
#!/usr/bin/env node
/**
* configure-traefik.js
*
* Background job script that generates Traefik static configuration and
* manages the Traefik container lifecycle for a site.
*
* Usage: node bin/configure-traefik.js --site-id=<id>
*
* The script will:
* 1. Load site configuration including external domains and transport services
* 2. Generate Traefik CLI flags for static configuration
* 3. Create or update the Traefik container:
* - If container doesn't exist: create it and queue a create-container job
* - If container exists: update entrypoint and queue a reconfigure-container job
*
* All output is logged to STDOUT for capture by the job-runner.
* Exit code 0 = success, non-zero = failure.
*/

const path = require('path');

// Load models from parent directory
const db = require(path.join(__dirname, '..', 'models'));
const { Site, Node, Container, Service, TransportService, ExternalDomain, Job } = db;

// Load utilities
const { parseArgs } = require(path.join(__dirname, '..', 'utils', 'cli'));
const {
getBaseUrl,
getSystemContainerOwner,
buildTraefikCliFlags
} = require(path.join(__dirname, '..', 'utils', 'traefik'));

const TRAEFIK_HOSTNAME = 'traefik';
const TRAEFIK_IMAGE = 'docker.io/library/traefik:v3.0';

/**
* Main function
*/
async function main() {
const args = parseArgs();

if (!args['site-id']) {
console.error('Usage: node configure-traefik.js --site-id=<id>');
process.exit(1);
}

const siteId = parseInt(args['site-id'], 10);
console.log(`Starting Traefik configuration for site ID: ${siteId}`);

// Load site with all necessary associations
const site = await Site.findByPk(siteId, {
include: [
{
model: ExternalDomain,
as: 'externalDomains'
},
{
model: Node,
as: 'nodes',
include: [{
model: Container,
as: 'containers',
include: [{
model: Service,
as: 'services',
include: [{
model: TransportService,
as: 'transportService'
}]
}]
}]
}
]
});

if (!site) {
console.error(`Site with ID ${siteId} not found`);
process.exit(1);
}

console.log(`Site: ${site.name} (${site.internalDomain})`);
console.log(`External domains: ${site.externalDomains?.length || 0}`);

// Get base URL for HTTP provider
const baseUrl = await getBaseUrl();
console.log(`Base URL: ${baseUrl}`);

// Build Traefik CLI flags
const cliFlags = await buildTraefikCliFlags(siteId, site, baseUrl);
console.log(`Generated ${cliFlags.length} CLI flags`);

// Build entrypoint command
const entrypoint = `traefik ${cliFlags.join(' ')}`;
console.log(`Entrypoint: ${entrypoint.substring(0, 100)}...`);

// Build environment variables for Cloudflare DNS challenge
const envVars = {};
for (const domain of site.externalDomains || []) {
if (domain.cloudflareApiEmail && domain.cloudflareApiKey) {
envVars['CF_API_EMAIL'] = domain.cloudflareApiEmail;
envVars['CF_API_KEY'] = domain.cloudflareApiKey;
break; // Traefik uses global env vars for Cloudflare
}
}

// Find existing Traefik container for this site
let traefikContainer = null;
for (const node of site.nodes || []) {
const existing = node.containers?.find(c => c.hostname === TRAEFIK_HOSTNAME);
if (existing) {
traefikContainer = existing;
break;
}
}

if (traefikContainer) {
console.log(`Found existing Traefik container (ID: ${traefikContainer.id}, Node: ${traefikContainer.nodeId})`);

// Update the container's entrypoint and environment variables
await traefikContainer.update({
entrypoint,
environmentVars: Object.keys(envVars).length > 0 ? JSON.stringify(envVars) : null
});
console.log('Updated container configuration');

// Queue a reconfigure job to restart the container
const reconfigureJob = await Job.create({
command: `node bin/reconfigure-container.js --container-id=${traefikContainer.id}`,
createdBy: 'system',
serialGroup: `traefik-config-${siteId}`
});
console.log(`Queued reconfigure job ${reconfigureJob.id}`);

} else {
console.log('No existing Traefik container found, creating new one');

// Find a node in this site to run the container
const availableNode = site.nodes?.[0];
if (!availableNode) {
console.error('No nodes available in this site');
process.exit(1);
}
console.log(`Selected node: ${availableNode.name} (ID: ${availableNode.id})`);

// Get owner for the container
const owner = await getSystemContainerOwner();
if (!owner) {
console.error('No admin users found to assign as container owner');
process.exit(1);
}
console.log(`Container owner: ${owner}`);

// Create the container record
const newContainer = await Container.create({
hostname: TRAEFIK_HOSTNAME,
username: owner,
status: 'pending',
template: TRAEFIK_IMAGE,
nodeId: availableNode.id,
entrypoint,
environmentVars: Object.keys(envVars).length > 0 ? JSON.stringify(envVars) : null
});
console.log(`Created container record (ID: ${newContainer.id})`);

// Queue a create-container job
const createJob = await Job.create({
command: `node bin/create-container.js --container-id=${newContainer.id}`,
createdBy: 'system',
serialGroup: `traefik-config-${siteId}`
});
console.log(`Queued create-container job ${createJob.id}`);

// Link the creation job to the container
await newContainer.update({ creationJobId: createJob.id });
}

console.log('Traefik configuration completed successfully!');
process.exit(0);
}

// Run the main function
main().catch(err => {
console.error('Unhandled error:', err);
process.exit(1);
});
16 changes: 16 additions & 0 deletions create-a-container/job-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@ async function claimPendingJob() {

if (!job) return null;

// If job has a serialGroup, check if another job in that group is running (in database)
if (job.serialGroup) {
const runningInGroup = await db.Job.findOne({
where: {
serialGroup: job.serialGroup,
status: 'running'
},
transaction: t,
});

if (runningInGroup) {
// Another job in this group is running, skip this one for now
return null;
}
}

await job.update({ status: 'running' }, { transaction: t });
return job;
});
Expand Down
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.addColumn('Jobs', 'serialGroup', {
type: Sequelize.STRING(255),
allowNull: true,
defaultValue: null
});

await queryInterface.addIndex('Jobs', ['serialGroup', 'status'], {
name: 'jobs_serial_group_status_idx'
});
},

async down(queryInterface, Sequelize) {
await queryInterface.removeIndex('Jobs', 'jobs_serial_group_status_idx');
await queryInterface.removeColumn('Jobs', 'serialGroup');
}
};
5 changes: 5 additions & 0 deletions create-a-container/models/job.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ module.exports = (sequelize, DataTypes) => {
type: DataTypes.ENUM('pending','running','success','failure','cancelled'),
allowNull: false,
defaultValue: 'pending'
},
serialGroup: {
type: DataTypes.STRING(255),
allowNull: true,
defaultValue: null
}
}, {
sequelize,
Expand Down
21 changes: 20 additions & 1 deletion create-a-container/routers/containers.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const dns = require('dns').promises;
const { Container, Service, HTTPService, TransportService, DnsService, Node, Site, ExternalDomain, Job, Sequelize, sequelize } = require('../models');
const { requireAuth } = require('../middlewares');
const ProxmoxApi = require('../utils/proxmox-api');
const { queueTraefikConfigJob } = require('../utils/traefik');
const serviceMap = require('../data/services.json');

/**
Expand Down Expand Up @@ -338,6 +339,7 @@ router.post('/', async (req, res) => {
}, { transaction: t });

// Create services if provided (validate within transaction)
let hasTransportServices = false;
if (services && typeof services === 'object') {
for (const key in services) {
const service = services[key];
Expand All @@ -358,6 +360,7 @@ router.post('/', async (req, res) => {
// tcp or udp
serviceType = 'transport';
protocol = type;
hasTransportServices = true;
}

const serviceData = {
Expand Down Expand Up @@ -422,6 +425,11 @@ router.post('/', async (req, res) => {
// Commit the transaction
await t.commit();

// Queue Traefik config job if transport services were created
if (hasTransportServices) {
await queueTraefikConfigJob(siteId, req.session.user);
}

await req.flash('success', `Container "${hostname}" is being created. Check back shortly for status updates.`);
return res.redirect(`/jobs/${job.id}`);
} catch (err) {
Expand Down Expand Up @@ -511,6 +519,7 @@ router.put('/:id', requireAuth, async (req, res) => {

// Wrap all database operations in a transaction
let restartJob = null;
let transportServicesChanged = false;
await sequelize.transaction(async (t) => {
// Update environment variables and entrypoint if changed
if (envChanged || entrypointChanged) {
Expand Down Expand Up @@ -538,9 +547,13 @@ router.put('/:id', requireAuth, async (req, res) => {
// Phase 1: Delete marked services
for (const key in services) {
const service = services[key];
const { id, deleted } = service;
const { id, deleted, type } = service;

if (deleted === 'true' && id) {
// Check if this is a transport service being deleted
if (type === 'tcp' || type === 'udp') {
transportServicesChanged = true;
}
await Service.destroy({
where: { id: parseInt(id, 10), containerId: container.id },
transaction: t
Expand Down Expand Up @@ -571,6 +584,7 @@ router.put('/:id', requireAuth, async (req, res) => {
// tcp or udp
serviceType = 'transport';
protocol = type;
transportServicesChanged = true;
}

const serviceData = {
Expand Down Expand Up @@ -623,6 +637,11 @@ router.put('/:id', requireAuth, async (req, res) => {
}
});

// Queue Traefik config job if transport services changed
if (transportServicesChanged) {
await queueTraefikConfigJob(siteId, req.session.user);
}

if (restartJob) {
await req.flash('success', 'Container configuration updated. Restarting container...');
return res.redirect(`/jobs/${restartJob.id}`);
Expand Down
10 changes: 10 additions & 0 deletions create-a-container/routers/external-domains.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { ExternalDomain, Site, Sequelize } = require('../models');
const { requireAuth, requireAdmin } = require('../middlewares');
const path = require('path');
const { run } = require('../utils');
const { queueTraefikConfigJob } = require('../utils/traefik');
const axios = require('axios');

// All routes require authentication
Expand Down Expand Up @@ -168,6 +169,9 @@ router.post('/', requireAdmin, async (req, res) => {
await req.flash('success', `External domain ${name} created successfully (certificate provisioning skipped - missing required fields)`);
}

// Queue Traefik config regeneration job
await queueTraefikConfigJob(siteId, req.session?.user?.uid);

return res.redirect(`/sites/${siteId}/external-domains`);
} catch (error) {
console.error('Error creating external domain:', error);
Expand Down Expand Up @@ -213,6 +217,9 @@ router.put('/:id', requireAdmin, async (req, res) => {

await externalDomain.update(updateData);

// Queue Traefik config regeneration job
await queueTraefikConfigJob(siteId, req.session?.user?.uid);

await req.flash('success', `External domain ${name} updated successfully`);
return res.redirect(`/sites/${siteId}/external-domains`);
} catch (error) {
Expand Down Expand Up @@ -246,6 +253,9 @@ router.delete('/:id', requireAdmin, async (req, res) => {
const domainName = externalDomain.name;
await externalDomain.destroy();

// Queue Traefik config regeneration job
await queueTraefikConfigJob(siteId, req.session?.user?.uid);

await req.flash('success', `External domain ${domainName} deleted successfully`);
return res.redirect(`/sites/${siteId}/external-domains`);
} catch (error) {
Expand Down
8 changes: 6 additions & 2 deletions create-a-container/routers/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ router.get('/', async (req, res) => {
'push_notification_url',
'push_notification_enabled',
'smtp_url',
'smtp_noreply_address'
'smtp_noreply_address',
'base_url'
]);

res.render('settings/index', {
pushNotificationUrl: settings.push_notification_url || '',
pushNotificationEnabled: settings.push_notification_enabled === 'true',
smtpUrl: settings.smtp_url || '',
smtpNoreplyAddress: settings.smtp_noreply_address || '',
baseUrl: settings.base_url || '',
req
});
});
Expand All @@ -28,7 +30,8 @@ router.post('/', async (req, res) => {
push_notification_url,
push_notification_enabled,
smtp_url,
smtp_noreply_address
smtp_noreply_address,
base_url
} = req.body;

const enabled = push_notification_enabled === 'on';
Expand All @@ -42,6 +45,7 @@ router.post('/', async (req, res) => {
await Setting.set('push_notification_enabled', enabled ? 'true' : 'false');
await Setting.set('smtp_url', smtp_url || '');
await Setting.set('smtp_noreply_address', smtp_noreply_address || '');
await Setting.set('base_url', base_url || '');

await req.flash('success', 'Settings saved successfully');
return res.redirect('/settings');
Expand Down
Loading
Loading