From 4b0241f992f2d92299abe0367450ad4ca1f92b5a Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 31 Aug 2026 00:09:41 +0800 Subject: [PATCH] fix(ql3): require read-only legacy target mount --- .../src/deployment/cutover/targetEvidence.ts | 11 ++++- .../test/cutoverTargetRun.test.cjs | 46 +++++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts index 1211b658..b80d86cb 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts @@ -361,6 +361,7 @@ function mappedMount( hostPath: string, targetPath: string, label: string, + expectedReadWrite = true, ): DockerMount { const matches = mounts.filter((mount) => { const relative = path.relative(mount.source, hostPath); @@ -370,8 +371,13 @@ function mappedMount( path.join(mount.destination, relative) === targetPath ); }); - if (matches.length !== 1 || matches[0]?.readWrite !== true) { - configurationError(`${label} must have one read-write bind mapping`); + if ( + matches.length !== 1 || + matches[0]?.readWrite !== expectedReadWrite + ) { + configurationError( + `${label} must have one ${expectedReadWrite ? 'read-write' : 'read-only'} bind mapping`, + ); } return matches[0]!; } @@ -597,6 +603,7 @@ export function parseTargetContainerEvidence( command.request.legacySourcePath, application.targetLegacySourcePath, 'target legacy source', + false, ); const databaseMount = mappedMount( mounts, diff --git a/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs b/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs index 8ed3034c..57469904 100644 --- a/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs +++ b/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs @@ -88,7 +88,7 @@ function stoppedLegacyInspection(state, running = false) { ]); } -function targetInspection(state) { +function targetInspection(state, options = {}) { return JSON.stringify([ { Id: state.targetContainerId, @@ -115,10 +115,27 @@ function targetInspection(state) { Mounts: [ { Type: 'bind', - Source: state.managementRoot, - Destination: '/host', + Source: state.deploymentRoot, + Destination: targetPath(state, state.deploymentRoot), RW: true, }, + { + Type: 'bind', + Source: state.legacySourcePath, + Destination: targetPath(state, state.legacySourcePath), + RW: options.writableLegacySource === true, + }, + ...[ + state.targetDatabasePath, + state.recoveryPath, + state.manifestPath, + state.activationPath, + ].map((filePath) => ({ + Type: 'bind', + Source: filePath, + Destination: targetPath(state, filePath), + RW: true, + })), ], }, ]); @@ -472,7 +489,7 @@ function harness(state, options = {}) { ); } if (args[0] === 'container' && args[1] === 'inspect') { - return targetInspection(state); + return targetInspection(state, options); } if (args[0] === 'container' && args[1] === 'start') { if (args[2] === state.legacyContainerId) { @@ -574,6 +591,27 @@ test('starts an exact target once and replays the active commitment without Dock assert.equal(replay.recordDigest, active.recordDigest); }); +test('requires the target container to keep the Legacy source read-only', async (t) => { + const state = fixture(t); + const controller = harness(state, { writableLegacySource: true }); + const result = await runLocalDeploymentDockerTarget( + command(state), + controller, + ); + assert.equal(result.state, 'manual_required'); + assert.equal( + controller.calls.filter((args) => args[1] === 'start').length, + 0, + ); + const request = JSON.parse( + fs.readFileSync( + path.join(state.journal, '0003-target-start-decision.json'), + 'utf8', + ), + ); + assert.equal(request.evidence.reason, 'target_preflight_unproved'); +}); + test('starts an offline Trial Kit image only when its local reference and content ID both match', async (t) => { const state = fixture(t); state.targetImageAuthority = 'local-image-id';