Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ZelBack/src/services/appLifecycle/advancedWorkflows.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions ZelBack/src/services/appLifecycle/appInstaller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -752,6 +766,13 @@ async function checkOrbitAppHealth(appSpecifications, appName, isComponent, res)
* @returns {Promise<void>} 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);

Expand Down Expand Up @@ -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}...`,
};
Expand Down Expand Up @@ -839,6 +865,13 @@ async function installApplicationHard(appSpecifications, appName, isComponent, r
* @returns {Promise<void>} 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);

Expand All @@ -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}...`,
};
Expand Down
277 changes: 277 additions & 0 deletions ZelBack/src/services/appLifecycle/appNetworkLinker.js
Original file line number Diff line number Diff line change
@@ -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_<linked>`), so
* it can reach that app's components by their docker DNS name
* `flux<component>_<linkedApp>` — 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<boolean>} 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<component>_<app>)
* @param {object} fullAppSpecs - full app specification of the parent app
* @returns {Promise<void>}
*/
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<void>}
*/
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<void>}
*/
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,
};
Loading
Loading