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..8c18bf2c0 --- /dev/null +++ b/ZelBack/src/services/appLifecycle/appNetworkLinker.js @@ -0,0 +1,277 @@ +/** + * 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, APP_NAME_REGEX } = require('../utils/appConstants'); + +/** + * 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 && APP_NAME_REGEX.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); +} + +/** + * 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 dockerService.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 dockerService.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}`); + } + } +} + +/** + * 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, + checkAppNetworkRequirements, + connectComponentToLinkedApps, + reconnectLinkedApps, + reconcileAllAppNetworkLinks, + findLinkedAppLogCollector, +}; 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/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 b27af92a8..e666c5cdf 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) { @@ -837,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; @@ -1424,6 +1445,94 @@ 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) { + 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 */ @@ -1642,6 +1751,9 @@ module.exports = { pruneVolumes, 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 new file mode 100644 index 000000000..51b82d838 --- /dev/null +++ b/tests/unit/appNetworkLinker.test.js @@ -0,0 +1,285 @@ +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 registryManagerStub; + let logStub; + + const configStub = { + database: { + appslocal: { database: 'localapps' }, + }, + }; + + const appConstantsStub = { + localAppsInformation: 'localAppsInformation', + APP_NAME_REGEX: /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/, + }; + + beforeEach(() => { + dbHelperStub = { + databaseConnection: sinon.stub().returns({ db: sinon.stub().returns('appsDB') }), + findOneInDatabase: sinon.stub(), + findInDatabase: sinon.stub(), + }; + dockerServiceStub = { + 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, + }); + }); + + 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('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.getAppContainerNames.withArgs('appB').resolves(['fluxweb_appB', 'fluxapi_appB']); + dockerServiceStub.getAppContainerNames.withArgs('appC').resolves(['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('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([ + { name: 'appB', description: 'networkWith:[appA]' }, + { name: 'appC', description: 'plain' }, + ]); + dockerServiceStub.getAppContainerNames.withArgs('appB').resolves(['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/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 077d8325e..5c6e34ff5 100644 --- a/tests/unit/dockerService.test.js +++ b/tests/unit/dockerService.test.js @@ -715,6 +715,118 @@ describe('dockerService tests', () => { }); }); + describe('appDockerNetworkConnect tests', () => { + afterEach(() => { + sinon.restore(); + }); + + 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 }); + + await dockerService.appDockerNetworkConnect('fluxweb_myapp', 'fluxDockerNetwork_dep'); + + sinon.assert.calledOnceWithExactly(getNetworkStub, 'fluxDockerNetwork_dep'); + sinon.assert.calledOnceWithExactly(connectStub, { Container: 'fluxweb_myapp' }); + }); + + 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); + sinon.stub(Dockerode.prototype, 'getNetwork').returns({ connect: connectStub }); + + await expect(dockerService.appDockerNetworkConnect('fluxweb_myapp', 'fluxDockerNetwork_dep')).to.not.be.rejected; + }); + + 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); + sinon.stub(Dockerode.prototype, 'getNetwork').returns({ connect: connectStub }); + + 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', () => { let dockerStub; let advancedWorkflowsStub;