diff --git a/create-a-container/migrations/20260506000000-transport-service-site-scoped-port-constraint.js b/create-a-container/migrations/20260506000000-transport-service-site-scoped-port-constraint.js new file mode 100644 index 00000000..5174ae5e --- /dev/null +++ b/create-a-container/migrations/20260506000000-transport-service-site-scoped-port-constraint.js @@ -0,0 +1,63 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + // 1. Add siteId column (nullable initially for backfill) + await queryInterface.addColumn('TransportServices', 'siteId', { + type: Sequelize.INTEGER, + allowNull: true, + references: { model: 'Sites', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }); + + // 2. Backfill siteId via Service -> Container + await queryInterface.sequelize.query(` + UPDATE "TransportServices" ts + SET "siteId" = c."siteId" + FROM "Services" s + JOIN "Containers" c ON s."containerId" = c.id + WHERE ts."serviceId" = s.id + `); + + // 3. Make siteId NOT NULL after backfill + await queryInterface.changeColumn('TransportServices', 'siteId', { + type: Sequelize.INTEGER, + allowNull: false + }); + + // 4. Remove duplicate FK constraints introduced by changeColumn (Sequelize bug) + const fks = await queryInterface.getForeignKeyReferencesForTable('TransportServices'); + const seen = new Set(); + for (const fk of fks) { + const key = `${fk.columnName}->${fk.referencedTableName}.${fk.referencedColumnName}`; + if (seen.has(key)) { + await queryInterface.removeConstraint('TransportServices', fk.constraintName); + } + seen.add(key); + } + + // 5. Drop the global (protocol, externalPort) unique index + await queryInterface.removeIndex('TransportServices', 'transport_services_unique_protocol_port'); + + // 6. Add site-scoped (siteId, protocol, externalPort) unique index + await queryInterface.addIndex('TransportServices', ['siteId', 'protocol', 'externalPort'], { + unique: true, + name: 'transport_services_unique_site_protocol_port' + }); + }, + + async down(queryInterface, Sequelize) { + // Remove site-scoped index + await queryInterface.removeIndex('TransportServices', 'transport_services_unique_site_protocol_port'); + + // Restore global (protocol, externalPort) unique index + await queryInterface.addIndex('TransportServices', ['protocol', 'externalPort'], { + unique: true, + name: 'transport_services_unique_protocol_port' + }); + + // Remove siteId column + await queryInterface.removeColumn('TransportServices', 'siteId'); + } +}; diff --git a/create-a-container/models/transport-service.js b/create-a-container/models/transport-service.js index 10397805..2c5d6bab 100644 --- a/create-a-container/models/transport-service.js +++ b/create-a-container/models/transport-service.js @@ -5,13 +5,16 @@ module.exports = (sequelize, DataTypes) => { class TransportService extends Model { static associate(models) { TransportService.belongsTo(models.Service, { foreignKey: 'serviceId', as: 'service' }); + TransportService.belongsTo(models.Site, { foreignKey: 'siteId', as: 'site' }); } - // Find the next available external port for the given protocol in the specified range - static async nextAvailablePortInRange(protocol, minPort, maxPort, transaction = null) { + // Find the next available external port for the given protocol in the specified range, + // scoped to a specific site. + static async nextAvailablePortInRange(protocol, minPort, maxPort, siteId, transaction = null) { const queryOptions = { where: { protocol: protocol, + siteId: siteId, externalPort: { [sequelize.Sequelize.Op.between]: [minPort, maxPort] } @@ -19,7 +22,7 @@ module.exports = (sequelize, DataTypes) => { attributes: ['externalPort'], order: [['externalPort', 'ASC']] }; - + if (transaction) { queryOptions.transaction = transaction; queryOptions.lock = sequelize.Sequelize.Transaction.LOCK.UPDATE; @@ -49,6 +52,14 @@ module.exports = (sequelize, DataTypes) => { key: 'id' } }, + siteId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'Sites', + key: 'id' + } + }, protocol: { type: DataTypes.ENUM('tcp', 'udp'), allowNull: false, @@ -74,9 +85,9 @@ module.exports = (sequelize, DataTypes) => { modelName: 'TransportService', indexes: [ { - name: 'transport_services_unique_protocol_port', + name: 'transport_services_unique_site_protocol_port', unique: true, - fields: ['protocol', 'externalPort'] + fields: ['siteId', 'protocol', 'externalPort'] } ] }); diff --git a/create-a-container/routers/containers.js b/create-a-container/routers/containers.js index 9ace32f5..a113c49c 100644 --- a/create-a-container/routers/containers.js +++ b/create-a-container/routers/containers.js @@ -423,10 +423,11 @@ router.post('/', async (req, res) => { }, { transaction: t }); } else { const minPort = 2000; - const maxPort = 65565; - const externalPort = await TransportService.nextAvailablePortInRange(protocol, minPort, maxPort, t); + const maxPort = 65535; + const externalPort = await TransportService.nextAvailablePortInRange(protocol, minPort, maxPort, siteId, t); await TransportService.create({ serviceId: createdService.id, + siteId, protocol: protocol, externalPort }, { transaction: t }); @@ -615,8 +616,8 @@ router.put('/:id', requireAuth, async (req, res) => { } else if (serviceType === 'dns') { await DnsService.create({ serviceId: createdService.id, recordType: 'SRV', dnsName }, { transaction: t }); } else { - const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65565); - await TransportService.create({ serviceId: createdService.id, protocol, externalPort }, { transaction: t }); + const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65535, siteId, t); + await TransportService.create({ serviceId: createdService.id, siteId, protocol, externalPort }, { transaction: t }); } }