diff --git a/API/Backend/Config/setup.js b/API/Backend/Config/setup.js index 06cc80915..23c21f06d 100644 --- a/API/Backend/Config/setup.js +++ b/API/Backend/Config/setup.js @@ -1,7 +1,8 @@ const router = require("./routes/configs"); const triggerWebhooks = require("../Webhooks/processes/triggerwebhooks.js"); const configurePackageJson = require("../../../configure/package.json"); -const { MODE, isLean } = require("../Utils/deploymentMode"); +const { MODE } = require("../Utils/deploymentMode"); +const { enabled } = require("../Utils/capabilities"); let setup = { //Once the app initializes @@ -36,12 +37,18 @@ let setup = { ? "" : process.env.WEBSOCKET_ROOT_PATH || "", IS_DOCKER: process.env.IS_DOCKER, - WITH_STAC: isLean() ? "false" : process.env.WITH_STAC, - WITH_TIPG: isLean() ? "false" : process.env.WITH_TIPG, - WITH_TITILER: isLean() ? "false" : process.env.WITH_TITILER, - WITH_TITILER_PGSTAC: isLean() - ? "false" - : process.env.WITH_TITILER_PGSTAC, + WITH_STAC: enabled("localSidecars") + ? process.env.WITH_STAC + : "false", + WITH_TIPG: enabled("localSidecars") + ? process.env.WITH_TIPG + : "false", + WITH_TITILER: enabled("localSidecars") + ? process.env.WITH_TITILER + : "false", + WITH_TITILER_PGSTAC: enabled("localSidecars") + ? process.env.WITH_TITILER_PGSTAC + : "false", DEPLOYMENT_MODE: MODE, }); } diff --git a/API/Backend/Datasets/setup.js b/API/Backend/Datasets/setup.js index 387cc4a38..52f4cad36 100644 --- a/API/Backend/Datasets/setup.js +++ b/API/Backend/Datasets/setup.js @@ -1,17 +1,17 @@ const router = require("./routes/datasets"); -const { isFull } = require("../Utils/deploymentMode"); let setup = { + // Gated off in lean. The discovery seam (API/setups.js) skips the + // feature-presence hooks when this capability is disabled. + capability: "datasets", //Once the app initializes onceInit: (s) => { - if (isFull()) { - s.app.use( - s.ROOT_PATH + "/api/datasets", - s.ensureAdmin(), - s.checkHeadersCodeInjection, - s.setContentType, - router - ); - } + s.app.use( + s.ROOT_PATH + "/api/datasets", + s.ensureAdmin(), + s.checkHeadersCodeInjection, + s.setContentType, + router + ); }, //Once the server starts onceStarted: (s) => {}, diff --git a/API/Backend/Deployments/setup.js b/API/Backend/Deployments/setup.js index b20765083..c5bc306f8 100644 --- a/API/Backend/Deployments/setup.js +++ b/API/Backend/Deployments/setup.js @@ -1,23 +1,23 @@ -const { isLean } = require("../Utils/deploymentMode"); - // Requiring the model registers it with Sequelize so the global // sequelize.sync() on boot creates the `deployments` table in BOTH modes — // a later mode flip needs no migration. In full mode the table stays -// passive: the routes below are never mounted, so nothing writes to it. +// passive: the routes below are never mounted (capability gated off via the +// seam), so nothing writes to it. require("./models/deployment"); let setup = { + // The publish flow exists ONLY in lean. The discovery seam mounts the routes + // below only when this capability is enabled. + capability: "deployments", //Once the app initializes onceInit: (s) => { - if (isLean()) { - const routeDeployments = require("./routes/deployments"); - s.app.use( - s.ROOT_PATH + "/api/deployments", - s.ensureAdmin(), - s.checkHeadersCodeInjection, - routeDeployments.router - ); - } + const routeDeployments = require("./routes/deployments"); + s.app.use( + s.ROOT_PATH + "/api/deployments", + s.ensureAdmin(), + s.checkHeadersCodeInjection, + routeDeployments.router + ); }, //Once the server starts onceStarted: (s) => {}, diff --git a/API/Backend/Draw/setup.js b/API/Backend/Draw/setup.js index d38c1a979..42716ad46 100644 --- a/API/Backend/Draw/setup.js +++ b/API/Backend/Draw/setup.js @@ -4,39 +4,39 @@ const routerDraw = require("./routes/draw").router; const routerAggregations = require("./routes/aggregations"); const ufiles = require("./models/userfiles"); const file_histories = require("./models/filehistories"); -const { isFull } = require("../Utils/deploymentMode"); let setup = { + // Gated off in lean (route mounts). onceSynced still runs unconditionally at + // the seam, so Draw's tables are created in both modes. + capability: "draw", //Once the app initializes onceInit: (s) => { - if (isFull()) { - s.app.use( - s.ROOT_PATH + "/api/files", - s.ensureUser(), - s.checkHeadersCodeInjection, - s.setContentType, - s.stopGuests, - routerFiles - ); + s.app.use( + s.ROOT_PATH + "/api/files", + s.ensureUser(), + s.checkHeadersCodeInjection, + s.setContentType, + s.stopGuests, + routerFiles + ); - s.app.use( - s.ROOT_PATH + "/api/draw", - s.ensureUser(), - s.checkHeadersCodeInjection, - s.setContentType, - s.stopGuests, - routerDraw - ); + s.app.use( + s.ROOT_PATH + "/api/draw", + s.ensureUser(), + s.checkHeadersCodeInjection, + s.setContentType, + s.stopGuests, + routerDraw + ); - s.app.use( - s.ROOT_PATH + "/api/draw", - s.ensureUser(), - s.checkHeadersCodeInjection, - s.setContentType, - s.stopGuests, - routerAggregations - ); - } + s.app.use( + s.ROOT_PATH + "/api/draw", + s.ensureUser(), + s.checkHeadersCodeInjection, + s.setContentType, + s.stopGuests, + routerAggregations + ); }, //Once the server starts onceStarted: (s) => {}, diff --git a/API/Backend/Geodatasets/setup.js b/API/Backend/Geodatasets/setup.js index dbc29588b..3363d3ec2 100644 --- a/API/Backend/Geodatasets/setup.js +++ b/API/Backend/Geodatasets/setup.js @@ -2,20 +2,19 @@ const router = require("./routes/geodatasets"); const geodatasets = require("./models/geodatasets"); -const { isFull } = require("../Utils/deploymentMode"); - let setup = { + // Gated off in lean (route mounts). onceSynced still runs unconditionally at + // the seam, so the geodatasets table is created in both modes. + capability: "geodatasets", //Once the app initializes onceInit: (s) => { - if (isFull()) { - s.app.use( - s.ROOT_PATH + "/api/geodatasets", - s.ensureAdmin(), - s.checkHeadersCodeInjection, - s.setContentType, - router - ); - } + s.app.use( + s.ROOT_PATH + "/api/geodatasets", + s.ensureAdmin(), + s.checkHeadersCodeInjection, + s.setContentType, + router + ); }, //Once the server starts onceStarted: (s) => {}, diff --git a/API/Backend/Shortener/setup.js b/API/Backend/Shortener/setup.js index 37a631810..e305d2b7e 100644 --- a/API/Backend/Shortener/setup.js +++ b/API/Backend/Shortener/setup.js @@ -1,18 +1,18 @@ const router = require("./routes/shortener"); -const { isFull } = require("../Utils/deploymentMode"); let setup = { + // Gated off in lean. The discovery seam (API/setups.js) skips the + // feature-presence hooks when this capability is disabled. + capability: "shortener", //Once the app initializes onceInit: (s) => { - if (isFull()) { - s.app.use( - s.ROOT_PATH + "/api/shortener", - s.ensureUser(), - s.checkHeadersCodeInjection, - s.setContentType, - router - ); - } + s.app.use( + s.ROOT_PATH + "/api/shortener", + s.ensureUser(), + s.checkHeadersCodeInjection, + s.setContentType, + router + ); }, //Once the server starts onceStarted: (s) => {}, diff --git a/API/Backend/Utils/capabilities.js b/API/Backend/Utils/capabilities.js new file mode 100644 index 000000000..56da469da --- /dev/null +++ b/API/Backend/Utils/capabilities.js @@ -0,0 +1,77 @@ +/** + * capabilities.js + * The single authoritative definition of what each deployment mode includes. + * + * `deploymentMode.js` stays the one env read (MMGIS_DEPLOYMENT_MODE -> MODE); + * this file is the one *meaning* of each mode. Reading the FEATURES map below + * answers "what does lean turn off (and on)" without grepping call sites. + * + * A capability's rule is a predicate over a small context ({ mode }), so a + * future composite condition (two axes) does not force a rewrite from a flat + * boolean table. Booleans and mode-lists are accepted as sugar for the common + * single-axis case; full predicates are used only where they earn their keep. + * + * Contract (backend): an unknown capability THROWS — a typo is a deploy error, + * not a silently-disabled feature. (The frontend twin in + * configure/src/core/capabilities.js deliberately warns + hides instead, so a + * render-time gate fails safe rather than white-screening the SPA.) + * + * Transitional artifact: enumerating every gated feature by name in the core is + * the kind of core-knows-every-feature coupling the plugin direction aims to + * dissolve. The durable half is that a module/tool *declares* the capability it + * needs and a seam decides; expect a future plugin manifest to subsume this map. + */ + +const { MODE } = require("./deploymentMode"); + +// Sugar coercions: +// - boolean -> enabled in every mode (or none) +// - string[] -> enabled only in the listed modes +// - function -> predicate over { mode } +const coerce = (rule) => { + if (typeof rule === "function") return rule; + if (Array.isArray(rule)) return ({ mode }) => rule.includes(mode); + if (typeof rule === "boolean") return () => rule; + throw new Error(`Invalid capability rule: ${JSON.stringify(rule)}`); +}; + +// enabling mode(s) +const FEATURES = { + // Geodata management — gated off in lean. + datasets: ["full"], + geodatasets: ["full"], + // Collaborative drawing — gated off in lean. + draw: ["full"], + // Link shortener — gated off in lean. + shortener: ["full"], + // Dashboard publish flow — exists ONLY in lean. + deployments: ["lean"], + // On-disk Missions/ filesystem (static mount) plus the server-side raster / + // SPICE utils endpoints (GDAL + Python shellouts) that read it. Not a + // sidecar — its own capability. + localMissions: ["full"], + // The bundled sidecar cluster: the adjacent-servers proxy mounts, the + // adjacent-servers spawner, the mmgis-stac database creation, and the + // derived WITH_* Configure flags. They always move together, so one row. + localSidecars: ["full"], +}; + +const RULES = Object.fromEntries( + Object.entries(FEATURES).map(([name, rule]) => [name, coerce(rule)]) +); + +/** + * enabled(capability) -> boolean + * Throws on an unknown capability so typos fail at boot. + */ +const enabled = (capability) => { + const rule = RULES[capability]; + if (rule == null) { + throw new Error( + `Unknown capability: '${capability}'. Add it to API/Backend/Utils/capabilities.js or fix the typo.` + ); + } + return rule({ mode: MODE }); +}; + +module.exports = { enabled }; diff --git a/API/Backend/Utils/routes/utils.js b/API/Backend/Utils/routes/utils.js index 0d7a240d0..2c9bd6b4d 100644 --- a/API/Backend/Utils/routes/utils.js +++ b/API/Backend/Utils/routes/utils.js @@ -12,7 +12,7 @@ const execFile = require("child_process").execFile; const Sequelize = require("sequelize"); const { sequelizeSTAC } = require("../../../connection"); const logger = require("../../../logger"); -const { isFull } = require("../deploymentMode"); +const { enabled } = require("../capabilities"); const rootDir = `${__dirname}/../../../..`; @@ -240,7 +240,7 @@ router.get("/healthcheck", function (req, res) { // Missions/ tree, local SPICE data, or shells out to Python — none of which // exist in a lean container. healthcheck (above) is the only utils route // lean serves. -if (isFull()) { +if (enabled("localMissions")) { // Reads the on-disk Missions/<...>/_time_/ tree router.get("/queryTilesetTimes", function (req, res) { if (req.query.stacCollection != null) queryTilesetTimesStac(req, res); @@ -400,6 +400,6 @@ if (isFull()) { } ); }); -} // if (isFull()) — full-only utils routes +} // if (enabled("localMissions")) — full-only utils routes module.exports = router; diff --git a/API/setups.js b/API/setups.js index e16a58f39..66e57c15a 100644 --- a/API/setups.js +++ b/API/setups.js @@ -2,6 +2,19 @@ const fs = require("fs"); const path = require("path"); const logger = require("./logger"); +const { enabled } = require("./Backend/Utils/capabilities"); + +// A setup with no `capability` is always wired (Users, Accounts, Config, …). +// A setup declaring `capability: ""` is wired only when that capability +// is enabled in the current deployment mode. This is the single seam where the +// per-module on/off decision lives — module bodies stay mode-agnostic. +// INVARIANT: this gates the feature-presence hooks (onceInit, onceStarted) +// ONLY. onceSynced (model sync) runs unconditionally, so gated-off features +// still get their tables in every mode and a later mode flip needs no +// migration. Because the gate lives here, a gated setup's onceInit assumes it +// is enabled — only this discovery seam may call it. +const isSetupEnabled = (setup) => + setup == null || setup.capability == null || enabled(setup.capability); let getBackendSetups = (cb) => { let setups = {}; @@ -170,15 +183,24 @@ let getBackendSetups = (cb) => { cb({ init: (s) => { for (let f in setups) { + // A module declaring `capability` is wired only when that + // capability is enabled in the current mode. onceInit is + // feature-presence (route mounts) — gate it. + if (!isSetupEnabled(setups[f])) continue; if (typeof setups[f].onceInit === "function") setups[f].onceInit(s); } }, started: (s) => { - for (let f in setups) + for (let f in setups) { + // onceStarted is feature-presence too — same gate as onceInit. + if (!isSetupEnabled(setups[f])) continue; if (typeof setups[f].onceStarted === "function") setups[f].onceStarted(s); + } }, synced: (s) => { + // UNCONDITIONAL by design: model sync must run in every mode so + // gated-off tables still exist (no migration on a later mode flip). for (let f in setups) if (typeof setups[f].onceSynced === "function") setups[f].onceSynced(s); diff --git a/API/updateTools.js b/API/updateTools.js index c50aa980e..67f4f1728 100644 --- a/API/updateTools.js +++ b/API/updateTools.js @@ -2,7 +2,7 @@ const fs = require("fs"); const path = require("path"); const logger = require("./logger"); -const { isLean } = require("./Backend/Utils/deploymentMode"); +const { enabled } = require("./Backend/Utils/capabilities"); function updateTools() { let tools = {}; @@ -39,15 +39,18 @@ function updateTools() { return; } - // Lean deployments exclude the Draw tool entirely - if (isLean() && items[i].name === "Draw") continue; - if (isDir && items[i].name[0] != "_" && items[i].name[0] != ".") { try { const contents = fs.readFileSync( toolsPath + "/" + items[i].name + "/config.json" ); const jsonContent = JSON.parse(contents); + // A tool may declare the capability it needs in its config.json; it is + // excluded from the registry when that capability is off in this mode + // (e.g. Draw declares "capability": "draw", gated out of lean). This + // replaces the former hardcoded name check. + if (jsonContent.capability != null && !enabled(jsonContent.capability)) + continue; tools[items[i].name] = jsonContent; } catch (err) { logger( diff --git a/adjacent-servers/adjacent-servers-proxy.js b/adjacent-servers/adjacent-servers-proxy.js index b811e50e4..e44a0e286 100644 --- a/adjacent-servers/adjacent-servers-proxy.js +++ b/adjacent-servers/adjacent-servers-proxy.js @@ -4,18 +4,10 @@ const { } = require("http-proxy-middleware"); const logger = require("../API/logger"); -const { isFull } = require("../API/Backend/Utils/deploymentMode"); +// Mode-agnostic: the caller (scripts/server.js) gates this behind the +// `localSidecars` capability. Do not add a mode check here. function initAdjacentServersProxy(app, isDocker, ensureAdmin) { - if (!isFull()) { - logger( - "info", - "adjacent-servers proxy disabled (deployment mode = lean)", - "adjacent-servers" - ); - return; - } - /////////////////////////// // Proxies //// STAC diff --git a/adjacent-servers/adjacent-servers.js b/adjacent-servers/adjacent-servers.js index bb9e9cba2..f13ff00fc 100755 --- a/adjacent-servers/adjacent-servers.js +++ b/adjacent-servers/adjacent-servers.js @@ -1,18 +1,10 @@ require("dotenv").config(); const logger = require("../API/logger"); const { spawn } = require("child_process"); -const { isFull } = require("../API/Backend/Utils/deploymentMode"); +// Mode-agnostic: the caller (scripts/server.js) gates this behind the +// `localSidecars` capability. Do not add a mode check here. function adjacentServers() { - if (!isFull()) { - logger( - "info", - "adjacent-servers spawner disabled (deployment mode = lean)", - "adjacent-servers" - ); - return; - } - const IS_WINDOWS = /^win/i.test(process.platform) ? true : false; const EXT = IS_WINDOWS ? ".bat" : ".sh"; const CMD = IS_WINDOWS ? "" : "sh "; diff --git a/configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js b/configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js index 36e75b5a1..50368ea64 100644 --- a/configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js +++ b/configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { isLeanMode } from "../../core/capabilities"; +import { isCapabilityEnabled } from "../../core/capabilities"; import { STATUS, TRANSITIONAL_STATUSES } from "../../core/deploymentStatus"; import { useSelector, useDispatch } from "react-redux"; @@ -54,13 +54,13 @@ export default function DeploymentsWatcher() { const inFlightRef = useRef(false); const lastDiffedRef = useRef(null); - const isLean = isLeanMode(); + const hasDeployments = isCapabilityEnabled("deployments"); const isWatching = Object.keys(deploymentsWatch).length > 0; // Diff each deployments list against the watch set: auto-watch // transitional rows, toast watched rows that reached a terminal status. useEffect(() => { - if (!isLean) return; + if (!hasDeployments) return; const byId = {}; deployments.forEach((d) => { @@ -129,12 +129,12 @@ export default function DeploymentsWatcher() { severity: "info", }) ); - }, [deployments, deploymentsWatch, dispatch, isLean]); + }, [deployments, deploymentsWatch, dispatch, hasDeployments]); // Poll while any watch is open. Keyed on the boolean so watch-set churn // doesn't reset the timer's phase. useEffect(() => { - if (!isLean || !isWatching) return; + if (!hasDeployments || !isWatching) return; const interval = setInterval(() => { // Skip the tick if the previous poll hasn't answered yet so a slow @@ -150,7 +150,7 @@ export default function DeploymentsWatcher() { }, POLL_INTERVAL_MS); return () => clearInterval(interval); - }, [isWatching, dispatch, isLean]); + }, [isWatching, dispatch, hasDeployments]); return null; } diff --git a/configure/src/components/Panel/Panel.js b/configure/src/components/Panel/Panel.js index b34c5a20d..76a014957 100644 --- a/configure/src/components/Panel/Panel.js +++ b/configure/src/components/Panel/Panel.js @@ -14,7 +14,7 @@ import { setSnackBarText, } from "../../core/ConfigureStore"; import { calls } from "../../core/calls"; -import { isLeanMode } from "../../core/capabilities"; +import { isCapabilityEnabled } from "../../core/capabilities"; import NewMissionModal from "./Modals/NewMissionModal/NewMissionModal"; @@ -317,7 +317,7 @@ export default function Panel() {
- {!isLeanMode() ? ( + {isCapabilityEnabled("geodatasets") ? ( - {isLeanMode() ? ( + {isCapabilityEnabled("deployments") ? (