From b4ad239ff5cb41640055334ae9137356a44352b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 14:14:00 +0000 Subject: [PATCH 1/3] Initial plan From f344da8b8b4e01189b2ea28a4aa578324ca9eb5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 14:17:59 +0000 Subject: [PATCH 2/3] fix: scope port allocation to site namespace in nextAvailablePortInRange Agent-Logs-Url: https://github.com/mieweb/opensource-server/sessions/c6615a50-b38a-469f-b541-ae051418f881 Co-authored-by: runleveldev <44057501+runleveldev@users.noreply.github.com> --- .../models/transport-service.js | 24 ++++++++++++++++--- create-a-container/routers/containers.js | 6 ++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/create-a-container/models/transport-service.js b/create-a-container/models/transport-service.js index 10397805..7d49d7c6 100644 --- a/create-a-container/models/transport-service.js +++ b/create-a-container/models/transport-service.js @@ -7,8 +7,11 @@ module.exports = (sequelize, DataTypes) => { TransportService.belongsTo(models.Service, { foreignKey: 'serviceId', as: 'service' }); } - // 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 when siteId is provided. + static async nextAvailablePortInRange(protocol, minPort, maxPort, siteId = null, transaction = null) { + const { Service, Container } = sequelize.models; + const queryOptions = { where: { protocol: protocol, @@ -19,7 +22,22 @@ module.exports = (sequelize, DataTypes) => { attributes: ['externalPort'], order: [['externalPort', 'ASC']] }; - + + if (siteId !== null) { + queryOptions.include = [{ + model: Service, + as: 'service', + attributes: [], + required: true, + include: [{ + model: Container, + attributes: [], + required: true, + where: { siteId } + }] + }]; + } + if (transaction) { queryOptions.transaction = transaction; queryOptions.lock = sequelize.Sequelize.Transaction.LOCK.UPDATE; diff --git a/create-a-container/routers/containers.js b/create-a-container/routers/containers.js index 9ace32f5..468f7a26 100644 --- a/create-a-container/routers/containers.js +++ b/create-a-container/routers/containers.js @@ -423,8 +423,8 @@ 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, protocol: protocol, @@ -615,7 +615,7 @@ 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); + const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65535, siteId, t); await TransportService.create({ serviceId: createdService.id, protocol, externalPort }, { transaction: t }); } } From 5cb4f1c05989966ddea4cf808243adf2408ced8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 14:30:40 +0000 Subject: [PATCH 3/3] fix: denormalize siteId onto TransportService with site-scoped unique port constraint Agent-Logs-Url: https://github.com/mieweb/opensource-server/sessions/755f5326-8e1f-43d8-8b0b-8426d4f6f98c Co-authored-by: runleveldev <44057501+runleveldev@users.noreply.github.com> --- ...ort-service-site-scoped-port-constraint.js | 63 +++++++++++++++++++ .../models/transport-service.js | 35 +++++------ create-a-container/routers/containers.js | 3 +- 3 files changed, 79 insertions(+), 22 deletions(-) create mode 100644 create-a-container/migrations/20260506000000-transport-service-site-scoped-port-constraint.js 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 7d49d7c6..2c5d6bab 100644 --- a/create-a-container/models/transport-service.js +++ b/create-a-container/models/transport-service.js @@ -5,16 +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, - // scoped to a specific site when siteId is provided. - static async nextAvailablePortInRange(protocol, minPort, maxPort, siteId = null, transaction = null) { - const { Service, Container } = sequelize.models; - + // 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] } @@ -23,21 +23,6 @@ module.exports = (sequelize, DataTypes) => { order: [['externalPort', 'ASC']] }; - if (siteId !== null) { - queryOptions.include = [{ - model: Service, - as: 'service', - attributes: [], - required: true, - include: [{ - model: Container, - attributes: [], - required: true, - where: { siteId } - }] - }]; - } - if (transaction) { queryOptions.transaction = transaction; queryOptions.lock = sequelize.Sequelize.Transaction.LOCK.UPDATE; @@ -67,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, @@ -92,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 468f7a26..a113c49c 100644 --- a/create-a-container/routers/containers.js +++ b/create-a-container/routers/containers.js @@ -427,6 +427,7 @@ router.post('/', async (req, res) => { const externalPort = await TransportService.nextAvailablePortInRange(protocol, minPort, maxPort, siteId, t); await TransportService.create({ serviceId: createdService.id, + siteId, protocol: protocol, externalPort }, { transaction: t }); @@ -616,7 +617,7 @@ router.put('/:id', requireAuth, async (req, res) => { await DnsService.create({ serviceId: createdService.id, recordType: 'SRV', dnsName }, { transaction: t }); } else { const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65535, siteId, t); - await TransportService.create({ serviceId: createdService.id, protocol, externalPort }, { transaction: t }); + await TransportService.create({ serviceId: createdService.id, siteId, protocol, externalPort }, { transaction: t }); } }