From 92abad96a29a6bd0c850064ea37b8437e3c848dc Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 30 Dec 2025 14:54:02 -0500 Subject: [PATCH] Add Templates feature for container creation - Create Template model with displayName, proxmoxTemplateName, and siteId - Add migration to create Templates table - Create templates router with full CRUD operations (admin only) - Add getAvailableProxmoxTemplates helper function to query nodes - Create templates index and form views - Update header to show Templates link for admins - Update container creation to use Template model instead of querying Proxmox directly - Mount templates router in sites router --- .../20251230144745-create-templates.js | 47 +++++ create-a-container/models/site.js | 6 + create-a-container/models/template.js | 42 ++++ create-a-container/routers/containers.js | 83 ++++---- create-a-container/routers/sites.js | 2 + create-a-container/routers/templates.js | 181 ++++++++++++++++++ create-a-container/utils/index.js | 50 ++++- create-a-container/views/containers/form.ejs | 6 +- create-a-container/views/layouts/header.ejs | 4 + create-a-container/views/templates/form.ejs | 76 ++++++++ create-a-container/views/templates/index.ejs | 57 ++++++ 11 files changed, 503 insertions(+), 51 deletions(-) create mode 100644 create-a-container/migrations/20251230144745-create-templates.js create mode 100644 create-a-container/models/template.js create mode 100644 create-a-container/routers/templates.js create mode 100644 create-a-container/views/templates/form.ejs create mode 100644 create-a-container/views/templates/index.ejs diff --git a/create-a-container/migrations/20251230144745-create-templates.js b/create-a-container/migrations/20251230144745-create-templates.js new file mode 100644 index 00000000..b169a68a --- /dev/null +++ b/create-a-container/migrations/20251230144745-create-templates.js @@ -0,0 +1,47 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('Templates', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + displayName: { + type: Sequelize.STRING, + allowNull: false, + comment: 'Human-readable template name' + }, + proxmoxTemplateName: { + type: Sequelize.STRING, + allowNull: false, + comment: 'Proxmox template identifier (e.g., local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst)' + }, + siteId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'Sites', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false + } + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('Templates'); + } +}; diff --git a/create-a-container/models/site.js b/create-a-container/models/site.js index 0296a904..3a8bb9bc 100644 --- a/create-a-container/models/site.js +++ b/create-a-container/models/site.js @@ -21,6 +21,12 @@ module.exports = (sequelize, DataTypes) => { foreignKey: 'siteId', as: 'externalDomains' }); + + // A Site has many Templates + Site.hasMany(models.Template, { + foreignKey: 'siteId', + as: 'templates' + }); } } Site.init({ diff --git a/create-a-container/models/template.js b/create-a-container/models/template.js new file mode 100644 index 00000000..ff569be9 --- /dev/null +++ b/create-a-container/models/template.js @@ -0,0 +1,42 @@ +'use strict'; +const { + Model +} = require('sequelize'); +module.exports = (sequelize, DataTypes) => { + class Template extends Model { + /** + * Helper method for defining associations. + * This method is not a part of Sequelize lifecycle. + * The `models/index` file will call this method automatically. + */ + static associate(models) { + // A Template belongs to a Site + Template.belongsTo(models.Site, { + foreignKey: 'siteId', + as: 'site' + }); + } + } + Template.init({ + displayName: { + type: DataTypes.STRING, + allowNull: false + }, + proxmoxTemplateName: { + type: DataTypes.STRING, + allowNull: false + }, + siteId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'Sites', + key: 'id' + } + } + }, { + sequelize, + modelName: 'Template', + }); + return Template; +}; diff --git a/create-a-container/routers/containers.js b/create-a-container/routers/containers.js index 2017470f..495f3072 100644 --- a/create-a-container/routers/containers.js +++ b/create-a-container/routers/containers.js @@ -2,14 +2,13 @@ const express = require('express'); const router = express.Router({ mergeParams: true }); // Enable access to :siteId param const https = require('https'); const dns = require('dns').promises; -const { Container, Service, HTTPService, TransportService, DnsService, Node, Site, ExternalDomain, Sequelize, sequelize } = require('../models'); +const { Container, Service, HTTPService, TransportService, DnsService, Node, Site, ExternalDomain, Template, Sequelize, sequelize } = require('../models'); const { requireAuth } = require('../middlewares'); const ProxmoxApi = require('../utils/proxmox-api'); const serviceMap = require('../data/services.json'); // GET /sites/:siteId/containers/new - Display form for creating a new container router.get('/new', requireAuth, async (req, res) => { - // verify site exists const siteId = parseInt(req.params.siteId, 10); const site = await Site.findByPk(siteId); if (!site) { @@ -17,48 +16,11 @@ router.get('/new', requireAuth, async (req, res) => { return res.redirect('/sites'); } - // Get valid container templates from all nodes in this site - const templates = []; - const nodes = await Node.findAll({ - where: { - [Sequelize.Op.and]: { - siteId, - apiUrl: { [Sequelize.Op.ne]: null }, - tokenId: { [Sequelize.Op.ne]: null }, - secret: { [Sequelize.Op.ne]: null } - } - }, + const templates = await Template.findAll({ + where: { siteId }, + order: [['displayName', 'ASC']] }); - // TODO: use datamodel backed templates instead of querying Proxmox here - for (const node of nodes) { - const client = new ProxmoxApi(node.apiUrl, node.tokenId, node.secret, { - httpsAgent: new https.Agent({ - rejectUnauthorized: node.tlsVerify !== false - }) - }); - - // Get datastores for this node - const datastores = await client.datastores(node.name, 'vztmpl', true); - - // Iterate over each datastore and get its contents - for (const datastore of datastores) { - const contents = await client.storageContents(node.name, datastore.storage, 'vztmpl'); - - // Add templates from this storage - for (const item of contents) { - templates.push({ - volid: item.volid, - name: item.volid.split('/').pop(), // Extract filename from volid - size: item.size, - node: node.name, - storage: datastore.storage - }); - } - } - } - - // Get external domains for this site const externalDomains = await ExternalDomain.findAll({ where: { siteId }, order: [['name', 'ASC']] @@ -68,7 +30,7 @@ router.get('/new', requireAuth, async (req, res) => { site, templates, externalDomains, - container: undefined, // Not editing + container: undefined, req }); }); @@ -217,10 +179,37 @@ router.post('/', async (req, res) => { // TODO: build the container async in a Job try { - // clone the template - const { hostname, template, services } = req.body; - const [ nodeName, ostemplate ] = template.split(','); - const node = await Node.findOne({ where: { name: nodeName, siteId } }); + const { hostname, templateId, services } = req.body; + + const template = await Template.findOne({ + where: { id: templateId, siteId } + }); + + if (!template) { + req.flash('error', 'Template not found'); + return res.redirect(`/sites/${siteId}/containers/new`); + } + + const ostemplate = template.proxmoxTemplateName; + const [ storage ] = ostemplate.split(':'); + + const nodes = await Node.findAll({ + where: { + [Sequelize.Op.and]: { + siteId, + apiUrl: { [Sequelize.Op.ne]: null }, + tokenId: { [Sequelize.Op.ne]: null }, + secret: { [Sequelize.Op.ne]: null } + } + }, + }); + + if (nodes.length === 0) { + req.flash('error', 'No configured nodes available for this site'); + return res.redirect(`/sites/${siteId}/containers/new`); + } + + const node = nodes[0]; const client = new ProxmoxApi(node.apiUrl, node.tokenId, node.secret, { httpsAgent: new https.Agent({ rejectUnauthorized: node.tlsVerify !== false diff --git a/create-a-container/routers/sites.js b/create-a-container/routers/sites.js index e0137267..80b10f4d 100644 --- a/create-a-container/routers/sites.js +++ b/create-a-container/routers/sites.js @@ -191,9 +191,11 @@ router.use('/:siteId', setCurrentSite); const nodesRouter = require('./nodes'); const containersRouter = require('./containers'); const externalDomainsRouter = require('./external-domains'); +const templatesRouter = require('./templates'); router.use('/:siteId/nodes', nodesRouter); router.use('/:siteId/containers', containersRouter); router.use('/:siteId/external-domains', externalDomainsRouter); +router.use('/:siteId/templates', templatesRouter); // GET /sites - List all sites (available to all authenticated users) router.get('/', async (req, res) => { diff --git a/create-a-container/routers/templates.js b/create-a-container/routers/templates.js new file mode 100644 index 00000000..b0beb843 --- /dev/null +++ b/create-a-container/routers/templates.js @@ -0,0 +1,181 @@ +const express = require('express'); +const router = express.Router({ mergeParams: true }); +const { Template, Site } = require('../models'); +const { requireAuth, requireAdmin } = require('../middlewares'); +const { getAvailableProxmoxTemplates } = require('../utils'); + +router.use(requireAuth); + +// GET /sites/:siteId/templates - List all templates for this site +router.get('/', requireAdmin, async (req, res) => { + const siteId = parseInt(req.params.siteId, 10); + + const site = await Site.findByPk(siteId); + if (!site) { + req.flash('error', 'Site not found'); + return res.redirect('/sites'); + } + + const templates = await Template.findAll({ + where: { siteId }, + order: [['displayName', 'ASC']] + }); + + return res.render('templates/index', { + templates, + site, + req + }); +}); + +// GET /sites/:siteId/templates/new - Display form for creating a new template +router.get('/new', requireAdmin, async (req, res) => { + const siteId = parseInt(req.params.siteId, 10); + + const site = await Site.findByPk(siteId); + if (!site) { + req.flash('error', 'Site not found'); + return res.redirect('/sites'); + } + + const availableTemplates = await getAvailableProxmoxTemplates(siteId); + + return res.render('templates/form', { + template: null, + availableTemplates, + site, + isEdit: false, + req + }); +}); + +// GET /sites/:siteId/templates/:id/edit - Display form for editing a template +router.get('/:id/edit', requireAdmin, async (req, res) => { + const siteId = parseInt(req.params.siteId, 10); + const templateId = parseInt(req.params.id, 10); + + const site = await Site.findByPk(siteId); + if (!site) { + req.flash('error', 'Site not found'); + return res.redirect('/sites'); + } + + const template = await Template.findOne({ + where: { id: templateId, siteId } + }); + + if (!template) { + req.flash('error', 'Template not found'); + return res.redirect(`/sites/${siteId}/templates`); + } + + const availableTemplates = await getAvailableProxmoxTemplates(siteId); + + return res.render('templates/form', { + template, + availableTemplates, + site, + isEdit: true, + req + }); +}); + +// POST /sites/:siteId/templates - Create a new template +router.post('/', requireAdmin, async (req, res) => { + const siteId = parseInt(req.params.siteId, 10); + + const site = await Site.findByPk(siteId); + if (!site) { + req.flash('error', 'Site not found'); + return res.redirect('/sites'); + } + + try { + const { displayName, proxmoxTemplateName } = req.body; + + await Template.create({ + displayName, + proxmoxTemplateName, + siteId + }); + + req.flash('success', `Template ${displayName} created successfully`); + return res.redirect(`/sites/${siteId}/templates`); + } catch (error) { + console.error('Error creating template:', error); + req.flash('error', 'Failed to create template: ' + error.message); + return res.redirect(`/sites/${siteId}/templates/new`); + } +}); + +// PUT /sites/:siteId/templates/:id - Update an existing template +router.put('/:id', requireAdmin, async (req, res) => { + const siteId = parseInt(req.params.siteId, 10); + const templateId = parseInt(req.params.id, 10); + + try { + const site = await Site.findByPk(siteId); + if (!site) { + req.flash('error', 'Site not found'); + return res.redirect('/sites'); + } + + const template = await Template.findOne({ + where: { id: templateId, siteId } + }); + + if (!template) { + req.flash('error', 'Template not found'); + return res.redirect(`/sites/${siteId}/templates`); + } + + const { displayName, proxmoxTemplateName } = req.body; + + await template.update({ + displayName, + proxmoxTemplateName + }); + + req.flash('success', `Template ${displayName} updated successfully`); + return res.redirect(`/sites/${siteId}/templates`); + } catch (error) { + console.error('Error updating template:', error); + req.flash('error', 'Failed to update template: ' + error.message); + return res.redirect(`/sites/${siteId}/templates/${templateId}/edit`); + } +}); + +// DELETE /sites/:siteId/templates/:id - Delete a template +router.delete('/:id', requireAdmin, async (req, res) => { + const siteId = parseInt(req.params.siteId, 10); + const templateId = parseInt(req.params.id, 10); + + try { + const site = await Site.findByPk(siteId); + if (!site) { + req.flash('error', 'Site not found'); + return res.redirect('/sites'); + } + + const template = await Template.findOne({ + where: { id: templateId, siteId } + }); + + if (!template) { + req.flash('error', 'Template not found'); + return res.redirect(`/sites/${siteId}/templates`); + } + + const templateName = template.displayName; + await template.destroy(); + + req.flash('success', `Template ${templateName} deleted successfully`); + return res.redirect(`/sites/${siteId}/templates`); + } catch (error) { + console.error('Error deleting template:', error); + req.flash('error', 'Failed to delete template: ' + error.message); + return res.redirect(`/sites/${siteId}/templates`); + } +}); + +module.exports = router; diff --git a/create-a-container/utils/index.js b/create-a-container/utils/index.js index 1283b26f..343bfcb3 100644 --- a/create-a-container/utils/index.js +++ b/create-a-container/utils/index.js @@ -1,5 +1,7 @@ const { spawn } = require('child_process'); +const https = require('https'); const ProxmoxApi = require('./proxmox-api'); +const { Node, Sequelize } = require('../models'); function run(cmd, args, opts) { return new Promise((resolve, reject) => { @@ -42,8 +44,54 @@ function isSafeRelativeUrl(url) { !url.includes('%2F%2E%2E%2F'); // basic check against encoded path traversal } +/** + * Get available Proxmox templates from all configured nodes for a site + * @param {number} siteId - The site ID to query nodes for + * @returns {Promise} - Array of template objects with volid, name, size, node, storage + */ +async function getAvailableProxmoxTemplates(siteId) { + const templates = []; + const nodes = await Node.findAll({ + where: { + [Sequelize.Op.and]: { + siteId, + apiUrl: { [Sequelize.Op.ne]: null }, + tokenId: { [Sequelize.Op.ne]: null }, + secret: { [Sequelize.Op.ne]: null } + } + }, + }); + + for (const node of nodes) { + const client = new ProxmoxApi(node.apiUrl, node.tokenId, node.secret, { + httpsAgent: new https.Agent({ + rejectUnauthorized: node.tlsVerify !== false + }) + }); + + const datastores = await client.datastores(node.name, 'vztmpl', true); + + for (const datastore of datastores) { + const contents = await client.storageContents(node.name, datastore.storage, 'vztmpl'); + + for (const item of contents) { + templates.push({ + volid: item.volid, + name: item.volid.split('/').pop(), + size: item.size, + node: node.name, + storage: datastore.storage + }); + } + } + } + + return templates; +} + module.exports = { ProxmoxApi, run, - isSafeRelativeUrl + isSafeRelativeUrl, + getAvailableProxmoxTemplates }; diff --git a/create-a-container/views/containers/form.ejs b/create-a-container/views/containers/form.ejs index c8de5b76..20788cec 100644 --- a/create-a-container/views/containers/form.ejs +++ b/create-a-container/views/containers/form.ejs @@ -36,12 +36,12 @@ const breadcrumbLabel = isEdit ? 'Edit' : 'New'; <% if (isEdit) { %> <% } else { %> - <% if (typeof templates !== 'undefined' && templates && templates.length > 0) { %> <% templates.forEach(template => { %> - <% }) %> <% } else { %> diff --git a/create-a-container/views/layouts/header.ejs b/create-a-container/views/layouts/header.ejs index 756cfc5d..a7d75d03 100644 --- a/create-a-container/views/layouts/header.ejs +++ b/create-a-container/views/layouts/header.ejs @@ -26,6 +26,7 @@ const containersActive = hasCurrentSite && path.includes(`/sites/${currentSite}/containers`); const nodesActive = hasCurrentSite && path.includes(`/sites/${currentSite}/nodes`); const externalDomainsActive = hasCurrentSite && path.includes(`/sites/${currentSite}/external-domains`); + const templatesActive = hasCurrentSite && path.includes(`/sites/${currentSite}/templates`); %>
  • External Domains
  • +
  • + Templates +
  • <% } %> <% } %> diff --git a/create-a-container/views/templates/form.ejs b/create-a-container/views/templates/form.ejs new file mode 100644 index 00000000..bd546c17 --- /dev/null +++ b/create-a-container/views/templates/form.ejs @@ -0,0 +1,76 @@ +<%- include('../layouts/header', { + title: (isEdit ? 'Edit Template' : 'New Template') + ' - MIE', + breadcrumbs: [ + { label: 'Sites', url: '/sites' }, + { label: site.name, url: `/sites/${site.id}/templates` }, + { label: isEdit ? 'Edit' : 'New', url: '#' } + ], + colWidth: 'col-lg-8', + req +}) %> + +
    +
    +

    <%= isEdit ? 'Edit Template' : 'Create New Template' %>

    + +
    + <% if (isEdit) { %> + + <% } %> + +
    + + +
    Human-readable name for this template
    +
    + +
    + + +
    + Select the Proxmox template from available nodes. + <% if (!availableTemplates || availableTemplates.length === 0) { %> + No templates found. Please ensure nodes are configured with templates. + <% } %> +
    +
    + +
    + + Cancel +
    +
    +
    + +
    + +<%- include('../layouts/footer') %> diff --git a/create-a-container/views/templates/index.ejs b/create-a-container/views/templates/index.ejs new file mode 100644 index 00000000..c85704a7 --- /dev/null +++ b/create-a-container/views/templates/index.ejs @@ -0,0 +1,57 @@ +<%- include('../layouts/header', { + title: 'Templates - MIE', + breadcrumbs: [ + { label: 'Sites', url: '/sites' }, + { label: site.name, url: `/sites/${site.id}/templates` } + ], + req +}) %> + +
    +
    +
    +

    Templates for <%= site.name %>

    + New Template +
    + +
    + + + + + + + + + + <% if (templates && templates.length) { %> + <% templates.forEach(template => { %> + + + + + + <% }) %> + <% } else { %> + + + + <% } %> + +
    Display NameProxmox TemplateActions
    <%= template.displayName %><%= template.proxmoxTemplateName %> + Edit +
    + + +
    +
    + No templates found. Click "New Template" to create your first one. +
    +
    +
    + +
    + +<%- include('../layouts/footer') %>