diff --git a/create-a-container/bin/create-container.js b/create-a-container/bin/create-container.js index af221df9..289234ec 100755 --- a/create-a-container/bin/create-container.js +++ b/create-a-container/bin/create-container.js @@ -28,7 +28,7 @@ const path = require('path'); // Load models from parent directory const db = require(path.join(__dirname, '..', 'models')); -const { Container, Node, Site, Service, HTTPService, ExternalDomain, Setting, ResourceRequest } = db; +const { Container, Node, Site, Service, HTTPService, ExternalDomain, Setting, ResourceRequest, Job } = db; // Load utilities const { parseArgs } = require(path.join(__dirname, '..', 'utils', 'cli')); @@ -230,6 +230,15 @@ async function main() { console.log('Allocating VMID from Proxmox...'); const vmid = await client.nextId(); console.log(`Allocated VMID: ${vmid}`); + + // Check if our job was cancelled (e.g., container was deleted while we were starting) + if (container.creationJobId) { + const job = await Job.findByPk(container.creationJobId); + if (job && job.status === 'cancelled') { + console.error('Job was cancelled — aborting container creation.'); + process.exit(1); + } + } if (isDocker) { // Docker image: pull from OCI registry, then create container @@ -341,6 +350,21 @@ async function main() { console.log('Container configured'); } + // Verify the DB record still exists before proceeding. + // If another workflow deleted it while we were creating on Proxmox, + // destroy the orphan and abort to prevent duplicate containers. + const freshContainer = await Container.findByPk(container.id); + if (!freshContainer) { + console.error(`Container record ${container.id} was deleted during creation. Destroying orphaned Proxmox container ${vmid}...`); + try { + await client.deleteContainer(node.name, vmid, true, true); + console.log(`Orphaned Proxmox container ${vmid} destroyed successfully.`); + } catch (cleanupErr) { + console.error(`Failed to destroy orphaned container ${vmid}: ${cleanupErr.message}`); + } + process.exit(1); + } + // Snapshot the template's env/entrypoint onto the container record now, as // if the user had supplied them (user-supplied values still win). Templates // are mutable Docker refs we can't re-query on a later reconfigure, so we diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index f962b776..f20edc95 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -413,6 +413,18 @@ router.post( } = req.body || {}; if (!hostname || !hostname.trim()) throw new ApiError(400, 'invalid_request', 'hostname is required'); + const existingContainer = await Container.findOne({ + where: { siteId: site.id, hostname }, + transaction: t, + lock: t.LOCK.UPDATE, + }); + if (existingContainer) { + throw new ApiError( + 409, + 'duplicate_hostname', + `Container with hostname "${hostname}" already exists in this site`, + ); + } // Contract: `collaborators`, when present, is an array of usernames. // Validated here so the insert below can trust its shape. @@ -748,6 +760,12 @@ router.delete( ); const node = container.node; let dnsWarnings = []; + if (container.creationJobId) { + const creationJob = await Job.findByPk(container.creationJobId); + if (creationJob && (creationJob.status === 'pending' || creationJob.status === 'running')) { + await creationJob.update({ status: 'cancelled' }); + } + } const httpServices = (container.services || []) .filter((s) => s.httpService?.externalDomain) .map((s) => ({