diff --git a/create-a-container/bin/create-container.js b/create-a-container/bin/create-container.js index 9266b8bc..f54539ba 100755 --- a/create-a-container/bin/create-container.js +++ b/create-a-container/bin/create-container.js @@ -365,6 +365,28 @@ async function main() { console.log('Environment/entrypoint configuration applied'); } + // Attach NVIDIA hookscript when GPU passthrough is requested + if (container.nvidiaRequested) { + const hookscriptVolid = 'local:snippets/nvidia'; + console.log(`NVIDIA requested — attaching hookscript ${hookscriptVolid}...`); + + // Check if the hookscript file exists on the node + try { + const snippets = await client.storageContents(node.name, 'local', 'snippets'); + const hookExists = snippets.some(item => item.volid === hookscriptVolid); + if (!hookExists) { + console.warn('⚠️ WARNING: nvidia-container-toolkit hookscript not found at local:snippets/nvidia.'); + console.warn(' NVIDIA GPU passthrough may not function. See admin docs for setup instructions.'); + } + } catch (snippetErr) { + console.warn('⚠️ WARNING: Could not verify nvidia hookscript availability:', snippetErr.message); + console.warn(' NVIDIA GPU passthrough may not function. See admin docs for setup instructions.'); + } + + await client.updateLxcConfig(node.name, vmid, { hookscript: hookscriptVolid }); + console.log('NVIDIA hookscript attached'); + } + // Setup ACL for container owner await setupContainerAcl(client, node.name, vmid, container.username); diff --git a/create-a-container/migrations/20260323000001-add-node-nvidia-available.js b/create-a-container/migrations/20260323000001-add-node-nvidia-available.js new file mode 100644 index 00000000..b88b0de8 --- /dev/null +++ b/create-a-container/migrations/20260323000001-add-node-nvidia-available.js @@ -0,0 +1,16 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('Nodes', 'nvidiaAvailable', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('Nodes', 'nvidiaAvailable'); + } +}; diff --git a/create-a-container/migrations/20260323000002-add-container-nvidia-requested.js b/create-a-container/migrations/20260323000002-add-container-nvidia-requested.js new file mode 100644 index 00000000..531b1349 --- /dev/null +++ b/create-a-container/migrations/20260323000002-add-container-nvidia-requested.js @@ -0,0 +1,16 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('Containers', 'nvidiaRequested', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('Containers', 'nvidiaRequested'); + } +}; diff --git a/create-a-container/models/container.js b/create-a-container/models/container.js index 185a27cd..baacf884 100644 --- a/create-a-container/models/container.js +++ b/create-a-container/models/container.js @@ -126,6 +126,11 @@ module.exports = (sequelize, DataTypes) => { allowNull: false, defaultValue: 'N' }, + nvidiaRequested: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, environmentVars: { type: DataTypes.TEXT, allowNull: true, diff --git a/create-a-container/models/node.js b/create-a-container/models/node.js index c7469070..6cdd991e 100644 --- a/create-a-container/models/node.js +++ b/create-a-container/models/node.js @@ -95,6 +95,11 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING(255), allowNull: false, defaultValue: 'vmbr0' + }, + nvidiaAvailable: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false } }, { sequelize, diff --git a/create-a-container/openapi.yaml b/create-a-container/openapi.yaml index f6c45d6c..273c0120 100644 --- a/create-a-container/openapi.yaml +++ b/create-a-container/openapi.yaml @@ -307,6 +307,9 @@ paths: type: array items: $ref: '#/components/schemas/ExternalDomain' + nvidiaAvailable: + type: boolean + description: Whether any node in the site has NVIDIA GPU support '401': $ref: '#/components/responses/Unauthorized' '404': @@ -668,6 +671,9 @@ components: createdAt: type: string format: date-time + nvidiaRequested: + type: boolean + description: Whether NVIDIA GPU passthrough was requested ContainerCreateRequest: type: object @@ -714,6 +720,13 @@ components: type: string nullable: true description: Override the container entrypoint command + nvidiaRequested: + type: boolean + default: false + description: | + Request NVIDIA GPU passthrough. Requires at least one NVIDIA-capable node in the site. + When true, `NVIDIA_VISIBLE_DEVICES` and `NVIDIA_DRIVER_CAPABILITIES` environment + variables are set automatically (unless explicitly provided in `environmentVars`). ServiceDefinition: type: object diff --git a/create-a-container/routers/containers.js b/create-a-container/routers/containers.js index e2eae0fd..bcfa9db1 100644 --- a/create-a-container/routers/containers.js +++ b/create-a-container/routers/containers.js @@ -114,10 +114,16 @@ router.get('/new', requireAuth, async (req, res) => { // Get all external domains: default domains for this site first (by id), then others (by id) const externalDomains = await site.getSortedExternalDomains(); + // Check if any node in this site has NVIDIA available + const nvidiaAvailable = await Node.count({ + where: { siteId, nvidiaAvailable: true } + }) > 0; + if (isApi) { return res.json({ site_id: site.id, - domains: externalDomains + domains: externalDomains, + nvidiaAvailable }); } // ---------------------------- @@ -125,6 +131,7 @@ router.get('/new', requireAuth, async (req, res) => { return res.render('containers/form', { site, externalDomains, + nvidiaAvailable, container: undefined, req }); @@ -271,9 +278,14 @@ router.post('/', async (req, res) => { try { let { hostname, template, customTemplate, services, environmentVars, entrypoint, // Extract specific API fields - template_name, repository, branch + template_name, repository, branch, + // NVIDIA GPU passthrough + nvidiaRequested } = req.body; + // Normalize NVIDIA requested flag + const wantsNvidia = !!nvidiaRequested; + // --- API Payload Mapping --- if (isApi) { if (template_name && !template) { @@ -309,6 +321,18 @@ router.post('/', async (req, res) => { envVarsJson = JSON.stringify(envObj); } } + + // Inject NVIDIA environment variables when GPU passthrough is requested + if (wantsNvidia) { + const envObj = envVarsJson ? JSON.parse(envVarsJson) : {}; + if (!envObj['NVIDIA_VISIBLE_DEVICES']) { + envObj['NVIDIA_VISIBLE_DEVICES'] = 'all'; + } + if (!envObj['NVIDIA_DRIVER_CAPABILITIES']) { + envObj['NVIDIA_DRIVER_CAPABILITIES'] = 'utility compute'; + } + envVarsJson = JSON.stringify(envObj); + } // Resolve Docker image ref from either the dropdown or the custom input const imageRef = (template === 'custom') ? customTemplate?.trim() : template; @@ -317,14 +341,21 @@ router.post('/', async (req, res) => { } const templateName = normalizeDockerRef(imageRef); - const node = await Node.findOne({ - where: { - siteId, - apiUrl: { [Sequelize.Op.ne]: null }, - tokenId: { [Sequelize.Op.ne]: null }, - secret: { [Sequelize.Op.ne]: null } - } - }); + // Build node selection criteria + const nodeWhere = { + siteId, + apiUrl: { [Sequelize.Op.ne]: null }, + tokenId: { [Sequelize.Op.ne]: null }, + secret: { [Sequelize.Op.ne]: null } + }; + if (wantsNvidia) { + nodeWhere.nvidiaAvailable = true; + } + + const node = await Node.findOne({ where: nodeWhere }); + if (!node && wantsNvidia) { + throw new Error('NVIDIA requested but no NVIDIA-capable nodes are available in this site'); + } if (!node) { throw new Error('No nodes with API access available in this site'); } @@ -340,6 +371,7 @@ router.post('/', async (req, res) => { containerId: null, macAddress: null, ipv4Address: null, + nvidiaRequested: wantsNvidia, environmentVars: envVarsJson, entrypoint: entrypoint && entrypoint.trim() ? entrypoint.trim() : null }, { transaction: t }); diff --git a/create-a-container/routers/nodes.js b/create-a-container/routers/nodes.js index 189ab441..3f87fdad 100644 --- a/create-a-container/routers/nodes.js +++ b/create-a-container/routers/nodes.js @@ -117,7 +117,7 @@ router.post('/', async (req, res) => { return res.redirect('/sites'); } - const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge } = req.body; + const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } = req.body; await Node.create({ name, @@ -129,6 +129,7 @@ router.post('/', async (req, res) => { imageStorage: imageStorage || 'local', volumeStorage: volumeStorage || 'local-lvm', networkBridge: networkBridge || 'vmbr0', + nvidiaAvailable: nvidiaAvailable === 'true', siteId }); @@ -267,7 +268,7 @@ router.put('/:id', async (req, res) => { return res.redirect(`/sites/${siteId}/nodes`); } - const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge } = req.body; + const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } = req.body; const updateData = { name, @@ -277,7 +278,8 @@ router.put('/:id', async (req, res) => { tlsVerify: tlsVerify === '' || tlsVerify === null ? null : tlsVerify === 'true', imageStorage: imageStorage || 'local', volumeStorage: volumeStorage || 'local-lvm', - networkBridge: networkBridge || 'vmbr0' + networkBridge: networkBridge || 'vmbr0', + nvidiaAvailable: nvidiaAvailable === 'true' }; // Only update secret if a new value was provided diff --git a/create-a-container/views/containers/form.ejs b/create-a-container/views/containers/form.ejs index f8a1237e..2df8358b 100644 --- a/create-a-container/views/containers/form.ejs +++ b/create-a-container/views/containers/form.ejs @@ -57,6 +57,31 @@ const breadcrumbLabel = isEdit ? 'Edit' : 'New'; + <% if (!isEdit) { %> + <% + const nvidiaEnabled = typeof nvidiaAvailable !== 'undefined' && nvidiaAvailable; + const nvidiaTooltip = nvidiaEnabled + ? 'Enable NVIDIA GPU passthrough for this container' + : 'No NVIDIA-capable nodes are available in this site'; + %> +