From 2b7786fa3d7f57dfc413981938296f6762854e22 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sat, 18 Jul 2026 23:18:32 -0700 Subject: [PATCH 01/15] Add legacy DNS aliases for new app services --- src/docker/DockerApi.ts | 48 +++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/docker/DockerApi.ts b/src/docker/DockerApi.ts index 0ac9d8c..dea1561 100644 --- a/src/docker/DockerApi.ts +++ b/src/docker/DockerApi.ts @@ -65,6 +65,38 @@ export abstract class IDockerUpdateOrders { } export type IDockerUpdateOrder = 'auto' | 'stopFirst' | 'startFirst' +export function getLegacyServiceDnsAlias( + serviceName: string, + namespace: string | undefined, + isLegacyAppName: boolean +) { + if (!namespace || isLegacyAppName) { + return undefined + } + + return `srv-${namespace}--${serviceName}` +} + +export function getServiceNetworkAttachments( + networks: string[], + legacyDnsAlias: string | undefined +) { + return networks.map((network) => { + const attachment: { + Target: string + Aliases?: string[] + } = { + Target: network, + } + + if (legacyDnsAlias) { + attachment.Aliases = [legacyDnsAlias] + } + + return attachment + }) +} + export interface CreateContainerParams { containerName?: string imageName: string @@ -1453,12 +1485,16 @@ class DockerApi { } if (networks) { - updatedData.TaskTemplate.Networks = [] - for (let i = 0; i < networks.length; i++) { - updatedData.TaskTemplate.Networks.push({ - Target: networks[i], - }) - } + const legacyDnsAlias = appObject + ? getLegacyServiceDnsAlias( + serviceName, + namespace, + !!appObject.isLegacyAppName + ) + : undefined + + updatedData.TaskTemplate.Networks = + getServiceNetworkAttachments(networks, legacyDnsAlias) } if (secrets) { From 64be9a9be11794146564462561f1068227129220 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sat, 18 Jul 2026 23:18:40 -0700 Subject: [PATCH 02/15] Reject orphaned legacy service collisions --- src/user/ServiceManager.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/user/ServiceManager.ts b/src/user/ServiceManager.ts index a6e5eb4..d1b3040 100644 --- a/src/user/ServiceManager.ts +++ b/src/user/ServiceManager.ts @@ -419,6 +419,23 @@ class ServiceManager { }) } + ensureLegacyServiceNameAvailable(appName: string) { + const legacyServiceName = this.dataStore + .getAppsDataStore() + .getServiceName(appName, true) + + return this.dockerApi + .isServiceRunningByName(legacyServiceName) + .then(function (serviceExists) { + if (serviceExists) { + throw ApiStatusCodes.createError( + ApiStatusCodes.STATUS_ERROR_ALREADY_EXIST, + `A Docker service named ${legacyServiceName} already exists and conflicts with the legacy DNS alias` + ) + } + }) + } + renameApp(oldAppName: string, newAppName: string) { Logger.d(`Renaming app: ${oldAppName}`) const self = this @@ -441,6 +458,11 @@ class ServiceManager { dataStore.getAppsDataStore().nameAllowedOrThrow(newAppName) + if (!appDef.isLegacyAppName) { + return self.ensureLegacyServiceNameAvailable(newAppName) + } + }) + .then(function () { return self.ensureNotBuilding(oldAppName) }) .then(function () { From 846d1d43bd86b68ba96bd4c3377b41ea930d5db4 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sat, 18 Jul 2026 23:19:02 -0700 Subject: [PATCH 03/15] Check legacy DNS alias before app registration --- src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts b/src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts index 2727577..1403a7c 100644 --- a/src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts +++ b/src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts @@ -40,6 +40,9 @@ export async function registerAppDefinition( // if project is not found, it will throw an error } + // Avoid creating a DNS alias that conflicts with an orphaned legacy service + await serviceManager.ensureLegacyServiceNameAvailable(appName) + // Register the app definition await dataStore .getAppsDataStore() From d1f06c1398c10592f58046e48cf1c1905c34097a Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sat, 18 Jul 2026 23:19:12 -0700 Subject: [PATCH 04/15] Test DNS alias compatibility and collisions --- tests/ServiceNamingMigration.test.ts | 67 ++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/ServiceNamingMigration.test.ts b/tests/ServiceNamingMigration.test.ts index f4896f2..291b43f 100644 --- a/tests/ServiceNamingMigration.test.ts +++ b/tests/ServiceNamingMigration.test.ts @@ -3,6 +3,10 @@ import AppsDataStore, { isNameAllowed, } from '../src/datastore/AppsDataStore' import { runDataStoreMigrations } from '../src/datastore/DataStore' +import { + getLegacyServiceDnsAlias, + getServiceNetworkAttachments, +} from '../src/docker/DockerApi' import ServiceManager from '../src/user/ServiceManager' function createConfigStore(initialData: { [key: string]: any }) { @@ -88,6 +92,69 @@ describe('service and volume naming migration', () => { expect(failedVolumes).toEqual(['data']) }) + test('adds the legacy DNS alias to every network for a new app', () => { + const alias = getLegacyServiceDnsAlias( + 'paperless-db', + 'captain', + false + ) + + expect( + getServiceNetworkAttachments( + ['captain-overlay-network', 'private-network'], + alias + ) + ).toEqual([ + { + Target: 'captain-overlay-network', + Aliases: ['srv-captain--paperless-db'], + }, + { + Target: 'private-network', + Aliases: ['srv-captain--paperless-db'], + }, + ]) + }) + + test('does not add a redundant DNS alias to legacy apps', () => { + const alias = getLegacyServiceDnsAlias( + 'srv-captain--paperless-db', + 'captain', + true + ) + + expect( + getServiceNetworkAttachments( + ['captain-overlay-network'], + alias + ) + ).toEqual([ + { + Target: 'captain-overlay-network', + }, + ]) + }) + + test('rejects a legacy DNS alias that conflicts with an orphaned service', async () => { + const serviceManager = Object.create( + ServiceManager.prototype + ) as ServiceManager + ;(serviceManager as any).dataStore = { + getAppsDataStore: () => ({ + getServiceName: () => 'srv-captain--paperless-db', + }), + } + ;(serviceManager as any).dockerApi = { + isServiceRunningByName: jest.fn().mockResolvedValue(true), + } + + await expect( + serviceManager.ensureLegacyServiceNameAvailable('paperless-db') + ).rejects.toThrow( + 'A Docker service named srv-captain--paperless-db already exists' + ) + }) + test('deletes both physical volumes when neither remains in use', async () => { const appsDataStore = new AppsDataStore( createConfigStore({}), From 9b2a27e4456c58535ee726f135f7d4b40beb3888 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sat, 18 Jul 2026 23:20:17 -0700 Subject: [PATCH 05/15] Validate app names before checking DNS aliases --- .../users/apps/appdefinition/AppDefinitionHandler.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts b/src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts index 1403a7c..245dc3b 100644 --- a/src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts +++ b/src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts @@ -40,9 +40,6 @@ export async function registerAppDefinition( // if project is not found, it will throw an error } - // Avoid creating a DNS alias that conflicts with an orphaned legacy service - await serviceManager.ensureLegacyServiceNameAvailable(appName) - // Register the app definition await dataStore .getAppsDataStore() @@ -50,6 +47,9 @@ export async function registerAppDefinition( appCreated = true + // Avoid creating a DNS alias that conflicts with an orphaned legacy service + await serviceManager.ensureLegacyServiceNameAvailable(appName) + // Create captain definition content const captainDefinitionContent: ICaptainDefinition = { schemaVersion: 2, From 6031b6bc8550da17a6f1250b27c555377125ca50 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sat, 18 Jul 2026 23:26:49 -0700 Subject: [PATCH 06/15] Preserve aliases when rebuilding networks --- src/docker/DockerApi.ts | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/docker/DockerApi.ts b/src/docker/DockerApi.ts index dea1561..d4e32b5 100644 --- a/src/docker/DockerApi.ts +++ b/src/docker/DockerApi.ts @@ -77,20 +77,33 @@ export function getLegacyServiceDnsAlias( return `srv-${namespace}--${serviceName}` } +export interface IServiceNetworkAttachment { + Target: string + Aliases?: string[] + [key: string]: any +} + export function getServiceNetworkAttachments( networks: string[], - legacyDnsAlias: string | undefined + legacyDnsAlias: string | undefined, + existingAttachments: IServiceNetworkAttachment[] = [] ) { return networks.map((network) => { - const attachment: { - Target: string - Aliases?: string[] - } = { + const existingAttachment = existingAttachments.find( + (attachment) => attachment.Target === network + ) + const attachment: IServiceNetworkAttachment = { + ...existingAttachment, Target: network, } if (legacyDnsAlias) { - attachment.Aliases = [legacyDnsAlias] + attachment.Aliases = Array.from( + new Set([ + ...(existingAttachment?.Aliases || []), + legacyDnsAlias, + ]) + ) } return attachment @@ -1494,7 +1507,11 @@ class DockerApi { : undefined updatedData.TaskTemplate.Networks = - getServiceNetworkAttachments(networks, legacyDnsAlias) + getServiceNetworkAttachments( + networks, + legacyDnsAlias, + updatedData.TaskTemplate.Networks || [] + ) } if (secrets) { From f946ed8e9840a0e9940a8cb21763c8e4f28bda0a Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sat, 18 Jul 2026 23:26:58 -0700 Subject: [PATCH 07/15] Test alias preservation across network rebuilds --- tests/ServiceNamingMigration.test.ts | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/ServiceNamingMigration.test.ts b/tests/ServiceNamingMigration.test.ts index 291b43f..b389168 100644 --- a/tests/ServiceNamingMigration.test.ts +++ b/tests/ServiceNamingMigration.test.ts @@ -116,6 +116,37 @@ describe('service and volume naming migration', () => { ]) }) + test('preserves existing aliases when rebuilding network attachments', () => { + expect( + getServiceNetworkAttachments( + ['network-a', 'network-b', 'network-c'], + undefined, + [ + { + Target: 'network-a', + Aliases: ['alias-a'], + }, + { + Target: 'network-b', + Aliases: ['alias-b'], + }, + ] + ) + ).toEqual([ + { + Target: 'network-a', + Aliases: ['alias-a'], + }, + { + Target: 'network-b', + Aliases: ['alias-b'], + }, + { + Target: 'network-c', + }, + ]) + }) + test('does not add a redundant DNS alias to legacy apps', () => { const alias = getLegacyServiceDnsAlias( 'srv-captain--paperless-db', From e91333d5dde81eed443d730fee5d86e0e63d89eb Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sun, 19 Jul 2026 11:08:27 -0700 Subject: [PATCH 08/15] Remove orphaned service collision check --- src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts b/src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts index 245dc3b..2727577 100644 --- a/src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts +++ b/src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts @@ -47,9 +47,6 @@ export async function registerAppDefinition( appCreated = true - // Avoid creating a DNS alias that conflicts with an orphaned legacy service - await serviceManager.ensureLegacyServiceNameAvailable(appName) - // Create captain definition content const captainDefinitionContent: ICaptainDefinition = { schemaVersion: 2, From a813db181d0eec36005a15effcab53e11db2a1e3 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sun, 19 Jul 2026 11:08:38 -0700 Subject: [PATCH 09/15] Remove one-off legacy service preflight --- src/user/ServiceManager.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/user/ServiceManager.ts b/src/user/ServiceManager.ts index d1b3040..9711eb5 100644 --- a/src/user/ServiceManager.ts +++ b/src/user/ServiceManager.ts @@ -419,23 +419,6 @@ class ServiceManager { }) } - ensureLegacyServiceNameAvailable(appName: string) { - const legacyServiceName = this.dataStore - .getAppsDataStore() - .getServiceName(appName, true) - - return this.dockerApi - .isServiceRunningByName(legacyServiceName) - .then(function (serviceExists) { - if (serviceExists) { - throw ApiStatusCodes.createError( - ApiStatusCodes.STATUS_ERROR_ALREADY_EXIST, - `A Docker service named ${legacyServiceName} already exists and conflicts with the legacy DNS alias` - ) - } - }) - } - renameApp(oldAppName: string, newAppName: string) { Logger.d(`Renaming app: ${oldAppName}`) const self = this @@ -457,10 +440,6 @@ class ServiceManager { .getServiceName(oldAppName, !!appDef.isLegacyAppName) dataStore.getAppsDataStore().nameAllowedOrThrow(newAppName) - - if (!appDef.isLegacyAppName) { - return self.ensureLegacyServiceNameAvailable(newAppName) - } }) .then(function () { return self.ensureNotBuilding(oldAppName) From 40cb40dbf8fccc800467ba77d3a75863df1d212c Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sun, 19 Jul 2026 11:08:52 -0700 Subject: [PATCH 10/15] Remove orphaned service collision test --- tests/ServiceNamingMigration.test.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/tests/ServiceNamingMigration.test.ts b/tests/ServiceNamingMigration.test.ts index b389168..d9d8ea2 100644 --- a/tests/ServiceNamingMigration.test.ts +++ b/tests/ServiceNamingMigration.test.ts @@ -166,26 +166,6 @@ describe('service and volume naming migration', () => { ]) }) - test('rejects a legacy DNS alias that conflicts with an orphaned service', async () => { - const serviceManager = Object.create( - ServiceManager.prototype - ) as ServiceManager - ;(serviceManager as any).dataStore = { - getAppsDataStore: () => ({ - getServiceName: () => 'srv-captain--paperless-db', - }), - } - ;(serviceManager as any).dockerApi = { - isServiceRunningByName: jest.fn().mockResolvedValue(true), - } - - await expect( - serviceManager.ensureLegacyServiceNameAvailable('paperless-db') - ).rejects.toThrow( - 'A Docker service named srv-captain--paperless-db already exists' - ) - }) - test('deletes both physical volumes when neither remains in use', async () => { const appsDataStore = new AppsDataStore( createConfigStore({}), From df16729cb7ec514cf678c7d6d2c699527f64b494 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sun, 19 Jul 2026 11:09:48 -0700 Subject: [PATCH 11/15] Restore original rename validation flow --- src/user/ServiceManager.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/user/ServiceManager.ts b/src/user/ServiceManager.ts index 9711eb5..a6e5eb4 100644 --- a/src/user/ServiceManager.ts +++ b/src/user/ServiceManager.ts @@ -440,8 +440,7 @@ class ServiceManager { .getServiceName(oldAppName, !!appDef.isLegacyAppName) dataStore.getAppsDataStore().nameAllowedOrThrow(newAppName) - }) - .then(function () { + return self.ensureNotBuilding(oldAppName) }) .then(function () { From 1b81eb371cda3162d86464a4b314c3e2732ead29 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sun, 19 Jul 2026 11:21:50 -0700 Subject: [PATCH 12/15] Limit DNS alias handling to app services --- src/docker/DockerApi.ts | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/docker/DockerApi.ts b/src/docker/DockerApi.ts index d4e32b5..a36d214 100644 --- a/src/docker/DockerApi.ts +++ b/src/docker/DockerApi.ts @@ -1498,20 +1498,27 @@ class DockerApi { } if (networks) { - const legacyDnsAlias = appObject - ? getLegacyServiceDnsAlias( - serviceName, - namespace, - !!appObject.isLegacyAppName - ) - : undefined - - updatedData.TaskTemplate.Networks = - getServiceNetworkAttachments( - networks, - legacyDnsAlias, - updatedData.TaskTemplate.Networks || [] + if (appObject) { + const legacyDnsAlias = getLegacyServiceDnsAlias( + serviceName, + namespace, + !!appObject.isLegacyAppName ) + + updatedData.TaskTemplate.Networks = + getServiceNetworkAttachments( + networks, + legacyDnsAlias, + updatedData.TaskTemplate.Networks || [] + ) + } else { + updatedData.TaskTemplate.Networks = [] + for (let i = 0; i < networks.length; i++) { + updatedData.TaskTemplate.Networks.push({ + Target: networks[i], + }) + } + } } if (secrets) { From d2b9814609f82399fd97413773ec323505138291 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sun, 19 Jul 2026 11:44:04 -0700 Subject: [PATCH 13/15] Rebuild app network attachments from scratch --- src/docker/DockerApi.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/docker/DockerApi.ts b/src/docker/DockerApi.ts index a36d214..b5fb16b 100644 --- a/src/docker/DockerApi.ts +++ b/src/docker/DockerApi.ts @@ -85,25 +85,15 @@ export interface IServiceNetworkAttachment { export function getServiceNetworkAttachments( networks: string[], - legacyDnsAlias: string | undefined, - existingAttachments: IServiceNetworkAttachment[] = [] + legacyDnsAlias: string | undefined ) { return networks.map((network) => { - const existingAttachment = existingAttachments.find( - (attachment) => attachment.Target === network - ) const attachment: IServiceNetworkAttachment = { - ...existingAttachment, Target: network, } if (legacyDnsAlias) { - attachment.Aliases = Array.from( - new Set([ - ...(existingAttachment?.Aliases || []), - legacyDnsAlias, - ]) - ) + attachment.Aliases = [legacyDnsAlias] } return attachment @@ -1508,8 +1498,7 @@ class DockerApi { updatedData.TaskTemplate.Networks = getServiceNetworkAttachments( networks, - legacyDnsAlias, - updatedData.TaskTemplate.Networks || [] + legacyDnsAlias ) } else { updatedData.TaskTemplate.Networks = [] From e0e1a5f77abaf163132ddf1f9b99487bb2cda060 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sun, 19 Jul 2026 11:44:20 -0700 Subject: [PATCH 14/15] Remove network metadata preservation test --- tests/ServiceNamingMigration.test.ts | 31 ---------------------------- 1 file changed, 31 deletions(-) diff --git a/tests/ServiceNamingMigration.test.ts b/tests/ServiceNamingMigration.test.ts index d9d8ea2..2efeb07 100644 --- a/tests/ServiceNamingMigration.test.ts +++ b/tests/ServiceNamingMigration.test.ts @@ -116,37 +116,6 @@ describe('service and volume naming migration', () => { ]) }) - test('preserves existing aliases when rebuilding network attachments', () => { - expect( - getServiceNetworkAttachments( - ['network-a', 'network-b', 'network-c'], - undefined, - [ - { - Target: 'network-a', - Aliases: ['alias-a'], - }, - { - Target: 'network-b', - Aliases: ['alias-b'], - }, - ] - ) - ).toEqual([ - { - Target: 'network-a', - Aliases: ['alias-a'], - }, - { - Target: 'network-b', - Aliases: ['alias-b'], - }, - { - Target: 'network-c', - }, - ]) - }) - test('does not add a redundant DNS alias to legacy apps', () => { const alias = getLegacyServiceDnsAlias( 'srv-captain--paperless-db', From e668e0439732566fcd5b021ceef87f5e976fa684 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sun, 19 Jul 2026 11:45:01 -0700 Subject: [PATCH 15/15] Narrow network attachment type --- src/docker/DockerApi.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/docker/DockerApi.ts b/src/docker/DockerApi.ts index b5fb16b..5247de5 100644 --- a/src/docker/DockerApi.ts +++ b/src/docker/DockerApi.ts @@ -80,7 +80,6 @@ export function getLegacyServiceDnsAlias( export interface IServiceNetworkAttachment { Target: string Aliases?: string[] - [key: string]: any } export function getServiceNetworkAttachments(