diff --git a/RELEASES.md b/RELEASES.md index e159cf5..9ea0c31 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -29,6 +29,14 @@ selected artifact, updates one DSP at a time and pauses on failure. A completed rollout sets the default version for new DSPs; a Dev candidate never becomes that default. DSPs created during a rollout join its remaining queue. +Each DSP release still includes runtime code and all catalog plugin packages. +Core caches the full verified release, then copies only runtime code and catalog +metadata into each DSP. Optional plugin code is copied on Install and upgraded +only for DSPs where that plugin is installed (enabled or disabled). Later installs +use the plugin version approved by the DSP's selected release. Fresh DSPs expose +only the built-in Cortex connection. Existing full runtime copies are retained +for compatibility and rollback; new release copies omit plugin payloads. + Updates require a verified initial split deployment, a permanent Dev DSP and the separate update worker. The first published `0.0.1` Core predates these controls; it cannot install this feature by itself. See [update operations](core/updates/README.md) diff --git a/core/accounts/src/plugins.js b/core/accounts/src/plugins.js index 8d7178f..36eabe3 100644 --- a/core/accounts/src/plugins.js +++ b/core/accounts/src/plugins.js @@ -42,10 +42,19 @@ function createPluginService({ store, access, invoke, settingsPort = null, insta || store.db.prepare("SELECT 1 FROM directory_lifecycle_requests WHERE organization_id=? AND status IN ('queued','running')").get(selected.organization.id)) fail('installation_not_ready'); return { ...selected, installation }; } + // Internal session serialization and the Plugins endpoint must use the same + // release-scoped catalog. The organization id comes from the signed session. + function listForOrganization(organizationId) { + const key = store.installationControl(organizationId)?.runtimeKey; + return listFor(store, organizationId).flatMap(item => { + const approved = installationCoordinator?.latest?.(item.id, key); + if (installationCoordinator && !approved && item.state === 'uninstalled') return []; + return [{ ...item, latestVersion: approved?.version || item.version, automaticUpdates: true }]; + }); + } function list(session) { const { organization } = access.organizationFor(session, 'dashboard.view'); - const key = store.installationControl(organization.id)?.runtimeKey; - return { items: listFor(store, organization.id).filter(item => !installationCoordinator || installationCoordinator.latest(item.id,key) || item.state !== 'uninstalled').map(item=>({...item,latestVersion:installationCoordinator?.latest?.(item.id,key)?.version || item.version,automaticUpdates:true})) }; + return { items: listForOrganization(organization.id) }; } function change(session, id, input) { const selected = context(session); @@ -199,7 +208,7 @@ function createPluginService({ store, access, invoke, settingsPort = null, insta })().finally(() => { running = null; }); return running; } - return { list, change, runPending, + return { list, listForOrganization, change, runPending, settings: require('./plugin-settings').createPluginSettings({store,access,port:settingsPort}), catalog: () => ({ items: catalog().map(publicPlugin) }) }; } diff --git a/core/accounts/tests/plugin-fixture.js b/core/accounts/tests/plugin-fixture.js index a072b95..941031f 100644 --- a/core/accounts/tests/plugin-fixture.js +++ b/core/accounts/tests/plugin-fixture.js @@ -5,7 +5,7 @@ const path = require('node:path'); const { AccessStore, AccessControlService } = require('../src'); const { createPluginService } = require('../src/plugins'); const { success } = require('../../../shared/contracts/src/result'); -async function fixture(t, { installationCoordinator = null, settingsPort = null } = {}) { +async function fixture(t, { installationCoordinator = null, settingsPort = null, dspCount = 2 } = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-plugins-')); fs.chmodSync(root, 0o700); const paths = { databaseRoot: path.join(root, 'access'), database: path.join(root, 'access/access.sqlite3') }; @@ -17,7 +17,7 @@ async function fixture(t, { installationCoordinator = null, settingsPort = null const platform = await access.acceptNewUser({ token: bootstrap.token, firstName: 'Platform', lastName: 'Owner', password, confirmPassword: password }); const dsps = []; const runtimes = new Map(); - for (let index = 0; index < 2; index++) { + for (let index = 0; index < dspCount; index++) { const created = access.createOrganization(platform.session, { idempotencyKey: `fixture:plugin:${index}`, name: `Plugin DSP ${index}`, abbreviation: `FX${index}`, stationCode: 'TST1', timezone: 'America/Chicago', ownerEmail: `owner${index}@example.test` }); const owner = await access.acceptNewUser({ token: created.token, firstName: 'DSP', lastName: 'Owner', password, confirmPassword: password }); diff --git a/core/api/access-http.js b/core/api/access-http.js index 8d54c37..bcd9e2d 100644 --- a/core/api/access-http.js +++ b/core/api/access-http.js @@ -100,7 +100,9 @@ function createAccessHttp({ stations: membership.organization.stations, }, })), - plugins: current.activeOrganizationId ? require('../accounts/src/plugins').listFor(access.store, current.activeOrganizationId) : [], + plugins: current.activeOrganizationId ? (plugins?.listForOrganization + ? plugins.listForOrganization(current.activeOrganizationId) + : require('../accounts/src/plugins').listFor(access.store, current.activeOrganizationId)) : [], csrfToken: current.csrfToken, expiresAt: current.expiresAt, }; diff --git a/core/plugins/package-catalog.js b/core/plugins/package-catalog.js index 1f30ddf..dac0a31 100644 --- a/core/plugins/package-catalog.js +++ b/core/plugins/package-catalog.js @@ -39,7 +39,9 @@ function packageCatalog({ local }) { if (!raw) return null; const value=normalizeCatalog(raw),entries=new Map(value.items.map(item=>[`${item.pluginId}@${item.version}`,item])); const latest = (id,runtimeKey) => { - const version = (runtimeKey && value.approved.dsps[runtimeKey]?.[id]) || value.approved.production[id]; + const approved = runtimeKey && Object.hasOwn(value.approved.dsps, runtimeKey) + ? value.approved.dsps[runtimeKey] : value.approved.production; + const version = approved[id]; return version ? entries.get(`${id}@${version}`) : null; }; const resolve = (id,version) => { diff --git a/core/updates/README.md b/core/updates/README.md index 0e92300..8e4e8f5 100644 --- a/core/updates/README.md +++ b/core/updates/README.md @@ -15,7 +15,7 @@ release download. Commands never accept an arbitrary repository or executable. | `core/updates/commands.js`, `worker.js` | Persistent owner commands, one external worker and periodic release discovery. | | `core/updates/directory.js`, `transport.js` | Private worker-to-API socket and DSP activation under the directory controller. | | `host/releases/core.js`, `core-state.js` | Stop Core services, snapshot Core state, swap code, verify API/database health and recover. | -| `host/releases/dsp.js`, `runtime.js` | Drain one DSP, snapshot its state, select its runtime/plugins and restore that DSP on failure. | +| `host/releases/dsp.js`, `runtime.js`, `runtime-package.js` | Drain one DSP, snapshot its state, copy and verify runtime files, select its installed plugins and restore that DSP on failure. | | `host/releases/provisioning.js` | Give new DSPs the last completely rolled-out release. | | `host/releases/setup.js`, `bin/dispatch-updates` | Configure permanent Dev, register an existing split baseline, prepare the independent worker and queue offline recovery. | | `dashboard/frontend/src/pages/Updates.tsx` | Owner controls, changelogs, progress and recovery UI. | @@ -24,7 +24,9 @@ release download. Commands never accept an arbitrary repository or executable. | `local/state/dsp-releases/` | Each DSP's selected release receipt. | | `local/backups/updates/` | Private Core and per-DSP rollback snapshots. | | `local/tools/update-worker/` | Retained bootstrap code that survives a Core directory swap. | -| `dsps//runtime/releases/` | Independently installed DSP runtime releases. | +| `dsps//runtime/releases/` | DSP-owned runtime and catalog metadata; newly prepared copies omit optional plugin payloads. | +| `local/packages/plugins/` | Shared verified plugin package cache, used for installation and release approval. | +| `dsps//plugins//versions/` | That DSP's installed plugin code and retained rollback versions. | The worker checks both feeds when idle, about every five minutes. **Check for updates** refreshes the selected product. Every install/start-rollout command @@ -33,6 +35,25 @@ An active rollout continues using its pinned digest when a newer release appears Release downloads only stage verified files. They do not select code or start services. Shared dependencies remain versioned copies inside each DSP release. +DSP code and optional plugin packages continue to ship in one DSP release. The +host keeps that full release, but each DSP receives only the runtime and catalog +metadata. The original release manifest/digest authenticates the exact copied +subset. Install copies a sealed plugin into only the requesting DSP. Rollout +updates enabled and disabled installed plugins to the selected release versions; +uninstalled plugins are not copied. New DSPs have only the built-in Cortex +connection until plugins are installed, and use the last completed fleet release. +New plugins are visible only to DSPs on a release that approves them. Update Dev +exposes them to Dev first; rollout exposes them to each DSP as it updates. Both +session bootstrap and the Plugins endpoint apply this same catalog filter, so +signing in or opening a platform-owner DSP view cannot reveal a Dev-only plugin. + +Existing full runtime copies remain readable for rollback. Installing Core does +not rewrite those copies. New DSP creation and preparation of a new DSP release +use the smaller layout. Previously installed or historical release code remains +retained for recovery; this change does not delete credentials, settings, business +data or rollback packages. Runtime-only copies are verified by the host's +`verifyRuntime`; `verifyRelease` still requires the complete publication archive. + ## Initial deployment and configuration These steps belong to a separately reviewed installation procedure. They are not @@ -137,8 +158,9 @@ The browser checks cover independent controls, newer-release reset, paused rollout, sequential resume and mobile layout. CI runs the same browser checks. `tests/architecture/release-update.acceptance.js` additionally installs a real -Paycom package in a private Linux/systemd namespace lab, switches DSP releases, -wakes a sleeping DSP and checks rollback of its private state. Provide sealed +Paycom package in a private Linux/systemd namespace lab. It verifies Cortex-only +startup without optional plugin payloads, installs Paycom, switches DSP and +Paycom versions, wakes a sleeping DSP and checks rollback of its private state. Provide sealed Node/tini and browser tool roots plus explicit Core/DSP release directories via `DISPATCH_WORKER_TEST_TOOLS`, `DISPATCH_WORKER_TEST_BROWSER`, `DISPATCH_UPDATE_TEST_CORE` and `DISPATCH_UPDATE_TEST_DSP`. It uses synthetic data diff --git a/core/updates/tests/runtime-package.test.js b/core/updates/tests/runtime-package.test.js new file mode 100644 index 0000000..c15eaf8 --- /dev/null +++ b/core/updates/tests/runtime-package.test.js @@ -0,0 +1,79 @@ +'use strict'; +const test = require('node:test'), assert = require('node:assert/strict'); +const fs = require('node:fs'), os = require('node:os'), path = require('node:path'); +const { hash, inventory, secureCopy, verifyRelease } = require('../../../shared/releases/package'); +const { prepareDspRelease, selectDspRelease, runtimeSource } = require('../../../host/releases/runtime'); +const { verifyRuntime } = require('../../../host/releases/runtime-package'); +const ID = 'dsp_' + 'a'.repeat(32); + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-runtime-package-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const paths = { local: path.join(root, 'local'), dsps: path.join(root, 'dsps') }; + const source = path.join(root, 'release'); + for (const [name, bytes] of Object.entries({ + 'code/runtime/supervisor.js': 'runtime', 'code/compatibility/cortex.js': 'built-in connection', + 'code/plugins/paycom/dispatch-plugin.json': '{}', 'code/node_modules/dispatch-sdk/index.js': 'dependency', + 'plugins/paycom/backend.js': 'optional Paycom code', 'plugins/paycom/frontend.js': 'optional Paycom frontend', + 'plugins/sample/backend.js': 'another optional plugin', 'release-notes.md': 'notes', + })) { + fs.mkdirSync(path.dirname(path.join(source, name)), { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(source, name), bytes, { mode: 0o600 }); + } + const manifest = { schemaVersion: 1, product: 'dsp', version: '1.0.0', channel: 'release', protocol: 1, + minimumProtocol: 1, sourceDigest: 'a'.repeat(64), plugins: [], files: inventory(source) }; + fs.writeFileSync(path.join(source, 'release.json'), JSON.stringify(manifest), { mode: 0o600 }); + const digest = hash(JSON.stringify(manifest)); + const prepare = () => prepareDspRelease(paths, ID, source, digest); + return { root, paths, source, manifest, digest, prepare }; +} + +test('DSP runtime copies omit every optional plugin payload and retain the original authenticated manifest', t => { + const f = fixture(t), installed = f.prepare(); + assert.equal(fs.existsSync(path.join(installed.directory, 'plugins')), false); + assert.equal(fs.existsSync(path.join(installed.directory, 'code/compatibility/cortex.js')), true); + assert.equal(fs.existsSync(path.join(installed.directory, 'code/plugins/paycom/dispatch-plugin.json')), true); + assert.equal(fs.existsSync(path.join(f.paths.dsps, ID, 'plugins/paycom')), false); + assert.deepEqual(verifyRuntime(installed.directory, f.digest), f.manifest); + assert.deepEqual(verifyRelease(f.source, f.digest), f.manifest); + assert.equal(fs.readFileSync(path.join(installed.directory, 'release.json'), 'utf8'), fs.readFileSync(path.join(f.source, 'release.json'), 'utf8')); + assert.notEqual(fs.statSync(path.join(installed.directory, 'code/runtime/supervisor.js')).ino, fs.statSync(path.join(f.source, 'code/runtime/supervisor.js')).ino); + selectDspRelease(f.paths, ID, f.digest, null); + assert.equal(runtimeSource(f.paths, ID), path.join(installed.directory, 'code')); + assert.deepEqual(f.prepare(), installed); +}); + +for (const change of ['runtime bytes', 'missing runtime', 'extra file', 'partial plugin', 'symlink', 'manifest']) { + test(`runtime verification rejects ${change}`, t => { + const f = fixture(t), installed = f.prepare(), runtime = path.join(installed.directory, 'code/runtime/supervisor.js'); + selectDspRelease(f.paths, ID, f.digest, null); + if (change === 'runtime bytes') fs.writeFileSync(runtime, 'changed'); + if (change === 'missing runtime') fs.unlinkSync(runtime); + if (change === 'extra file') fs.writeFileSync(path.join(installed.directory, 'code/extra.js'), 'extra'); + if (change === 'partial plugin') { + fs.mkdirSync(path.join(installed.directory, 'plugins/paycom'), { recursive: true }); + fs.copyFileSync(path.join(f.source, 'plugins/paycom/backend.js'), path.join(installed.directory, 'plugins/paycom/backend.js')); + } + if (change === 'symlink') { fs.unlinkSync(runtime); fs.symlinkSync(path.join(f.source, 'code/runtime/supervisor.js'), runtime); } + if (change === 'manifest') fs.writeFileSync(path.join(installed.directory, 'release.json'), JSON.stringify({ ...f.manifest, version: '2.0.0' })); + assert.throws(() => runtimeSource(f.paths, ID), /release_(digest_mismatch|entry_invalid|manifest_invalid)/); + }); +} + +test('invalid plugin bytes in the downloaded release are rejected even though they will not be copied to the DSP', t => { + const f = fixture(t); + fs.writeFileSync(path.join(f.source, 'plugins/paycom/backend.js'), 'tampered'); + assert.throws(f.prepare, /release_digest_mismatch/); + assert.equal(fs.existsSync(path.join(f.paths.dsps, ID, 'runtime/releases', f.digest)), false); +}); + +test('legacy full DSP releases remain verified and selectable for rollback', t => { + const f = fixture(t), target = path.join(f.paths.dsps, ID, 'runtime/releases', f.digest); + fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); + secureCopy(f.source, target); + selectDspRelease(f.paths, ID, f.digest, null); + assert.equal(runtimeSource(f.paths, ID), path.join(target, 'code')); + assert.equal(f.prepare().directory, target); + fs.writeFileSync(path.join(target, 'plugins/paycom/backend.js'), 'tampered'); + assert.throws(() => runtimeSource(f.paths, ID), /release_digest_mismatch/); +}); diff --git a/core/updates/tests/selective-delivery-fixture.js b/core/updates/tests/selective-delivery-fixture.js new file mode 100644 index 0000000..2f51e5f --- /dev/null +++ b/core/updates/tests/selective-delivery-fixture.js @@ -0,0 +1,97 @@ +'use strict'; +const fs = require('node:fs'), path = require('node:path'); +const { fixture: accounts } = require('../../accounts/tests/plugin-fixture'); +const { privateDirectory, withLock } = require('../../../host/controller/operations'); +const { ensureDsp } = require('../../../host/storage/storage'); +const { atomic } = require('../../installations/src/release-delivery-files'); +const { sealPackage } = require('../../../tooling/build-plugin-package'); +const { hash, inventory } = require('../../../shared/releases/package'); +const { packageCatalog } = require('../../plugins/package-catalog'); +const { LocalReleases } = require('../local-releases'); +const { dspHooks } = require('../../../host/releases/dsp'); +const { withCreation } = require('../../../host/releases/provisioning'); +const { fileFor } = require('../../../host/releases/runtime'); +const { createDirectoryInstallation } = require('../../../host/plugins/directory-lifecycle'); +const { createPluginService } = require('../../accounts/src/plugins'); + +async function fixture(t) { + const f = await accounts(t, { dspCount: 4 }); + const paths = { platformRoot: f.root }; + for (const name of ['local', 'live', 'dsps', 'dev', 'worktrees']) paths[name] = privateDirectory(path.join(f.root, name)); + const records = new Map(), roots = new Map(), events = []; + let failedDigest = null; + for (const dsp of f.dsps) { + const creationId = 'create_' + dsp.runtimeKey.slice(4); + roots.set(dsp.runtimeKey, ensureDsp(paths, dsp.runtimeKey, creationId).root); + records.set(dsp.runtimeKey, { id: dsp.runtimeKey, creationId, latestRequest: 'a'.repeat(64), desiredState: 'running' }); + f.store.db.prepare('INSERT INTO plugin_migration_checks(organization_id) VALUES(?)').run(dsp.id); + } + const manager = { + journal: { record: id => records.get(id), saveRecord: value => records.set(value.id, value) }, + checkedDsp: record => ({ root: roots.get(record.id) }), credentials: () => {}, bridge: async () => {}, + ready: async id => { if (JSON.parse(fs.readFileSync(fileFor(paths, id))).digest === failedDigest) throw new Error('release_health_failed'); }, + apply: async () => {}, + host: { stop: async id => events.push(['stop', id]), prepare: async () => {}, start: async id => events.push(['start', id]) }, + pluginBackend: { request: async (id, action, input) => { + events.push([action, id, input.version]); + if (action === 'plugin.initialize') return true; + if (action !== 'plugin.revoke') throw new Error('unexpected_backend_request'); + } }, + }; + const execution = { eligible: () => false, locked: (_id, work) => work() }; + const hooks = dspHooks({ paths, store: f.store, manager, execution }); + const releases = new LocalReleases({ directory: path.join(paths.local, 'state/updates'), devDspId: f.dsps[0].runtimeKey, hooks, allowDevelopment: true }); + const coordinator = createDirectoryInstallation({ paths, store: f.store, manager, execution }); + const plugins = createPluginService({ store: f.store, access: f.access, installationCoordinator: coordinator, invoke: async () => { throw new Error('unexpected_runtime_request'); } }); + const definitions = () => packageCatalog(paths)?.definitions() || []; + require('../../../shared/plugin-sdk/catalog').configureCatalog(definitions); + require('dispatch-protocol/plugin-sdk/catalog').configureCatalog(definitions); + t.after(() => { + const defaults = () => [require('../../../tests/fixtures/paycom-plugin.json')]; + require('../../../shared/plugin-sdk/catalog').configureCatalog(defaults); + require('dispatch-protocol/plugin-sdk/catalog').configureCatalog(defaults); + }); + function artifact(product, version, pluginVersion = null, pluginIds = ['paycom', 'sample']) { + const directory = privateDirectory(path.join(paths.dev, product + '-' + version)); + privateDirectory(path.join(directory, 'code/runtime')); + fs.writeFileSync(path.join(directory, 'code/runtime/index.js'), `module.exports='${version}';`, { mode: 0o600 }); + const packaged = []; + if (pluginVersion) for (const id of pluginIds) { + const root = privateDirectory(path.join(directory, 'plugins', id)); + privateDirectory(path.join(root, 'backend')); privateDirectory(path.join(root, 'migrations')); + const definition = { ...require('../../../tests/fixtures/paycom-plugin.json'), id, name: id, version: pluginVersion, + frontend: null, dashboard: null, published: null, runtime: 'backend/index.js', + pages: [], actions: [], httpPrefixes: [], gatewayActions: [], services: id === 'paycom' ? ['paycom'] : [], + collectors: [], syncs: [], jobs: [], legacyProfile: null, + package: { runtime: 'backend/index.js', authentication: null, collections: 'migrations/collections.json' } }; + delete definition.settings; + atomic(path.join(root, 'dispatch-plugin.json'), definition); + fs.writeFileSync(path.join(root, 'backend/index.js'), `module.exports='${id}@${pluginVersion}';`, { mode: 0o600 }); + atomic(path.join(root, 'migrations/collections.json'), { schemaVersion: 1, collectors: [], sources: [], plans: [], syncs: [] }); + packaged.push({ pluginId: id, version: pluginVersion, digest: sealPackage(root).digest }); + privateDirectory(path.join(directory, 'code/plugins', id)); + atomic(path.join(directory, 'code/plugins', id, 'dispatch-plugin.json'), definition); + } + const manifest = { schemaVersion: 1, product, version, channel: 'development', protocol: 1, minimumProtocol: 1, + sourceDigest: 'a'.repeat(64), plugins: packaged, files: inventory(directory) }; + atomic(path.join(directory, 'release.json'), manifest); + return { directory, manifest, digest: hash(JSON.stringify(manifest)) }; + } + const core = artifact('core', '1.0.0'), before = artifact('dsp', '1.0.0', '1.0.0'), next = artifact('dsp', '1.1.0', '1.1.0'); + for (const item of [core, before, next]) await releases.stage(item.directory, item.digest); + const state = releases.state(); state.active.core = core.digest; state.defaultDsp = before.digest; releases.save(state); + privateDirectory(path.join(paths.local, 'config')); + atomic(path.join(paths.local, 'config/updates.json'), { schemaVersion: 1, devDspId: f.dsps[0].runtimeKey, apiPort: 4999 }); + const provision = dsp => withCreation(paths, 'create', assign => withLock(paths, fd => assign(dsp.runtimeKey, fd))); + for (const dsp of f.dsps) await provision(dsp); + const change = async (dsp, action) => { + const item = plugins.list(dsp.owner).items.find(item => item.id === 'paycom'); + plugins.change(dsp.owner, 'paycom', { action, expectedRevision: item.revision, idempotencyKey: `fixture:${action}:${item.revision}` }); + await plugins.runPending(); + const current = plugins.list(dsp.owner).items.find(item => item.id === 'paycom'); + if (current.failureCode || current.pending) throw new Error(JSON.stringify(current)); + return current; + }; + return { ...f, paths, roots, releases, before, next, artifact, plugins, change, provision, events, fail: digest => { failedDigest = digest; } }; +} +module.exports = { fixture }; diff --git a/core/updates/tests/selective-delivery.test.js b/core/updates/tests/selective-delivery.test.js new file mode 100644 index 0000000..8d967b4 --- /dev/null +++ b/core/updates/tests/selective-delivery.test.js @@ -0,0 +1,87 @@ +'use strict'; +const test = require('node:test'), assert = require('node:assert/strict'); +const fs = require('node:fs'), path = require('node:path'); +const { fixture } = require('./selective-delivery-fixture'); +const { installationReceipt } = require('../../../host/plugins/install'); +const { packageCatalog } = require('../../plugins/package-catalog'); +const { runtimeSource } = require('../../../host/releases/runtime'); +const pluginFile = (f, dsp, version) => path.join(f.roots.get(dsp.runtimeKey), 'plugins/paycom/versions', version, 'backend/index.js'); + +test('provisioning and delayed installs keep optional plugin bytes out of DSPs until each owner installs', async t => { + const f = await fixture(t), [dev, a, b] = f.dsps; + for (const dsp of f.dsps) { + assert.equal(fs.existsSync(path.join(path.dirname(runtimeSource(f.paths, dsp.runtimeKey)), 'plugins')), false); + assert.equal(fs.existsSync(path.join(f.roots.get(dsp.runtimeKey), 'plugins/paycom')), false); + assert.equal(fs.existsSync(path.join(f.roots.get(dsp.runtimeKey), 'plugins/sample')), false); + assert.equal(f.plugins.list(dsp.owner).items.find(item => item.id === 'paycom').state, 'uninstalled'); + } + await f.change(dev, 'install'); + await f.releases.updateDev(f.next.digest); + assert.equal(installationReceipt(f.roots.get(dev.runtimeKey), 'paycom').version, '1.1.0'); + assert.equal(fs.existsSync(pluginFile(f, a, '1.1.0')), false); + await f.provision(b); // Even a provisioning retry while Dev is newer keeps the fleet baseline. + assert.equal(packageCatalog(f.paths).latest('paycom', b.runtimeKey).version, '1.0.0'); + await f.change(a, 'install'); + assert.match(fs.readFileSync(pluginFile(f, a, '1.0.0'), 'utf8'), /paycom@1.0.0/); + assert.equal(fs.existsSync(pluginFile(f, b, '1.0.0')), false); + assert.notEqual(fs.statSync(pluginFile(f, a, '1.0.0')).ino, fs.statSync(path.join(f.paths.local, 'packages/plugins/paycom/1.0.0/backend/index.js')).ino); +}); + +test('DSP rollout upgrades enabled and disabled installed plugins one DSP at a time and skips uninstalled plugins', async t => { + const f = await fixture(t), [dev, a, b, c] = f.dsps; + for (const dsp of [dev, a, b]) await f.change(dsp, 'install'); + await f.change(b, 'disable'); + for (const dsp of f.dsps) for (const root of ['config', 'data', 'secrets']) { + fs.writeFileSync(path.join(f.roots.get(dsp.runtimeKey), root, 'sentinel'), `${dsp.runtimeKey}:${root}`, { mode: 0o600 }); + } + await f.releases.updateDev(f.next.digest); + assert.equal(installationReceipt(f.roots.get(a.runtimeKey), 'paycom').version, '1.0.0'); + await f.releases.beginRollout(f.next.digest, f.dsps.map(dsp => dsp.runtimeKey)); + const targets = f.releases.state().rollout.targets; + for (const id of targets) { + const before = f.releases.state().active.dsps; + await f.releases.step(); + const after = f.releases.state().active.dsps; + assert.equal(after[id], f.next.digest); + for (const other of f.dsps.map(dsp => dsp.runtimeKey).filter(key => key !== id)) assert.equal(after[other], before[other]); + } + for (const dsp of [dev, a, b]) assert.equal(installationReceipt(f.roots.get(dsp.runtimeKey), 'paycom').version, '1.1.0'); + assert.equal(installationReceipt(f.roots.get(b.runtimeKey), 'paycom').state, 'disabled'); + assert.equal(fs.existsSync(path.join(f.roots.get(c.runtimeKey), 'plugins/paycom')), false); + for (const dsp of f.dsps) { + assert.equal(fs.existsSync(path.join(f.roots.get(dsp.runtimeKey), 'plugins/sample')), false); + for (const root of ['config', 'data', 'secrets']) assert.equal(fs.readFileSync(path.join(f.roots.get(dsp.runtimeKey), root, 'sentinel'), 'utf8'), `${dsp.runtimeKey}:${root}`); + } + await f.change(c, 'install'); + assert.equal(installationReceipt(f.roots.get(c.runtimeKey), 'paycom').version, '1.1.0'); + assert.equal(fs.existsSync(pluginFile(f, c, '1.0.0')), false); +}); + +test('failed plugin update restores the installed version, DSP release approvals and private state', async t => { + const f = await fixture(t), [dev, other] = f.dsps; + await f.change(dev, 'install'); + const sentinel = path.join(f.roots.get(dev.runtimeKey), 'data/sentinel'); + fs.writeFileSync(sentinel, 'private before', { mode: 0o600 }); + f.fail(f.next.digest); + await assert.rejects(f.releases.updateDev(f.next.digest), /release_health_failed/); + assert.equal(installationReceipt(f.roots.get(dev.runtimeKey), 'paycom').version, '1.0.0'); + assert.equal(packageCatalog(f.paths).latest('paycom', dev.runtimeKey).version, '1.0.0'); + assert.equal(f.releases.state().active.dsps[dev.runtimeKey], f.before.digest); + assert.equal(fs.readFileSync(sentinel, 'utf8'), 'private before'); + assert.equal(fs.existsSync(pluginFile(f, dev, '1.1.0')), false); + assert.equal(fs.existsSync(pluginFile(f, other, '1.0.0')), false); + assert.equal(f.releases.state().tested, null); +}); + +test('a release cannot drop an installed plugin; an uninstalled plugin receives no later code', async t => { + const f = await fixture(t), [dev] = f.dsps; + await f.change(dev, 'install'); + const empty = f.artifact('dsp', '1.2.0'); + await f.releases.stage(empty.directory, empty.digest); + await assert.rejects(f.releases.updateDev(empty.digest), /release_installed_plugin_missing/); + await f.change(dev, 'uninstall'); + await f.releases.updateDev(empty.digest); + assert.equal(packageCatalog(f.paths).latest('paycom', dev.runtimeKey), null); + assert.equal(fs.existsSync(pluginFile(f, dev, '1.1.0')), false); + assert.equal(fs.existsSync(pluginFile(f, dev, '1.0.0')), true, 'previously installed code is retained for recovery'); +}); diff --git a/dashboard/tests/plugins.test.js b/dashboard/tests/plugins.test.js index 9f951de..efc9212 100644 --- a/dashboard/tests/plugins.test.js +++ b/dashboard/tests/plugins.test.js @@ -4,6 +4,51 @@ const test = require('node:test'); const { fixture } = require('../../core/accounts/tests/plugin-fixture'); const { createDashboardServer } = require('../server/server'); const { success } = require('../../shared/contracts/src/result'); + +test('new plugins stay Dev-only in session bootstrap and the Plugins API until each DSP receives the rollout', async t => { + const f = await require('../../core/updates/tests/selective-delivery-fixture').fixture(t); + const [dev, production] = f.dsps; + const candidate = f.artifact('dsp', '1.2.0', '1.2.0', ['paycom', 'sample', 'new-plugin']); + await f.releases.stage(candidate.directory, candidate.digest); + const client = { workforce: { day() {} }, sync: { status() {}, runNow() {} }, system: { status() {} } }; + const server = createDashboardServer({ access: f.access, client, plugins: f.plugins, runtimeResolver: () => client }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise(resolve => server.close(resolve))); + const base = `http://127.0.0.1:${server.address().port}`; + async function visible(dsp, expected, view = null) { + const headers = { cookie: `dispatch_session=${view ? f.platform.token : dsp.token}`, + ...(view ? { 'x-dispatch-dsp-view': view } : {}) }; + for (const route of ['/api/auth/session', '/api/organization/plugins']) { + const response = await fetch(base + route, { headers }); + assert.equal(response.status, 200); + const { data } = await response.json(); + const items = route.endsWith('session') ? data.plugins : data.items; + assert.equal(items.some(item => item.id === 'new-plugin'), expected, `${route} for ${dsp.runtimeKey}`); + assert.equal(items.filter(item => item.id === 'new-plugin').every(item => item.state === 'uninstalled'), true); + } + } + const owner = f.access.session(f.platform.token); + const view = f.access.beginDspView(owner, { controlRef: f.access.issuePlatformControlRef(owner, production.id) }); + for (const dsp of f.dsps) await visible(dsp, false); + await f.releases.updateDev(candidate.digest); + await visible(dev, true); + for (const dsp of f.dsps.slice(1)) await visible(dsp, false); + await visible(production, false, view.dspView.viewRef); + const install = await fetch(base + '/api/organization/plugins/new-plugin', { method: 'POST', headers: { + cookie: `dispatch_session=${production.token}`, 'content-type': 'application/json', 'x-dispatch-csrf': production.owner.csrfToken, + }, body: JSON.stringify({ action: 'install', expectedRevision: 0, idempotencyKey: 'fixture:hidden:install' }) }); + assert.equal(install.status, 409); + assert.equal((await install.json()).error.code, 'plugin_package_not_approved'); + assert.equal(f.store.db.prepare("SELECT count(*) count FROM dsp_plugins WHERE plugin_id='new-plugin'").get().count, 0); + await f.releases.beginRollout(candidate.digest, f.dsps.map(dsp => dsp.runtimeKey)); + const updated = new Set([dev.runtimeKey]); + for (const id of f.releases.state().rollout.targets) { + await f.releases.step(); updated.add(id); + for (const dsp of f.dsps) await visible(dsp, updated.has(dsp.runtimeKey)); + } + await visible(production, true, view.dspView.viewRef); + assert.equal(f.store.db.prepare("SELECT count(*) count FROM dsp_plugins WHERE plugin_id='new-plugin'").get().count, 0); +}); test('installed assets require current installation and signed DSP scope without waking a runtime', async t => { const f = await fixture(t); const [a, b] = f.dsps; require('../../core/accounts/tests/plugin-fixture').enableFixturePlugin(f.store, a.id); diff --git a/host/plugins/README.md b/host/plugins/README.md index 1272572..9974719 100644 --- a/host/plugins/README.md +++ b/host/plugins/README.md @@ -1,5 +1,12 @@ # Installed plugin packages +DSP releases contain the runtime, catalog metadata and separate sealed plugin +packages. Core retains the complete verified release and plugin cache. Preparing +a DSP copies only the runtime and metadata into its runtime release directory; +optional plugin backends, frontends and dependencies are not copied there. +New DSPs expose the built-in Cortex connection and start with no optional plugins +installed. Catalog metadata does not enable a plugin or create its credentials. + `install.js` verifies approved package digests and copies actual files into each DSP's `plugins//versions//` directory. It rejects links, unexpected files, path traversal, incompatible SDK versions and mutated version contents. @@ -13,6 +20,13 @@ permits Core acknowledgement. Interrupted acknowledgement resumes without repeating completed initialization. Initialization must itself use the supplied operation id to recover a crash before its completion receipt is written. +The DSP's selected release approves the exact plugin versions available to it. +Update Dev and sequential rollout update only that DSP's installed plugins, +including disabled installations, while preserving their enabled/disabled state. +Plugins not installed remain absent; a later Install uses that DSP release's +approved version. An explicit per-DSP catalog never inherits additional plugins +from the legacy global catalog, even when the per-DSP catalog is empty. + Disable and uninstall drain work and change activation state. They retain credentials, data, profiles and package bytes needed for recovery. Reclaiming old versions and code/state rollback remain separate migration tasks. diff --git a/host/plugins/distribution.js b/host/plugins/distribution.js index db6dadd..d4919e2 100644 --- a/host/plugins/distribution.js +++ b/host/plugins/distribution.js @@ -42,14 +42,17 @@ async function distributePackage(paths, { directory, digest }, { lockFd } = {}) // Internal lifecycle port. The release coordinator calls this only for the // selected DSP after validating its release. No HTTP-supplied paths are accepted. async function approvePackages(paths, { runtimeKey, packages }, { lockFd } = {}) { - if (!/^[a-zA-Z0-9_-]{1,128}$/.test(runtimeKey) || !Array.isArray(packages) || !packages.length) throw new Error('plugin_approval_invalid'); + if (!/^[a-zA-Z0-9_-]{1,128}$/.test(runtimeKey) || !Array.isArray(packages)) throw new Error('plugin_approval_invalid'); const work = async () => { const file = path.join(paths.local, 'config/plugin-packages.json'); - const catalog = normalizeCatalog(privateJson(file, process.geteuid())); + privateDirectory(path.dirname(file)); + const catalog = normalizeCatalog(privateJson(file, process.geteuid(), true) + || { schemaVersion: 2, items: [], approved: { production: {}, dsps: {} } }); const verified = packageCatalog(paths); const selected = {}; for (const item of packages) { - const found = verified.resolve(item.pluginId, item.version); + const found = verified?.resolve(item.pluginId, item.version); + if (!found) throw new Error('plugin_package_unavailable'); if (found.digest !== item.digest || Object.hasOwn(selected,item.pluginId)) throw new Error('plugin_approval_invalid'); selected[item.pluginId] = item.version; } diff --git a/host/plugins/tests/distribution.test.js b/host/plugins/tests/distribution.test.js index c36a2fd..66121af 100644 --- a/host/plugins/tests/distribution.test.js +++ b/host/plugins/tests/distribution.test.js @@ -59,3 +59,23 @@ test('legacy approval is preserved before staging a new candidate', () => { old.items.push({pluginId:'paycom',version:'0.18.0',digest:'b'.repeat(64)}); assert.equal(normalizeCatalog(old).approved.production.paycom,'0.17.2'); }); + +test('an explicit DSP release catalog never falls back to global plugins, including an empty release', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-plugin-approvals-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const paths = { local: privateDirectory(path.join(root, 'local')) }; + await approvePackages(paths, { runtimeKey: 'empty', packages: [] }); + assert.equal(packageCatalog(paths).latest('paycom', 'empty'), null); + const file = path.join(paths.local, 'config/plugin-packages.json'); + const value = JSON.parse(fs.readFileSync(file)); + value.items.push({ pluginId: 'paycom', version: '1.0.0', digest: 'a'.repeat(64) }); + value.approved.production.paycom = '1.0.0'; + value.approved.dsps.selected = { paycom: '1.0.0' }; + atomic(file, value); + assert.equal(packageCatalog(paths).latest('paycom', 'legacy').version, '1.0.0'); + assert.equal(packageCatalog(paths).latest('paycom', 'selected').version, '1.0.0'); + assert.equal(packageCatalog(paths).latest('paycom', 'empty'), null); + await approvePackages(paths, { runtimeKey: 'selected', packages: [] }); + assert.equal(packageCatalog(paths).latest('paycom', 'selected'), null); + assert.throws(() => packageCatalog(paths).resolveApproved('paycom', '1.0.0', 'selected'), /plugin_package_not_approved/); +}); diff --git a/host/releases/dsp.js b/host/releases/dsp.js index f0ffd31..7fac8b3 100644 --- a/host/releases/dsp.js +++ b/host/releases/dsp.js @@ -40,7 +40,7 @@ function dspHooks({ paths, store, manager, execution }) { const value = privateJson(approvalsFile, process.geteuid(), true); if (!value) return {}; const catalog = normalizeCatalog(value); - return { ...catalog.approved.production, ...catalog.approved.dsps[id] }; + return { ...(Object.hasOwn(catalog.approved.dsps, id) ? catalog.approved.dsps[id] : catalog.approved.production) }; }; const stop = async c => { await manager.pluginBackend?.request(c.dspId, 'plugin.revoke', { pluginId: null }); @@ -102,7 +102,7 @@ function dspHooks({ paths, store, manager, execution }) { prepareDspRelease(paths, c.dspId, c.directory, c.digest); for (const item of c.manifest.plugins) await distributePackage(paths, { directory: path.join(c.directory, 'plugins', item.pluginId), digest: item.digest }, { lockFd }); - if (c.manifest.plugins.length) await approvePackages(paths, { runtimeKey: c.dspId, packages: c.manifest.plugins }, { lockFd }); + await approvePackages(paths, { runtimeKey: c.dspId, packages: c.manifest.plugins }, { lockFd }); selectDspRelease(paths, c.dspId, c.digest, c.previousDigest); for (const plugin of store.db.prepare("SELECT * FROM dsp_plugins WHERE organization_id=? AND desired_state<>'uninstalled'").all(row.organization_id)) { const selected = c.manifest.plugins.find(item => item.pluginId === plugin.plugin_id); diff --git a/host/releases/provisioning.js b/host/releases/provisioning.js index 0c6ec84..72d7b42 100644 --- a/host/releases/provisioning.js +++ b/host/releases/provisioning.js @@ -25,7 +25,7 @@ async function withCreation(paths, action, work) { prepareDspRelease(paths, id, release.directory, digest); for (const item of manifest.plugins) await distributePackage(paths, { directory: path.join(release.directory, 'plugins', item.pluginId), digest: item.digest }, { lockFd }); - if (manifest.plugins.length) await approvePackages(paths, { runtimeKey: id, packages: manifest.plugins }, { lockFd }); + await approvePackages(paths, { runtimeKey: id, packages: manifest.plugins }, { lockFd }); if (!existing) selectDspRelease(paths, id, digest, null); state.active.dsps[id] = digest; if (state.rollout && state.rollout.status !== 'completed' && id !== config.devDspId && !state.rollout.targets.includes(id)) state.rollout.targets.push(id); diff --git a/host/releases/runtime-package.js b/host/releases/runtime-package.js new file mode 100644 index 0000000..edf0d63 --- /dev/null +++ b/host/releases/runtime-package.js @@ -0,0 +1,40 @@ +'use strict'; +const fs = require('node:fs'), path = require('node:path'); +const { hash, inventory, secureCopy } = require('../../shared/releases/package'); + +const runtimeFile = file => !file.path.startsWith('plugins/'); + +// Keep the original release manifest and digest. The host has already verified +// the complete published release before deriving this DSP-owned runtime copy. +// Plugin payloads live in the host cache until the DSP installs them; code/plugins +// contains only catalog definitions needed by the runtime's existing contracts. +function copyRuntime(source, target) { + fs.mkdirSync(target, { mode: 0o700 }); + for (const name of fs.readdirSync(source)) { + if (name === 'plugins') continue; + const from = path.join(source, name), to = path.join(target, name); + const stat = fs.lstatSync(from); + if (stat.isDirectory()) secureCopy(from, to); + else if (stat.isFile()) { + fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL); + fs.chmodSync(to, stat.mode & 0o777 & ~0o022); + } else throw new Error('release_entry_invalid'); + } +} + +function verifyRuntime(directory, expectedDigest) { + const file = path.join(directory, 'release.json'), stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024 * 1024) throw new Error('release_manifest_invalid'); + const manifest = JSON.parse(fs.readFileSync(file)); + if (manifest.schemaVersion !== 1 || manifest.product !== 'dsp' || !Array.isArray(manifest.files) + || !/^[a-f0-9]{64}$/.test(expectedDigest) || hash(JSON.stringify(manifest)) !== expectedDigest) throw new Error('release_manifest_invalid'); + const actual = JSON.stringify(inventory(directory).filter(item => item.path !== 'release.json')); + // Retained full copies remain readable for existing deployments and rollback. + // Accept either complete layout, never a partly missing plugin/runtime tree. + if (actual !== JSON.stringify(manifest.files.filter(runtimeFile)) && actual !== JSON.stringify(manifest.files)) { + throw new Error('release_digest_mismatch'); + } + return manifest; +} + +module.exports = { copyRuntime, verifyRuntime }; diff --git a/host/releases/runtime.js b/host/releases/runtime.js index 13fd12c..e46fe86 100644 --- a/host/releases/runtime.js +++ b/host/releases/runtime.js @@ -1,6 +1,7 @@ 'use strict'; const fs=require('node:fs'),path=require('node:path'); -const {verifyRelease,secureCopy}=require('../../shared/releases/package'); +const {verifyRelease}=require('../../shared/releases/package'); +const {copyRuntime,verifyRuntime}=require('./runtime-package'); const {validateDspId}=require('../../shared/paths/platform-paths'); const {privateJson,atomic}=require('../../core/installations/src/release-delivery-files'); const {privateDirectory,syncDirectory}=require('../controller/operations'); @@ -11,7 +12,7 @@ function runtimeSource(paths,id) { if(!current){if(fs.existsSync(path.join(paths.live,'runtime')))return paths.live;throw new Error('dsp_release_required');} if(Object.keys(current).sort().join(',')!=='digest,schemaVersion'||current.schemaVersion!==1||!/^[a-f0-9]{64}$/.test(current.digest))throw new Error('dsp_release_invalid'); const directory=path.join(paths.dsps,id,'runtime/releases',current.digest); - if(verifyRelease(directory,current.digest).product!=='dsp')throw new Error('dsp_release_invalid'); + verifyRuntime(directory,current.digest); return path.join(directory,'code'); } function prepareDspRelease(paths,id,directory,digest) { @@ -20,10 +21,10 @@ function prepareDspRelease(paths,id,directory,digest) { const parent=privateDirectory(path.join(paths.dsps,id,'runtime/releases')),target=path.join(parent,digest); if(!fs.existsSync(target)){ const temporary=target+'.stage-'+require('node:crypto').randomBytes(12).toString('hex'); - try{secureCopy(directory,temporary);verifyRelease(temporary,digest);fs.renameSync(temporary,target);syncDirectory(parent);} + try{copyRuntime(directory,temporary);verifyRuntime(temporary,digest);fs.renameSync(temporary,target);syncDirectory(parent);} finally{fs.rmSync(temporary,{recursive:true,force:true});} } - verifyRelease(target,digest);return {dspId:id,digest,directory:target}; + verifyRuntime(target,digest);return {dspId:id,digest,directory:target}; } // Lifecycle callers hold their DSP lock and drain old processes before selecting // code. Health verification and restoring the prior receipt belong to that caller. @@ -31,7 +32,8 @@ function selectDspRelease(paths,id,digest,expectedDigest) { const file=fileFor(paths,id),prior=privateJson(file,process.geteuid(),true); if((prior?.digest||null)!==expectedDigest)throw new Error('dsp_release_changed'); if(digest!==null){ - if(!/^[a-f0-9]{64}$/.test(digest)||verifyRelease(path.join(paths.dsps,id,'runtime/releases',digest),digest).product!=='dsp')throw new Error('dsp_release_invalid'); + if(!/^[a-f0-9]{64}$/.test(digest))throw new Error('dsp_release_invalid'); + verifyRuntime(path.join(paths.dsps,id,'runtime/releases',digest),digest); privateDirectory(path.dirname(file));atomic(file,{schemaVersion:1,digest}); }else if(prior)fs.unlinkSync(file); return {previousDigest:prior?.digest||null,digest}; diff --git a/tests/architecture/release-update.acceptance.js b/tests/architecture/release-update.acceptance.js index b7e9b38..6a5592e 100644 --- a/tests/architecture/release-update.acceptance.js +++ b/tests/architecture/release-update.acceptance.js @@ -26,7 +26,8 @@ test('independent DSP updates switch a real runtime and restore its private stat const { LocalReleases } = require('../../core/updates/local-releases'); const { dspHooks } = require('../../host/releases/dsp'); const { hash, inventory, secureCopy, verifyRelease } = require('../../shared/releases/package'); - const { prepareDspRelease, selectDspRelease, fileFor } = require('../../host/releases/runtime'); + const { prepareDspRelease, selectDspRelease, runtimeSource, fileFor } = require('../../host/releases/runtime'); + const { installationReceipt } = require('../../host/plugins/install'); const beforeUmask = process.umask(0o077); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dpd-')); for (const name of ['live', 'local', 'dsps', 'dev', 'worktrees']) privateDirectory(path.join(root, name)); @@ -85,6 +86,8 @@ test('independent DSP updates switch a real runtime and restore its private stat require('../../shared/plugin-sdk/catalog').configureCatalog(definitions); require('dispatch-protocol/plugin-sdk/catalog').configureCatalog(definitions); prepareDspRelease(paths, id, dspPackage, dspDigest); selectDspRelease(paths, id, dspDigest, null); + assert.equal(fs.existsSync(path.join(path.dirname(runtimeSource(paths, id)), 'plugins')), false); + assert.equal(fs.existsSync(path.join(dsp.root, 'plugins/paycom')), false); const installation = loadInstallation(paths); const open = () => openDirectoryRuntime({ paths, installation, journal, authorityCatalog: authority.authorityCatalog, publishAuthority: authority.publishAuthority, networkPermitted: () => false, select: () => false }); @@ -96,10 +99,18 @@ test('independent DSP updates switch a real runtime and restore its private stat const plugins = createPluginService({ store, access, installationCoordinator: { ...coordinator, async apply(input) { try { return await coordinator.apply(input); } catch (error) { process.stderr.write('Fixture plugin activation: ' + error.stack + '\n'); throw error; } } }, invoke: (runtimeKey, action, input) => execution.invoke(runtimeKey, action, input) }); const session = access.session(owner.token); + await runtime.manager.apply('start', 'fixture_fresh_runtime', id); + const ownerConnections = require('../../core/accounts/src/owner-connections').createOwnerConnections({ store, access, + invoke: (key, action, input) => runtime.hub.invoke(key, action, input) }); + assert.deepEqual((await ownerConnections.list(session)).items.map(item => item.service), ['cortex']); + assert.equal(store.db.prepare('SELECT count(*) count FROM dsp_plugins WHERE organization_id=?').get(org).count, 0); + assert.equal(fs.existsSync(path.join(dsp.root, 'plugins/paycom')), false); plugins.change(session, 'paycom', { action: 'install', expectedRevision: 0, idempotencyKey: 'daemon:fixture:install' }); await plugins.runPending(); assert.equal(plugins.list(session).items[0].available, true, JSON.stringify(plugins.list(session).items.map(item => ({ id: item.id, state: item.state, failureCode: item.failureCode })))); assert.equal(runtime.hub.connected(id), true, 'Install resumes an always-on DSP after acknowledging the package'); + assert.deepEqual((await ownerConnections.list(session)).items.map(item => item.service).sort(), ['cortex', 'paycom']); + assert.equal(installationReceipt(dsp.root, 'paycom').version, paycomVersion); const result = await runtime.hub.invoke(id, 'plugins.invoke', { pluginId: 'paycom', action: 'sync.status', input: { id: 'paycom-main-workforce' } }); assert.equal(result.ok, true, JSON.stringify(result)); const connections = await runtime.hub.invoke(id, 'connections.manage', { command: 'list' }); @@ -126,7 +137,20 @@ test('independent DSP updates switch a real runtime and restore its private stat const baseline = updates.state(); baseline.active = { core: coreDigest, dsps: { [id]: dspDigest } }; baseline.defaultDsp = dspDigest; updates.save(baseline); atomic(path.join(paths.local, 'config/updates.json'), { schemaVersion: 1, devDspId: id, apiPort: 4999 }); const nextPackage = path.join(paths.dev, 'next-release'); secureCopy(dspPackage, nextPackage); - const nextManifest = { ...dspManifest, version: '0.0.2', channel: 'development' }; + // Build a new sealed Paycom version inside this synthetic DSP release. Its + // migrations/runtime are real; only the version differs from the fixture. + const nextPluginRoot = path.join(nextPackage, 'plugins/paycom'); + const nextPlugin = JSON.parse(fs.readFileSync(path.join(nextPluginRoot, 'dispatch-plugin.json'))); + const nextPluginVersion = nextPlugin.version.split('.').map((part, index) => Number(part) + (index === 2 ? 1 : 0)).join('.'); + nextPlugin.version = nextPluginVersion; + fs.chmodSync(path.join(nextPluginRoot, 'dispatch-plugin.json'), 0o600); + fs.writeFileSync(path.join(nextPluginRoot, 'dispatch-plugin.json'), JSON.stringify(nextPlugin)); + fs.unlinkSync(path.join(nextPluginRoot, 'package-manifest.json')); + const nextPluginDigest = require('../../tooling/build-plugin-package').sealPackage(nextPluginRoot).digest; + const metadata = path.join(nextPackage, 'code/plugins/paycom/dispatch-plugin.json'); + fs.chmodSync(metadata, 0o600); fs.writeFileSync(metadata, JSON.stringify(nextPlugin)); + const nextManifest = { ...dspManifest, version: '0.0.2', channel: 'development', + plugins: dspManifest.plugins.map(item => item.pluginId === 'paycom' ? { ...item, version: nextPluginVersion, digest: nextPluginDigest } : item) }; fs.chmodSync(path.join(nextPackage, 'release.json'), 0o600); fs.unlinkSync(path.join(nextPackage, 'release.json')); nextManifest.files = inventory(nextPackage); @@ -140,6 +164,8 @@ test('independent DSP updates switch a real runtime and restore its private stat assert.equal(execution.store.get(id).state, 'running'); assert.equal(execution.store.get(id).failure_code, null); assert.equal(JSON.parse(fs.readFileSync(fileFor(paths, id))).digest, nextDigest); + assert.equal(installationReceipt(dsp.root, 'paycom').version, nextPluginVersion); + assert.equal(fs.existsSync(path.join(path.dirname(runtimeSource(paths, id)), 'plugins')), false); assert.equal(runtime.hub.connected(id), true); assert.equal((await runtime.hub.invoke(id, 'health', {})).ok, true); const privateFile = path.join(dsp.root, 'data/release-sentinel'); fs.writeFileSync(privateFile, 'before failed migration', { mode: 0o600 }); @@ -155,6 +181,7 @@ test('independent DSP updates switch a real runtime and restore its private stat await assert.rejects(updates.updateDev(failedDigest), /release_health_failed/); assert.equal(fs.readFileSync(privateFile, 'utf8'), 'before failed migration'); assert.equal(JSON.parse(fs.readFileSync(fileFor(paths, id))).digest, nextDigest); + assert.equal(installationReceipt(dsp.root, 'paycom').version, nextPluginVersion); assert.equal(runtime.manager.journal.record(id).desiredState, 'stopped'); assert.equal(execution.store.get(id).state, 'sleeping'); assert.equal(updates.state().operation, null);