From 5cadf1b543a480d01a28c80c360566328dd93ecb Mon Sep 17 00:00:00 2001 From: Valter Silva Date: Fri, 22 May 2026 14:26:10 +0100 Subject: [PATCH 1/3] feat: app-to-app network linking via networkWith description token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app owner can link an app to other apps by embedding a token in the app description text: networkWith:[appA,appB] (brackets required, quotes optional, key case-insensitive, comma separated). This is purely node-local behaviour — no app specification field, no validation change, no network consensus impact. When the token is present: - Before install/redeploy, the node verifies every named app is installed locally and owned by the same owner; otherwise the operation fails. - Each of the app's component containers is attached to the private docker network of every linked app (fluxDockerNetwork_), so it can reach that app's components by docker DNS name flux_, as if both apps were a single app. - When a linked-to app is (re)deployed, any locally installed app that is networked with it is reconnected to its network. New module appNetworkLinker.js holds the parser, the install gate, and the forward/reverse network wiring. The gate and forward wiring run in installApplicationHard/installApplicationSoft (the only callers of appDockerCreate), so every container-creation path is covered, including direct callers that bypass registerAppLocally (container health recovery and legacy v<=3 redeploys). Reverse wiring runs in registerAppLocally and softRegisterAppLocally; a boot-time reconcile sweep re-applies all links. dockerService gains an idempotent appDockerNetworkConnect helper. Adds tests/unit/appNetworkLinker.test.js (parser, gate, wiring, reconcile) and appDockerNetworkConnect coverage in dockerService.test.js. --- .../appLifecycle/advancedWorkflows.js | 14 + .../src/services/appLifecycle/appInstaller.js | 38 +++ .../services/appLifecycle/appNetworkLinker.js | 255 ++++++++++++++++++ .../appLifecycle/appStartupManager.js | 6 + ZelBack/src/services/dockerService.js | 22 ++ tests/unit/appNetworkLinker.test.js | 214 +++++++++++++++ tests/unit/dockerService.test.js | 34 +++ 7 files changed, 583 insertions(+) create mode 100644 ZelBack/src/services/appLifecycle/appNetworkLinker.js create mode 100644 tests/unit/appNetworkLinker.test.js diff --git a/ZelBack/src/services/appLifecycle/advancedWorkflows.js b/ZelBack/src/services/appLifecycle/advancedWorkflows.js index bce02acd9..ef4036484 100644 --- a/ZelBack/src/services/appLifecycle/advancedWorkflows.js +++ b/ZelBack/src/services/appLifecycle/advancedWorkflows.js @@ -28,6 +28,7 @@ const { checkAndDecryptAppSpecs } = require('../utils/enterpriseHelper'); const { stopAppMonitoring } = require('../appManagement/appInspector'); const { decryptEnterpriseApps } = require('../appQuery/appQueryService'); const globalState = require('../utils/globalState'); +const appNetworkLinker = require('./appNetworkLinker'); const isArcane = Boolean(process.env.FLUXOS_PATH); @@ -1014,6 +1015,11 @@ async function softRegisterAppLocally(appSpecs, componentSpecs, res) { return; } + // Verify the apps this app must be networked with (networkWith token in the + // description) are installed locally and owned by the same owner before any + // side effects. + await appNetworkLinker.checkAppNetworkRequirements(appSpecifications); + if (!isComponent) { let dockerNetworkAddrValue = Math.floor(Math.random() * 256); if (appsThatMightBeUsingOldGatewayIpAssignment.includes(appName)) { @@ -1151,6 +1157,14 @@ async function softRegisterAppLocally(appSpecs, componentSpecs, res) { log.info(`Restored syncthing cache for ${appId} during soft redeploy`); } } + + // Reconnect any locally installed apps that are networked with this app. + // Guarded on appComponent (the unmutated entry value) since isComponent is + // flipped to true inside the component install loop above. + if (!appComponent) { + await appNetworkLinker.reconnectLinkedApps(appName); + } + // all done message const successStatus = messageHelper.createSuccessMessage(`Flux App ${appName} successfully installed and launched`); log.info(successStatus); diff --git a/ZelBack/src/services/appLifecycle/appInstaller.js b/ZelBack/src/services/appLifecycle/appInstaller.js index 664596fbb..8ddc025bd 100644 --- a/ZelBack/src/services/appLifecycle/appInstaller.js +++ b/ZelBack/src/services/appLifecycle/appInstaller.js @@ -11,6 +11,7 @@ const benchmarkService = require('../benchmarkService'); const daemonServiceMiscRpcs = require('../daemonService/daemonServiceMiscRpcs'); const fluxNetworkHelper = require('../fluxNetworkHelper'); const appUninstaller = require('./appUninstaller'); +const appNetworkLinker = require('./appNetworkLinker'); // const advancedWorkflows = require('./advancedWorkflows'); // Moved to dynamic require to avoid circular dependency const fluxCommunicationMessagesSender = require('../fluxCommunicationMessagesSender'); const { storeAppInstallingErrorMessage } = require('../appMessaging/messageStore'); @@ -454,6 +455,11 @@ async function registerAppLocally(appSpecs, componentSpecs, res, test = false, s await performDockerCleanup(res); } + // Verify the apps this app must be networked with (networkWith token in the + // description) are installed locally and owned by the same owner before any + // side effects. + await appNetworkLinker.checkAppNetworkRequirements(appSpecifications); + if (!isComponent) { let dockerNetworkAddrValue = Math.floor(Math.random() * 256); if (appsThatMightBeUsingOldGatewayIpAssignment.includes(appName)) { @@ -616,6 +622,14 @@ async function registerAppLocally(appSpecs, componentSpecs, res, test = false, s fluxEventBus.publish('app:installed', { name: appSpecifications.name, hash: appSpecifications.hash }); } + // Reconnect any locally installed apps that are networked with this app — + // its private network was (re)created during this install. Guarded on + // appComponent (the unmutated entry value) since isComponent is flipped to + // true inside the component install loop above. + if (!appComponent && !test) { + await appNetworkLinker.reconnectLinkedApps(appName); + } + // all done message const successStatus = messageHelper.createSuccessMessage(`Flux App ${appName} successfully installed and launched`); log.info(successStatus); @@ -752,6 +766,13 @@ async function checkOrbitAppHealth(appSpecifications, appName, isComponent, res) * @returns {Promise} Installation result */ async function installApplicationHard(appSpecifications, appName, isComponent, res, fullAppSpecs, test = false) { + // Verify the apps this app must be networked with (networkWith token) are + // installed locally and owned by the same owner. Enforced here too — not just + // in registerAppLocally — so direct callers that bypass it (container health + // recovery, legacy v<=3 redeploys) cannot create a container without its + // network links satisfied. + await appNetworkLinker.checkAppNetworkRequirements(fullAppSpecs); + // Setup firewall and UPnP ports (fail fast before downloading images) await setupApplicationPorts(appSpecifications, appName, isComponent, res, test); @@ -795,6 +816,11 @@ async function installApplicationHard(appSpecifications, appName, isComponent, r await dockerService.appDockerCreate(appSpecifications, appName, isComponent, fullAppSpecs); + // Attach this component to the private network of every app it is linked with + // so it can reach their components by docker DNS name. + const componentContainerName = dockerService.getAppIdentifier(isComponent ? `${appSpecifications.name}_${appName}` : appName); + await appNetworkLinker.connectComponentToLinkedApps(componentContainerName, fullAppSpecs); + const startStatus = { status: isComponent ? `Starting component ${appSpecifications.name} of Flux App ${appName}...` : `Starting Flux App ${appName}...`, }; @@ -839,6 +865,13 @@ async function installApplicationHard(appSpecifications, appName, isComponent, r * @returns {Promise} Return statement is only used here to interrupt the function and nothing is returned. */ async function installApplicationSoft(appSpecifications, appName, isComponent, res, fullAppSpecs) { + // Verify the apps this app must be networked with (networkWith token) are + // installed locally and owned by the same owner. Enforced here too — not just + // in softRegisterAppLocally — so direct callers that bypass it (container + // health recovery, legacy v<=3 redeploys) cannot create a container without + // its network links satisfied. + await appNetworkLinker.checkAppNetworkRequirements(fullAppSpecs); + // Setup firewall and UPnP ports (fail fast before downloading images) await setupApplicationPorts(appSpecifications, appName, isComponent, res); @@ -856,6 +889,11 @@ async function installApplicationSoft(appSpecifications, appName, isComponent, r await dockerService.appDockerCreate(appSpecifications, appName, isComponent, fullAppSpecs); + // Attach this component to the private network of every app it is linked with + // so it can reach their components by docker DNS name. + const componentContainerName = dockerService.getAppIdentifier(isComponent ? `${appSpecifications.name}_${appName}` : appName); + await appNetworkLinker.connectComponentToLinkedApps(componentContainerName, fullAppSpecs); + const startStatus = { status: isComponent ? `Starting component ${appSpecifications.name} of Flux App ${appName}...` : `Starting Flux App ${appName}...`, }; diff --git a/ZelBack/src/services/appLifecycle/appNetworkLinker.js b/ZelBack/src/services/appLifecycle/appNetworkLinker.js new file mode 100644 index 000000000..f9fe82c32 --- /dev/null +++ b/ZelBack/src/services/appLifecycle/appNetworkLinker.js @@ -0,0 +1,255 @@ +/** + * App Network Linker + * + * Implements opt-in app-to-app network linking. An app owner links the app to + * other apps by embedding a token in the app `description` text: + * + * networkWith:[appA,appB] + * + * Brackets are required, quotes are optional, the key is matched + * case-insensitively and names are comma separated. When the token is present, + * before the app is installed or redeployed the node verifies every named app + * is installed locally and owned by the same owner; otherwise the install + * fails. Each of the app's component containers is then attached to the + * private docker network of every linked app (`fluxDockerNetwork_`), so + * it can reach that app's components by their docker DNS name + * `flux_` — exactly as if both apps were a single app. + * + * This is purely node-local behaviour: it does not introduce an app + * specification field, change validation, or touch any network consensus. An + * app whose description has no (or a malformed) token behaves exactly as before. + */ + +const config = require('config'); +const dbHelper = require('../dbHelper'); +const dockerService = require('../dockerService'); +const log = require('../../lib/log'); +const { localAppsInformation } = require('../utils/appConstants'); + +// Flux app name syntax (version 8 superset — alphanumerics with internal hyphens). +const appNameRegex = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/; + +/** + * Parses the `networkWith:[...]` token out of an app description. + * + * @param {string} description - app description text + * @returns {string[]} unique, syntactically valid linked app names ([] if none) + */ +function parseNetworkWith(description) { + if (typeof description !== 'string' || !description) { + return []; + } + const match = description.match(/\bnetworkWith\s*[:=]\s*\[([^\]]*)\]/i); + if (!match) { + return []; + } + const names = []; + const seen = new Set(); + match[1].split(',').forEach((raw) => { + const name = raw.trim().replace(/^["']+|["']+$/g, '').trim(); + const key = name.toLowerCase(); + if (name && appNameRegex.test(name) && !seen.has(key)) { + seen.add(key); + names.push(name); + } + }); + return names; +} + +/** + * Returns the linked app names declared by an app, excluding any self + * reference to the app itself. + * + * @param {object} appSpecs - full app specification + * @returns {string[]} linked app names + */ +function getLinkedApps(appSpecs) { + if (!appSpecs || !appSpecs.name) { + return []; + } + const selfName = String(appSpecs.name).toLowerCase(); + return parseNetworkWith(appSpecs.description).filter((linked) => linked.toLowerCase() !== selfName); +} + +/** + * Resolves the docker container names belonging to an installed app by + * inspecting docker directly. This is robust for enterprise apps, whose stored + * `compose` array is blanked in the local database. + * + * @param {string} appName - application name + * @returns {Promise} docker container names (without leading slash) + */ +async function getAppContainerNames(appName) { + const containers = await dockerService.dockerListContainers(true); + const singleComponentName = dockerService.getAppIdentifier(appName); + const names = []; + (containers || []).forEach((container) => { + (container.Names || []).forEach((rawName) => { + const name = rawName.replace(/^\//, ''); + // component container: flux_; single-component app: flux / zel + const belongsToApp = name === singleComponentName || name.endsWith(`_${appName}`); + if (belongsToApp && !names.includes(name)) { + names.push(name); + } + }); + }); + return names; +} + +/** + * Verifies every app this app is linked to is installed locally and owned by + * the same owner. Throws otherwise, aborting the install/redeploy. + * + * @param {object} appSpecs - full app specification + * @returns {Promise} true when all network links are satisfied + */ +async function checkAppNetworkRequirements(appSpecs) { + const linkedApps = getLinkedApps(appSpecs); + if (!linkedApps.length) { + return true; + } + + const dbopen = dbHelper.databaseConnection(); + const appsDatabase = dbopen.db(config.database.appslocal.database); + const projection = { projection: { _id: 0, name: 1, owner: 1 } }; + + // eslint-disable-next-line no-restricted-syntax + for (const linkedApp of linkedApps) { + // eslint-disable-next-line no-await-in-loop + const installed = await dbHelper.findOneInDatabase(appsDatabase, localAppsInformation, { name: linkedApp }, projection); + if (!installed) { + throw new Error(`App '${linkedApp}' that '${appSpecs.name}' must be networked with is not installed on this node. Installation aborted.`); + } + if (installed.owner !== appSpecs.owner) { + throw new Error(`App '${linkedApp}' that '${appSpecs.name}' must be networked with is owned by a different owner. Installation aborted.`); + } + } + log.info(`App network links satisfied for ${appSpecs.name}: ${linkedApps.join(', ')}`); + return true; +} + +/** + * Attaches a freshly created component container to the private docker network + * of every app the parent app is linked to, so it can reach the linked apps' + * components. Throws on a real connection failure so the install is rolled back. + * + * @param {string} componentContainerName - docker container name (flux_) + * @param {object} fullAppSpecs - full app specification of the parent app + * @returns {Promise} + */ +async function connectComponentToLinkedApps(componentContainerName, fullAppSpecs) { + const linkedApps = getLinkedApps(fullAppSpecs); + if (!linkedApps.length) { + return; + } + + // eslint-disable-next-line no-restricted-syntax + for (const linkedApp of linkedApps) { + const networkName = `fluxDockerNetwork_${linkedApp}`; + // eslint-disable-next-line no-await-in-loop + await dockerService.appDockerNetworkConnect(componentContainerName, networkName); + log.info(`Connected ${componentContainerName} to linked app network ${networkName}`); + } +} + +/** + * After an app's private network is (re)created, reconnects every locally + * installed app that is networked with it back onto that network. Best-effort — + * never throws, so a redeploy is not aborted by a reconnect hiccup. + * + * @param {string} appName - the app whose network was (re)created + * @returns {Promise} + */ +async function reconnectLinkedApps(appName) { + let installedApps; + try { + const dbopen = dbHelper.databaseConnection(); + const appsDatabase = dbopen.db(config.database.appslocal.database); + installedApps = await dbHelper.findInDatabase(appsDatabase, localAppsInformation, {}, { projection: { _id: 0 } }); + } catch (error) { + log.error(`reconnectLinkedApps: failed to read installed apps for ${appName}: ${error.message}`); + return; + } + + const networkName = `fluxDockerNetwork_${appName}`; + const lowerAppName = appName.toLowerCase(); + + // eslint-disable-next-line no-restricted-syntax + for (const app of installedApps || []) { + if (!app || app.name === appName) { + // eslint-disable-next-line no-continue + continue; + } + const linkedApps = getLinkedApps(app); + if (!linkedApps.some((linked) => linked.toLowerCase() === lowerAppName)) { + // eslint-disable-next-line no-continue + continue; + } + try { + // eslint-disable-next-line no-await-in-loop + const containerNames = await getAppContainerNames(app.name); + // eslint-disable-next-line no-restricted-syntax + for (const containerName of containerNames) { + // eslint-disable-next-line no-await-in-loop + await dockerService.appDockerNetworkConnect(containerName, networkName); + log.info(`Reconnected linked app ${containerName} to ${networkName}`); + } + } catch (error) { + log.error(`reconnectLinkedApps: failed to reconnect ${app.name} to ${networkName}: ${error.message}`); + } + } +} + +/** + * Boot-time sweep: ensures every installed app that declares network links is + * attached to each linked app's network. Idempotent and best-effort. + * + * @returns {Promise} + */ +async function reconcileAllAppNetworkLinks() { + let installedApps; + try { + const dbopen = dbHelper.databaseConnection(); + const appsDatabase = dbopen.db(config.database.appslocal.database); + installedApps = await dbHelper.findInDatabase(appsDatabase, localAppsInformation, {}, { projection: { _id: 0 } }); + } catch (error) { + log.error(`reconcileAllAppNetworkLinks: failed to read installed apps: ${error.message}`); + return; + } + + // eslint-disable-next-line no-restricted-syntax + for (const app of installedApps || []) { + const linkedApps = getLinkedApps(app); + if (!linkedApps.length) { + // eslint-disable-next-line no-continue + continue; + } + try { + // eslint-disable-next-line no-await-in-loop + const containerNames = await getAppContainerNames(app.name); + // eslint-disable-next-line no-restricted-syntax + for (const linkedApp of linkedApps) { + const networkName = `fluxDockerNetwork_${linkedApp}`; + // eslint-disable-next-line no-restricted-syntax + for (const containerName of containerNames) { + // eslint-disable-next-line no-await-in-loop + await dockerService.appDockerNetworkConnect(containerName, networkName).catch((error) => { + log.error(`reconcileAllAppNetworkLinks: failed to connect ${containerName} to ${networkName}: ${error.message}`); + }); + } + } + } catch (error) { + log.error(`reconcileAllAppNetworkLinks: failed for ${app.name}: ${error.message}`); + } + } +} + +module.exports = { + parseNetworkWith, + getLinkedApps, + getAppContainerNames, + checkAppNetworkRequirements, + connectComponentToLinkedApps, + reconnectLinkedApps, + reconcileAllAppNetworkLinks, +}; diff --git a/ZelBack/src/services/appLifecycle/appStartupManager.js b/ZelBack/src/services/appLifecycle/appStartupManager.js index a09be59cb..bcbb8d235 100644 --- a/ZelBack/src/services/appLifecycle/appStartupManager.js +++ b/ZelBack/src/services/appLifecycle/appStartupManager.js @@ -15,6 +15,7 @@ const fluxNetworkHelper = require('../fluxNetworkHelper'); const registryManager = require('../appDatabase/registryManager'); const advancedWorkflows = require('./advancedWorkflows'); const appUninstaller = require('./appUninstaller'); +const appNetworkLinker = require('./appNetworkLinker'); const globalState = require('../utils/globalState'); const fluxEventBus = require('../utils/fluxEventBus'); const nodeConfirmationService = require('../nodeConfirmationService'); @@ -248,6 +249,11 @@ async function reconcileAppsOnBoot() { + `Apps failed: ${results.appsFailed.length}`, ); + // Re-apply app-to-app network links (networkWith token in the description). + // Idempotent and best-effort — defensive in case docker did not restore a + // secondary network membership across the reboot. + await appNetworkLinker.reconcileAllAppNetworkLinks(); + return results; } catch (error) { log.error(`appStartupManager - Critical error during recovery: ${error.message}`); diff --git a/ZelBack/src/services/dockerService.js b/ZelBack/src/services/dockerService.js index b27af92a8..55b2212ee 100644 --- a/ZelBack/src/services/dockerService.js +++ b/ZelBack/src/services/dockerService.js @@ -1424,6 +1424,27 @@ async function forceRemoveFluxAppDockerNetwork(appname) { } } +/** + * Connects a container to an existing docker network. Idempotent — if the + * container is already attached to the network this resolves without error. + * + * @param {string} containerIdOrName - container id or name + * @param {string} networkName - target docker network name + * @returns {Promise} + */ +async function appDockerNetworkConnect(containerIdOrName, networkName) { + const network = docker.getNetwork(networkName); + try { + await network.connect({ Container: containerIdOrName }); + } catch (error) { + // 403 - endpoint already exists in network (container already connected) + if (error.statusCode === 403 || /already exists|already connected/i.test(error.message || '')) { + return; + } + throw error; + } +} + /** * Remove all unused containers. Unused contaienrs are those wich are not running */ @@ -1642,6 +1663,7 @@ module.exports = { pruneVolumes, removeFluxAppDockerNetwork, forceRemoveFluxAppDockerNetwork, + appDockerNetworkConnect, getAppNameByContainerIp, waitForDocker, }; diff --git a/tests/unit/appNetworkLinker.test.js b/tests/unit/appNetworkLinker.test.js new file mode 100644 index 000000000..41bb0a743 --- /dev/null +++ b/tests/unit/appNetworkLinker.test.js @@ -0,0 +1,214 @@ +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +chai.use(chaiAsPromised); +const { expect } = chai; + +describe('appNetworkLinker tests', () => { + let appNetworkLinker; + let dbHelperStub; + let dockerServiceStub; + let logStub; + + const configStub = { + database: { + appslocal: { database: 'localapps' }, + }, + }; + + const appConstantsStub = { + localAppsInformation: 'localAppsInformation', + }; + + beforeEach(() => { + dbHelperStub = { + databaseConnection: sinon.stub().returns({ db: sinon.stub().returns('appsDB') }), + findOneInDatabase: sinon.stub(), + findInDatabase: sinon.stub(), + }; + dockerServiceStub = { + appDockerNetworkConnect: sinon.stub().resolves(), + dockerListContainers: sinon.stub().resolves([]), + getAppIdentifier: sinon.stub().callsFake((name) => (name.startsWith('flux') || name.startsWith('zel') ? name : `flux${name}`)), + }; + logStub = { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub() }; + + appNetworkLinker = proxyquire('../../ZelBack/src/services/appLifecycle/appNetworkLinker', { + config: configStub, + '../dbHelper': dbHelperStub, + '../dockerService': dockerServiceStub, + '../../lib/log': logStub, + '../utils/appConstants': appConstantsStub, + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('parseNetworkWith', () => { + it('returns [] when description is not a string', () => { + expect(appNetworkLinker.parseNetworkWith(undefined)).to.eql([]); + expect(appNetworkLinker.parseNetworkWith(null)).to.eql([]); + expect(appNetworkLinker.parseNetworkWith(123)).to.eql([]); + expect(appNetworkLinker.parseNetworkWith('')).to.eql([]); + }); + + it('returns [] when no token is present', () => { + expect(appNetworkLinker.parseNetworkWith('just a normal description')).to.eql([]); + }); + + it('parses an unquoted token embedded in free text', () => { + expect(appNetworkLinker.parseNetworkWith('My great app. networkWith:[appA,appB]')).to.eql(['appA', 'appB']); + }); + + it('parses a quoted JSON-style token', () => { + expect(appNetworkLinker.parseNetworkWith('text networkWith:["appA","appB"]')).to.eql(['appA', 'appB']); + }); + + it('tolerates spaces, the = separator and a case-insensitive key', () => { + expect(appNetworkLinker.parseNetworkWith('NETWORKWITH = [ appA , appB ]')).to.eql(['appA', 'appB']); + }); + + it('drops invalid names and deduplicates', () => { + expect(appNetworkLinker.parseNetworkWith('networkWith:[appA,appA,bad name,inv@lid,appB]')).to.eql(['appA', 'appB']); + }); + + it('returns [] for empty brackets', () => { + expect(appNetworkLinker.parseNetworkWith('networkWith:[]')).to.eql([]); + }); + + it('returns [] when brackets are missing (malformed)', () => { + expect(appNetworkLinker.parseNetworkWith('networkWith:appA,appB')).to.eql([]); + }); + + it('does not match networkWith embedded inside a larger word', () => { + expect(appNetworkLinker.parseNetworkWith('mynetworkWith:[appA]')).to.eql([]); + }); + + it('accepts app names containing internal hyphens', () => { + expect(appNetworkLinker.parseNetworkWith('networkWith:[my-app]')).to.eql(['my-app']); + }); + }); + + describe('getLinkedApps', () => { + it('excludes a self-reference to the app itself', () => { + const specs = { name: 'appA', description: 'networkWith:[appA,appB]' }; + expect(appNetworkLinker.getLinkedApps(specs)).to.eql(['appB']); + }); + + it('returns [] when the app has no name', () => { + expect(appNetworkLinker.getLinkedApps({ description: 'networkWith:[appB]' })).to.eql([]); + }); + + it('returns [] for a falsy app spec', () => { + expect(appNetworkLinker.getLinkedApps(null)).to.eql([]); + }); + }); + + describe('checkAppNetworkRequirements', () => { + it('resolves true and touches no database when there are no linked apps', async () => { + const result = await appNetworkLinker.checkAppNetworkRequirements({ name: 'appB', description: 'plain text', owner: 'owner1' }); + expect(result).to.equal(true); + sinon.assert.notCalled(dbHelperStub.findOneInDatabase); + }); + + it('throws when a linked app is not installed locally', async () => { + dbHelperStub.findOneInDatabase.resolves(null); + await expect(appNetworkLinker.checkAppNetworkRequirements({ name: 'appB', description: 'networkWith:[appA]', owner: 'owner1' })) + .to.be.rejectedWith(/is not installed on this node/); + }); + + it('throws when a linked app is owned by a different owner', async () => { + dbHelperStub.findOneInDatabase.resolves({ name: 'appA', owner: 'owner2' }); + await expect(appNetworkLinker.checkAppNetworkRequirements({ name: 'appB', description: 'networkWith:[appA]', owner: 'owner1' })) + .to.be.rejectedWith(/owned by a different owner/); + }); + + it('resolves true when every linked app is installed with the same owner', async () => { + dbHelperStub.findOneInDatabase.resolves({ name: 'appA', owner: 'owner1' }); + const result = await appNetworkLinker.checkAppNetworkRequirements({ name: 'appB', description: 'networkWith:[appA]', owner: 'owner1' }); + expect(result).to.equal(true); + }); + }); + + describe('connectComponentToLinkedApps', () => { + it('does nothing when the app declares no network links', async () => { + await appNetworkLinker.connectComponentToLinkedApps('fluxweb_appB', { name: 'appB', description: 'plain text' }); + sinon.assert.notCalled(dockerServiceStub.appDockerNetworkConnect); + }); + + it('connects the container to every linked app network', async () => { + await appNetworkLinker.connectComponentToLinkedApps('fluxweb_appB', { name: 'appB', description: 'networkWith:[appA,appC]' }); + sinon.assert.calledWith(dockerServiceStub.appDockerNetworkConnect, 'fluxweb_appB', 'fluxDockerNetwork_appA'); + sinon.assert.calledWith(dockerServiceStub.appDockerNetworkConnect, 'fluxweb_appB', 'fluxDockerNetwork_appC'); + }); + + it('propagates a connection failure so the install is rolled back', async () => { + dockerServiceStub.appDockerNetworkConnect.rejects(new Error('docker boom')); + await expect(appNetworkLinker.connectComponentToLinkedApps('c', { name: 'appB', description: 'networkWith:[appA]' })) + .to.be.rejectedWith('docker boom'); + }); + }); + + describe('getAppContainerNames', () => { + it('returns component and single-component containers belonging to an app', async () => { + dockerServiceStub.dockerListContainers.resolves([ + { Names: ['/fluxweb_myapp'] }, + { Names: ['/fluxapi_myapp'] }, + { Names: ['/fluxother_differentapp'] }, + { Names: ['/fluxmyapp'] }, + ]); + const names = await appNetworkLinker.getAppContainerNames('myapp'); + expect(names).to.have.members(['fluxweb_myapp', 'fluxapi_myapp', 'fluxmyapp']); + expect(names).to.not.include('fluxother_differentapp'); + }); + }); + + describe('reconnectLinkedApps', () => { + it('reconnects only the apps that are networked with the given app', async () => { + dbHelperStub.findInDatabase.resolves([ + { name: 'appB', description: 'networkWith:[appA]' }, + { name: 'appC', description: 'no links here' }, + { name: 'appA', description: 'networkWith:[appA]' }, + ]); + dockerServiceStub.dockerListContainers.resolves([ + { Names: ['/fluxweb_appB'] }, + { Names: ['/fluxapi_appB'] }, + { Names: ['/fluxweb_appC'] }, + ]); + + await appNetworkLinker.reconnectLinkedApps('appA'); + + sinon.assert.calledWith(dockerServiceStub.appDockerNetworkConnect, 'fluxweb_appB', 'fluxDockerNetwork_appA'); + sinon.assert.calledWith(dockerServiceStub.appDockerNetworkConnect, 'fluxapi_appB', 'fluxDockerNetwork_appA'); + expect(dockerServiceStub.appDockerNetworkConnect.calledWith('fluxweb_appC')).to.equal(false); + }); + + it('does not throw when the database read fails', async () => { + dbHelperStub.findInDatabase.rejects(new Error('db down')); + await expect(appNetworkLinker.reconnectLinkedApps('appA')).to.not.be.rejected; + }); + }); + + describe('reconcileAllAppNetworkLinks', () => { + it('connects every linked app to each of its linked app networks', async () => { + dbHelperStub.findInDatabase.resolves([ + { name: 'appB', description: 'networkWith:[appA]' }, + { name: 'appC', description: 'plain' }, + ]); + dockerServiceStub.dockerListContainers.resolves([{ Names: ['/fluxweb_appB'] }]); + + await appNetworkLinker.reconcileAllAppNetworkLinks(); + + sinon.assert.calledWith(dockerServiceStub.appDockerNetworkConnect, 'fluxweb_appB', 'fluxDockerNetwork_appA'); + }); + + it('does not throw when the database read fails', async () => { + dbHelperStub.findInDatabase.rejects(new Error('db down')); + await expect(appNetworkLinker.reconcileAllAppNetworkLinks()).to.not.be.rejected; + }); + }); +}); diff --git a/tests/unit/dockerService.test.js b/tests/unit/dockerService.test.js index 077d8325e..4637b5424 100644 --- a/tests/unit/dockerService.test.js +++ b/tests/unit/dockerService.test.js @@ -715,6 +715,40 @@ describe('dockerService tests', () => { }); }); + describe('appDockerNetworkConnect tests', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should connect a container to the given network', async () => { + const connectStub = sinon.stub().resolves(); + const getNetworkStub = sinon.stub(Dockerode.prototype, 'getNetwork').returns({ connect: connectStub }); + + await dockerService.appDockerNetworkConnect('fluxweb_myapp', 'fluxDockerNetwork_dep'); + + sinon.assert.calledOnceWithExactly(getNetworkStub, 'fluxDockerNetwork_dep'); + sinon.assert.calledOnceWithExactly(connectStub, { Container: 'fluxweb_myapp' }); + }); + + it('should resolve without error when the container is already connected (403)', async () => { + const error = new Error('endpoint with name fluxweb_myapp already exists in network fluxDockerNetwork_dep'); + error.statusCode = 403; + const connectStub = sinon.stub().rejects(error); + sinon.stub(Dockerode.prototype, 'getNetwork').returns({ connect: connectStub }); + + await expect(dockerService.appDockerNetworkConnect('fluxweb_myapp', 'fluxDockerNetwork_dep')).to.not.be.rejected; + }); + + it('should rethrow non-403 errors', async () => { + const error = new Error('network fluxDockerNetwork_dep not found'); + error.statusCode = 404; + const connectStub = sinon.stub().rejects(error); + sinon.stub(Dockerode.prototype, 'getNetwork').returns({ connect: connectStub }); + + await expect(dockerService.appDockerNetworkConnect('fluxweb_myapp', 'fluxDockerNetwork_dep')).to.be.rejectedWith('not found'); + }); + }); + describe('appDockerCreate tests', () => { let dockerStub; let advancedWorkflowsStub; From d44707f1b6278e42c8502f6393ec7a20a036324b Mon Sep 17 00:00:00 2001 From: Valter Silva Date: Sat, 23 May 2026 10:33:00 +0100 Subject: [PATCH 2/3] refactor: address review feedback on app network linker - extract APP_NAME_REGEX (v8+) and APP_NAME_REGEX_LEGACY (v<=7 / components) into appConstants; consume from appValidator and appNetworkLinker - move getAppContainerNames / getAppContainerObjects into dockerService; anchor the multi-component match to ^(?:flux|zel)[a-zA-Z0-9]+_$ and escape regex metacharacters in the app name; refactor getNextAvailableIPForApp to use the same helper - rewrite appDockerNetworkConnect to inspect the container's NetworkSettings.Networks first and skip the connect when already attached; drop the blanket 403 catch (overloaded by docker) in favour of a narrow already-exists message match as a TOCTOU race fallback - update affected unit tests --- .../services/appLifecycle/appNetworkLinker.js | 37 +------- .../services/appRequirements/appValidator.js | 10 ++- ZelBack/src/services/dockerService.js | 76 ++++++++++++++++- ZelBack/src/services/utils/appConstants.js | 9 ++ tests/unit/appNetworkLinker.test.js | 27 ++---- tests/unit/appValidator.test.js | 2 + tests/unit/dockerService.test.js | 84 ++++++++++++++++++- 7 files changed, 179 insertions(+), 66 deletions(-) diff --git a/ZelBack/src/services/appLifecycle/appNetworkLinker.js b/ZelBack/src/services/appLifecycle/appNetworkLinker.js index f9fe82c32..aee95e123 100644 --- a/ZelBack/src/services/appLifecycle/appNetworkLinker.js +++ b/ZelBack/src/services/appLifecycle/appNetworkLinker.js @@ -24,10 +24,7 @@ const config = require('config'); const dbHelper = require('../dbHelper'); const dockerService = require('../dockerService'); const log = require('../../lib/log'); -const { localAppsInformation } = require('../utils/appConstants'); - -// Flux app name syntax (version 8 superset — alphanumerics with internal hyphens). -const appNameRegex = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/; +const { localAppsInformation, APP_NAME_REGEX } = require('../utils/appConstants'); /** * Parses the `networkWith:[...]` token out of an app description. @@ -48,7 +45,7 @@ function parseNetworkWith(description) { match[1].split(',').forEach((raw) => { const name = raw.trim().replace(/^["']+|["']+$/g, '').trim(); const key = name.toLowerCase(); - if (name && appNameRegex.test(name) && !seen.has(key)) { + if (name && APP_NAME_REGEX.test(name) && !seen.has(key)) { seen.add(key); names.push(name); } @@ -71,31 +68,6 @@ function getLinkedApps(appSpecs) { return parseNetworkWith(appSpecs.description).filter((linked) => linked.toLowerCase() !== selfName); } -/** - * Resolves the docker container names belonging to an installed app by - * inspecting docker directly. This is robust for enterprise apps, whose stored - * `compose` array is blanked in the local database. - * - * @param {string} appName - application name - * @returns {Promise} docker container names (without leading slash) - */ -async function getAppContainerNames(appName) { - const containers = await dockerService.dockerListContainers(true); - const singleComponentName = dockerService.getAppIdentifier(appName); - const names = []; - (containers || []).forEach((container) => { - (container.Names || []).forEach((rawName) => { - const name = rawName.replace(/^\//, ''); - // component container: flux_; single-component app: flux / zel - const belongsToApp = name === singleComponentName || name.endsWith(`_${appName}`); - if (belongsToApp && !names.includes(name)) { - names.push(name); - } - }); - }); - return names; -} - /** * Verifies every app this app is linked to is installed locally and owned by * the same owner. Throws otherwise, aborting the install/redeploy. @@ -187,7 +159,7 @@ async function reconnectLinkedApps(appName) { } try { // eslint-disable-next-line no-await-in-loop - const containerNames = await getAppContainerNames(app.name); + const containerNames = await dockerService.getAppContainerNames(app.name); // eslint-disable-next-line no-restricted-syntax for (const containerName of containerNames) { // eslint-disable-next-line no-await-in-loop @@ -226,7 +198,7 @@ async function reconcileAllAppNetworkLinks() { } try { // eslint-disable-next-line no-await-in-loop - const containerNames = await getAppContainerNames(app.name); + const containerNames = await dockerService.getAppContainerNames(app.name); // eslint-disable-next-line no-restricted-syntax for (const linkedApp of linkedApps) { const networkName = `fluxDockerNetwork_${linkedApp}`; @@ -247,7 +219,6 @@ async function reconcileAllAppNetworkLinks() { module.exports = { parseNetworkWith, getLinkedApps, - getAppContainerNames, checkAppNetworkRequirements, connectComponentToLinkedApps, reconnectLinkedApps, diff --git a/ZelBack/src/services/appRequirements/appValidator.js b/ZelBack/src/services/appRequirements/appValidator.js index e9bcc1181..f86e544f9 100644 --- a/ZelBack/src/services/appRequirements/appValidator.js +++ b/ZelBack/src/services/appRequirements/appValidator.js @@ -11,7 +11,9 @@ const messageVerifier = require('../appMessaging/messageVerifier'); const imageManager = require('../appSecurity/imageManager'); // const advancedWorkflows = require('../appLifecycle/advancedWorkflows'); // Moved to dynamic require to avoid circular dependency // eslint-disable-next-line no-unused-vars -const { supportedArchitectures, enterpriseRequiredArchitectures } = require('../utils/appConstants'); +const { + supportedArchitectures, enterpriseRequiredArchitectures, APP_NAME_REGEX, APP_NAME_REGEX_LEGACY, +} = require('../utils/appConstants'); const { specificationFormatter, findCommonArchitectures } = require('../utils/appUtilities'); const { checkAndDecryptAppSpecs } = require('../utils/enterpriseHelper'); const portManager = require('../appNetwork/portManager'); @@ -573,10 +575,10 @@ function verifyRestrictionCorrectnessOfApp(appSpecifications, height) { } // Version 8+ allows hyphens in app names (but not as first or last character) if (appSpecifications.version >= 8) { - if (!appSpecifications.name.match(/^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/)) { + if (!APP_NAME_REGEX.test(appSpecifications.name)) { throw new Error('Flux App name contains special characters. Only a-z, A-Z, 0-9 and hyphens are allowed (hyphens cannot be first or last character)'); } - } else if (!appSpecifications.name.match(/^[a-zA-Z0-9]+$/)) { + } else if (!APP_NAME_REGEX_LEGACY.test(appSpecifications.name)) { throw new Error('Flux App name contains special characters. Only a-z, A-Z and 0-9 are allowed'); } if (appSpecifications.name.startsWith('zel')) { @@ -703,7 +705,7 @@ function verifyRestrictionCorrectnessOfApp(appSpecifications, height) { throw new Error('Flux App Component name can not start with flux'); } // furthermore name cannot contain any special character - if (!appComponent.name.match(/^[a-zA-Z0-9]+$/)) { + if (!APP_NAME_REGEX_LEGACY.test(appComponent.name)) { throw new Error('Flux App component name contains special characters. Only a-z, A-Z and 0-9 are allowed'); } if (usedNames.includes(appComponent.name)) { diff --git a/ZelBack/src/services/dockerService.js b/ZelBack/src/services/dockerService.js index 55b2212ee..c400227ea 100644 --- a/ZelBack/src/services/dockerService.js +++ b/ZelBack/src/services/dockerService.js @@ -623,8 +623,7 @@ async function getNextAvailableIPForApp(appName) { }); } - const allContainers = await docker.listContainers({ all: true }); - const filteredContainers = allContainers.filter((container) => container.Names.some((name) => name.endsWith(`_${appName}`))); + const filteredContainers = await getAppContainerObjects(appName); // eslint-disable-next-line no-restricted-syntax for (const container of filteredContainers) { @@ -1428,23 +1427,90 @@ async function forceRemoveFluxAppDockerNetwork(appname) { * Connects a container to an existing docker network. Idempotent — if the * container is already attached to the network this resolves without error. * + * Strategy: inspect the container's NetworkSettings.Networks and return early + * if the network is already present. Falls back to a narrow string-match catch + * only for the connect race window (another caller wired the container between + * our inspect and connect). The previous blanket 403 catch is gone — 403 is + * overloaded by docker for unrelated failure modes (e.g. forbidden swarm-scoped + * operations) and was silently masking them. + * * @param {string} containerIdOrName - container id or name * @param {string} networkName - target docker network name * @returns {Promise} */ async function appDockerNetworkConnect(containerIdOrName, networkName) { + try { + const containerInfo = await docker.getContainer(containerIdOrName).inspect(); + const attached = containerInfo && containerInfo.NetworkSettings && containerInfo.NetworkSettings.Networks; + if (attached && Object.prototype.hasOwnProperty.call(attached, networkName)) { + return; + } + } catch (error) { + // Inspect failed (container not found, transient docker error). Let the + // connect attempt below surface the real error message. + } + const network = docker.getNetwork(networkName); try { await network.connect({ Container: containerIdOrName }); } catch (error) { - // 403 - endpoint already exists in network (container already connected) - if (error.statusCode === 403 || /already exists|already connected/i.test(error.message || '')) { + if (/already exists in network|already connected/i.test(error.message || '')) { return; } throw error; } } +/** + * Escapes a string for safe use inside a RegExp source. + */ +function escapeRegExp(str) { + return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Returns the docker container summary objects (output of listContainers) + * belonging to a given Flux app — both the modern component form + * `flux_` / `zel_` and the + * legacy single-component form `flux` / `zel`. + * + * Docker-listing based on purpose: the local DB blanks `compose` for enterprise + * apps, so iterating spec.compose would miss components on non-Arcane nodes. The + * regex anchors the `flux`/`zel` prefix so non-Flux containers cannot match. + * + * @param {string} appName + * @returns {Promise>} container summary objects + */ +async function getAppContainerObjects(appName) { + const containers = await dockerListContainers(true); + const singleComponentSlashName = getAppDockerNameIdentifier(appName); + const componentRegex = new RegExp(`^/(?:flux|zel)[a-zA-Z0-9]+_${escapeRegExp(appName)}$`); + + return (containers || []).filter((container) => { + const names = container.Names || []; + return names.some((name) => name === singleComponentSlashName || componentRegex.test(name)); + }); +} + +/** + * Returns the docker container names (without leading slash) belonging to a + * given Flux app. Thin wrapper around getAppContainerObjects. + * + * @param {string} appName + * @returns {Promise} + */ +async function getAppContainerNames(appName) { + const objects = await getAppContainerObjects(appName); + const names = []; + objects.forEach((container) => { + const raw = container.Names && container.Names[0]; + if (!raw) return; + const name = raw.replace(/^\//, ''); + if (!names.includes(name)) names.push(name); + }); + return names; +} + /** * Remove all unused containers. Unused contaienrs are those wich are not running */ @@ -1664,6 +1730,8 @@ module.exports = { removeFluxAppDockerNetwork, forceRemoveFluxAppDockerNetwork, appDockerNetworkConnect, + getAppContainerNames, + getAppContainerObjects, getAppNameByContainerIp, waitForDocker, }; diff --git a/ZelBack/src/services/utils/appConstants.js b/ZelBack/src/services/utils/appConstants.js index 4b3cd0e69..b7ddbfdca 100644 --- a/ZelBack/src/services/utils/appConstants.js +++ b/ZelBack/src/services/utils/appConstants.js @@ -23,6 +23,11 @@ const globalAppStateEvents = config.database.appsglobal.collections.appStateEven const globalAppsInstallingErrorsLocations = config.database.appsglobal.collections.appsInstallingErrorsLocations; const globalAppsInstallingErrorsBroadcasts = config.database.appsglobal.collections.appsInstallingErrorsBroadcasts; +// App / component name validation regexes. +// v8+ app names allow internal hyphens; v<=7 app names and all component names are strictly alphanumeric. +const APP_NAME_REGEX = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/; +const APP_NAME_REGEX_LEGACY = /^[a-zA-Z0-9]+$/; + // Supported architectures const supportedArchitectures = ['amd64', 'arm64']; @@ -96,6 +101,10 @@ module.exports = { globalAppsInstallingErrorsLocations, globalAppsInstallingErrorsBroadcasts, + // Validation regexes + APP_NAME_REGEX, + APP_NAME_REGEX_LEGACY, + // Configuration supportedArchitectures, enterpriseRequiredArchitectures, diff --git a/tests/unit/appNetworkLinker.test.js b/tests/unit/appNetworkLinker.test.js index 41bb0a743..656b857ac 100644 --- a/tests/unit/appNetworkLinker.test.js +++ b/tests/unit/appNetworkLinker.test.js @@ -20,6 +20,7 @@ describe('appNetworkLinker tests', () => { const appConstantsStub = { localAppsInformation: 'localAppsInformation', + APP_NAME_REGEX: /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/, }; beforeEach(() => { @@ -30,8 +31,7 @@ describe('appNetworkLinker tests', () => { }; dockerServiceStub = { appDockerNetworkConnect: sinon.stub().resolves(), - dockerListContainers: sinon.stub().resolves([]), - getAppIdentifier: sinon.stub().callsFake((name) => (name.startsWith('flux') || name.startsWith('zel') ? name : `flux${name}`)), + getAppContainerNames: sinon.stub().resolves([]), }; logStub = { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub() }; @@ -153,20 +153,6 @@ describe('appNetworkLinker tests', () => { }); }); - describe('getAppContainerNames', () => { - it('returns component and single-component containers belonging to an app', async () => { - dockerServiceStub.dockerListContainers.resolves([ - { Names: ['/fluxweb_myapp'] }, - { Names: ['/fluxapi_myapp'] }, - { Names: ['/fluxother_differentapp'] }, - { Names: ['/fluxmyapp'] }, - ]); - const names = await appNetworkLinker.getAppContainerNames('myapp'); - expect(names).to.have.members(['fluxweb_myapp', 'fluxapi_myapp', 'fluxmyapp']); - expect(names).to.not.include('fluxother_differentapp'); - }); - }); - describe('reconnectLinkedApps', () => { it('reconnects only the apps that are networked with the given app', async () => { dbHelperStub.findInDatabase.resolves([ @@ -174,11 +160,8 @@ describe('appNetworkLinker tests', () => { { name: 'appC', description: 'no links here' }, { name: 'appA', description: 'networkWith:[appA]' }, ]); - dockerServiceStub.dockerListContainers.resolves([ - { Names: ['/fluxweb_appB'] }, - { Names: ['/fluxapi_appB'] }, - { Names: ['/fluxweb_appC'] }, - ]); + dockerServiceStub.getAppContainerNames.withArgs('appB').resolves(['fluxweb_appB', 'fluxapi_appB']); + dockerServiceStub.getAppContainerNames.withArgs('appC').resolves(['fluxweb_appC']); await appNetworkLinker.reconnectLinkedApps('appA'); @@ -199,7 +182,7 @@ describe('appNetworkLinker tests', () => { { name: 'appB', description: 'networkWith:[appA]' }, { name: 'appC', description: 'plain' }, ]); - dockerServiceStub.dockerListContainers.resolves([{ Names: ['/fluxweb_appB'] }]); + dockerServiceStub.getAppContainerNames.withArgs('appB').resolves(['fluxweb_appB']); await appNetworkLinker.reconcileAllAppNetworkLinks(); diff --git a/tests/unit/appValidator.test.js b/tests/unit/appValidator.test.js index 68d64f942..86cadcef9 100644 --- a/tests/unit/appValidator.test.js +++ b/tests/unit/appValidator.test.js @@ -100,6 +100,8 @@ describe('appValidator tests', () => { '../utils/appConstants': { supportedArchitectures: ['amd64', 'arm64'], enterpriseRequiredArchitectures: ['amd64'], + APP_NAME_REGEX: /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/, + APP_NAME_REGEX_LEGACY: /^[a-zA-Z0-9]+$/, }, '../utils/appUtilities': { specificationFormatter: sinon.stub().returnsArg(0), diff --git a/tests/unit/dockerService.test.js b/tests/unit/dockerService.test.js index 4637b5424..5c6e34ff5 100644 --- a/tests/unit/dockerService.test.js +++ b/tests/unit/dockerService.test.js @@ -720,7 +720,20 @@ describe('dockerService tests', () => { sinon.restore(); }); - it('should connect a container to the given network', async () => { + function stubInspectWithNetworks(networks) { + const inspectStub = sinon.stub().resolves({ NetworkSettings: { Networks: networks } }); + sinon.stub(Dockerode.prototype, 'getContainer').returns({ inspect: inspectStub }); + return inspectStub; + } + + function stubInspectThrows(error) { + const inspectStub = sinon.stub().rejects(error); + sinon.stub(Dockerode.prototype, 'getContainer').returns({ inspect: inspectStub }); + return inspectStub; + } + + it('connects the container when not already attached', async () => { + stubInspectWithNetworks({ bridge: {} }); const connectStub = sinon.stub().resolves(); const getNetworkStub = sinon.stub(Dockerode.prototype, 'getNetwork').returns({ connect: connectStub }); @@ -730,7 +743,28 @@ describe('dockerService tests', () => { sinon.assert.calledOnceWithExactly(connectStub, { Container: 'fluxweb_myapp' }); }); - it('should resolve without error when the container is already connected (403)', async () => { + it('skips the connect call when the container is already attached', async () => { + stubInspectWithNetworks({ fluxDockerNetwork_dep: {} }); + const connectStub = sinon.stub().resolves(); + sinon.stub(Dockerode.prototype, 'getNetwork').returns({ connect: connectStub }); + + await dockerService.appDockerNetworkConnect('fluxweb_myapp', 'fluxDockerNetwork_dep'); + + sinon.assert.notCalled(connectStub); + }); + + it('still attempts to connect when inspect fails', async () => { + stubInspectThrows(new Error('inspect transient')); + const connectStub = sinon.stub().resolves(); + sinon.stub(Dockerode.prototype, 'getNetwork').returns({ connect: connectStub }); + + await dockerService.appDockerNetworkConnect('fluxweb_myapp', 'fluxDockerNetwork_dep'); + + sinon.assert.calledOnceWithExactly(connectStub, { Container: 'fluxweb_myapp' }); + }); + + it('swallows the race-window already-exists error from connect', async () => { + stubInspectWithNetworks({ bridge: {} }); const error = new Error('endpoint with name fluxweb_myapp already exists in network fluxDockerNetwork_dep'); error.statusCode = 403; const connectStub = sinon.stub().rejects(error); @@ -739,7 +773,8 @@ describe('dockerService tests', () => { await expect(dockerService.appDockerNetworkConnect('fluxweb_myapp', 'fluxDockerNetwork_dep')).to.not.be.rejected; }); - it('should rethrow non-403 errors', async () => { + it('rethrows generic connect errors (no message match)', async () => { + stubInspectWithNetworks({ bridge: {} }); const error = new Error('network fluxDockerNetwork_dep not found'); error.statusCode = 404; const connectStub = sinon.stub().rejects(error); @@ -747,6 +782,49 @@ describe('dockerService tests', () => { await expect(dockerService.appDockerNetworkConnect('fluxweb_myapp', 'fluxDockerNetwork_dep')).to.be.rejectedWith('not found'); }); + + it('rethrows a generic 403 that is not already-exists', async () => { + stubInspectWithNetworks({ bridge: {} }); + const error = new Error('operation not permitted on swarm-scoped network'); + error.statusCode = 403; + const connectStub = sinon.stub().rejects(error); + sinon.stub(Dockerode.prototype, 'getNetwork').returns({ connect: connectStub }); + + await expect(dockerService.appDockerNetworkConnect('fluxweb_myapp', 'fluxDockerNetwork_dep')).to.be.rejectedWith('swarm-scoped'); + }); + }); + + describe('getAppContainerNames tests', () => { + afterEach(() => { + sinon.restore(); + }); + + it('returns multi-component and legacy single-component containers, anchored to flux/zel', async () => { + sinon.stub(Dockerode.prototype, 'listContainers').resolves([ + { Names: ['/fluxweb_myapp'] }, + { Names: ['/fluxapi_myapp'] }, + { Names: ['/fluxother_differentapp'] }, + { Names: ['/fluxmyapp'] }, + { Names: ['/zelmyapp'] }, + { Names: ['/someoneelse_myapp'] }, // missing flux/zel prefix — must NOT match + ]); + + const names = await dockerService.getAppContainerNames('myapp'); + + expect(names).to.have.members(['fluxweb_myapp', 'fluxapi_myapp', 'fluxmyapp']); + expect(names).to.not.include('fluxother_differentapp'); + expect(names).to.not.include('someoneelse_myapp'); + }); + + it('escapes regex metacharacters in the app name', async () => { + sinon.stub(Dockerode.prototype, 'listContainers').resolves([ + { Names: ['/fluxweb_my-app'] }, + ]); + + const names = await dockerService.getAppContainerNames('my-app'); + + expect(names).to.eql(['fluxweb_my-app']); + }); }); describe('appDockerCreate tests', () => { From 5b4e1a94d82f3a31e8fe47badca785e034e83b3e Mon Sep 17 00:00:00 2001 From: Valter Silva Date: Sat, 23 May 2026 10:51:35 +0100 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20cross-app=20LOG=3DSEND=20=E2=86=92?= =?UTF-8?q?=20LOG=3DCOLLECT=20discovery=20via=20networkWith?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a SEND component is being installed in an app whose own compose has no LOG=COLLECT component, walk every app it is networkWith-linked to and ship to the first linked app that exposes a collector. Reachability is provided by the existing networkWith wiring (sender's container is already attached to the linked app's private docker network). Enterprise linked apps whose compose is blanked in the local DB and cannot be decrypted on this node are skipped — the SEND container falls back to json-file logging with a warning. Same fallback applies if the collector container is not reachable at install time. - new appNetworkLinker.findLinkedAppLogCollector(fullAppSpecs) that resolves the linked app + component name (handles the legacy enviromentParameters typo too) - appDockerCreate calls it as a fallback after the existing in-compose collector lookup, only for SEND components --- .../services/appLifecycle/appNetworkLinker.js | 51 +++++++++++ ZelBack/src/services/dockerService.js | 22 +++++ tests/unit/appNetworkLinker.test.js | 88 +++++++++++++++++++ 3 files changed, 161 insertions(+) diff --git a/ZelBack/src/services/appLifecycle/appNetworkLinker.js b/ZelBack/src/services/appLifecycle/appNetworkLinker.js index aee95e123..8c18bf2c0 100644 --- a/ZelBack/src/services/appLifecycle/appNetworkLinker.js +++ b/ZelBack/src/services/appLifecycle/appNetworkLinker.js @@ -216,6 +216,56 @@ async function reconcileAllAppNetworkLinks() { } } +/** + * For a SEND component being installed in an app whose own compose array does + * NOT contain a LOG=COLLECT component, looks at every app this app is + * networkWith-linked to and returns the first linked app that owns a COLLECT + * component. The actual container name resolution happens in the caller. + * + * Enterprise linked apps whose `compose` is blanked in the local DB and not + * decryptable on this node are skipped — the SEND container will fall back to + * json-file logging. + * + * @param {object} fullAppSpecs - app specification of the app being installed + * @returns {Promise<{linkedAppName: string, collectorComponentName: string}|null>} + */ +async function findLinkedAppLogCollector(fullAppSpecs) { + const linkedApps = getLinkedApps(fullAppSpecs); + if (!linkedApps.length) { + return null; + } + + // Lazy require to avoid the circular dependency dockerService → appLifecycle/appNetworkLinker → appDatabase/registryManager → dockerService. + // eslint-disable-next-line global-require + const registryManager = require('../appDatabase/registryManager'); + + // eslint-disable-next-line no-restricted-syntax + for (const linkedAppName of linkedApps) { + let linkedSpec; + try { + // eslint-disable-next-line no-await-in-loop + linkedSpec = await registryManager.getApplicationSpecifications(linkedAppName); + } catch (error) { + log.warn(`findLinkedAppLogCollector: failed to read spec for ${linkedAppName}: ${error.message}`); + // eslint-disable-next-line no-continue + continue; + } + if (!linkedSpec || !Array.isArray(linkedSpec.compose) || !linkedSpec.compose.length) { + // No compose to scan — typical for enterprise apps on non-Arcane nodes. + // eslint-disable-next-line no-continue + continue; + } + const collectorComponent = linkedSpec.compose.find((component) => { + const envs = (component && (component.environmentParameters || component.enviromentParameters)) || []; + return envs.some((env) => typeof env === 'string' && env.startsWith('LOG=COLLECT')); + }); + if (collectorComponent && collectorComponent.name) { + return { linkedAppName, collectorComponentName: collectorComponent.name }; + } + } + return null; +} + module.exports = { parseNetworkWith, getLinkedApps, @@ -223,4 +273,5 @@ module.exports = { connectComponentToLinkedApps, reconnectLinkedApps, reconcileAllAppNetworkLinks, + findLinkedAppLogCollector, }; diff --git a/ZelBack/src/services/dockerService.js b/ZelBack/src/services/dockerService.js index c400227ea..e666c5cdf 100644 --- a/ZelBack/src/services/dockerService.js +++ b/ZelBack/src/services/dockerService.js @@ -836,6 +836,28 @@ async function appDockerCreate(appSpecifications, appName, isComponent, fullAppS syslogIP = await getNextAvailableIPForApp(appName); } + // Cross-app LOG=SEND → LOG=COLLECT discovery: if this is a SEND component and + // the app has no in-compose collector, look at every app this one is + // networkWith-linked to and ship to the first linked app exposing a COLLECT + // component. Reachability is provided by appNetworkLinker attaching this + // container to the linked app's docker network. + if (!syslogTarget && isSender && fullAppSpecs) { + // eslint-disable-next-line global-require + const appNetworkLinker = require('./appLifecycle/appNetworkLinker'); + const linkedCollector = await appNetworkLinker.findLinkedAppLogCollector(fullAppSpecs); + if (linkedCollector) { + const collectorContainerName = `flux${linkedCollector.collectorComponentName}_${linkedCollector.linkedAppName}`; + const linkedIP = await getContainerIP(collectorContainerName); + if (linkedIP) { + syslogTarget = linkedCollector.collectorComponentName; + syslogIP = linkedIP; + log.info(`Cross-app LOG: ${appName} sender will ship to ${collectorContainerName} at ${syslogIP}`); + } else { + log.warn(`Cross-app LOG: collector ${collectorContainerName} not reachable; ${appName} will fall back to json-file logging`); + } + } + } + let nodeId = null; let nodeIP = null; let labels = null; diff --git a/tests/unit/appNetworkLinker.test.js b/tests/unit/appNetworkLinker.test.js index 656b857ac..51b82d838 100644 --- a/tests/unit/appNetworkLinker.test.js +++ b/tests/unit/appNetworkLinker.test.js @@ -10,6 +10,7 @@ describe('appNetworkLinker tests', () => { let appNetworkLinker; let dbHelperStub; let dockerServiceStub; + let registryManagerStub; let logStub; const configStub = { @@ -33,12 +34,16 @@ describe('appNetworkLinker tests', () => { appDockerNetworkConnect: sinon.stub().resolves(), getAppContainerNames: sinon.stub().resolves([]), }; + registryManagerStub = { + getApplicationSpecifications: sinon.stub(), + }; logStub = { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub() }; appNetworkLinker = proxyquire('../../ZelBack/src/services/appLifecycle/appNetworkLinker', { config: configStub, '../dbHelper': dbHelperStub, '../dockerService': dockerServiceStub, + '../appDatabase/registryManager': registryManagerStub, '../../lib/log': logStub, '../utils/appConstants': appConstantsStub, }); @@ -176,6 +181,89 @@ describe('appNetworkLinker tests', () => { }); }); + describe('findLinkedAppLogCollector', () => { + it('returns null when there are no linked apps', async () => { + const result = await appNetworkLinker.findLinkedAppLogCollector({ name: 'appB', description: 'no token' }); + expect(result).to.equal(null); + sinon.assert.notCalled(registryManagerStub.getApplicationSpecifications); + }); + + it('returns the first linked app exposing a LOG=COLLECT component', async () => { + registryManagerStub.getApplicationSpecifications.withArgs('appA').resolves({ + name: 'appA', + compose: [ + { name: 'web', environmentParameters: ['FOO=BAR'] }, + { name: 'logsink', environmentParameters: ['LOG=COLLECT'] }, + ], + }); + + const result = await appNetworkLinker.findLinkedAppLogCollector({ + name: 'appB', + description: 'networkWith:[appA]', + }); + + expect(result).to.eql({ linkedAppName: 'appA', collectorComponentName: 'logsink' }); + }); + + it('accepts the legacy enviromentParameters (typo) field', async () => { + registryManagerStub.getApplicationSpecifications.withArgs('appA').resolves({ + name: 'appA', + compose: [{ name: 'logsink', enviromentParameters: ['LOG=COLLECT'] }], + }); + + const result = await appNetworkLinker.findLinkedAppLogCollector({ + name: 'appB', + description: 'networkWith:[appA]', + }); + + expect(result).to.eql({ linkedAppName: 'appA', collectorComponentName: 'logsink' }); + }); + + it('skips linked apps with blanked compose (enterprise on non-Arcane)', async () => { + registryManagerStub.getApplicationSpecifications.withArgs('appA').resolves({ name: 'appA', compose: [] }); + registryManagerStub.getApplicationSpecifications.withArgs('appC').resolves({ + name: 'appC', + compose: [{ name: 'collector', environmentParameters: ['LOG=COLLECT'] }], + }); + + const result = await appNetworkLinker.findLinkedAppLogCollector({ + name: 'appB', + description: 'networkWith:[appA,appC]', + }); + + expect(result).to.eql({ linkedAppName: 'appC', collectorComponentName: 'collector' }); + }); + + it('returns null when no linked app exposes a LOG=COLLECT component', async () => { + registryManagerStub.getApplicationSpecifications.withArgs('appA').resolves({ + name: 'appA', + compose: [{ name: 'web', environmentParameters: ['FOO=BAR'] }], + }); + + const result = await appNetworkLinker.findLinkedAppLogCollector({ + name: 'appB', + description: 'networkWith:[appA]', + }); + + expect(result).to.equal(null); + }); + + it('continues past a spec lookup that throws', async () => { + registryManagerStub.getApplicationSpecifications.withArgs('appA').rejects(new Error('db down')); + registryManagerStub.getApplicationSpecifications.withArgs('appC').resolves({ + name: 'appC', + compose: [{ name: 'collector', environmentParameters: ['LOG=COLLECT'] }], + }); + + const result = await appNetworkLinker.findLinkedAppLogCollector({ + name: 'appB', + description: 'networkWith:[appA,appC]', + }); + + expect(result).to.eql({ linkedAppName: 'appC', collectorComponentName: 'collector' }); + }); + }); + describe('reconcileAllAppNetworkLinks', () => { it('connects every linked app to each of its linked app networks', async () => { dbHelperStub.findInDatabase.resolves([