Skip to content
Merged
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
5 changes: 5 additions & 0 deletions docs/operations/dev-ci-migration-role.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ transport, не шифрование. В payload нет host/database overrides.
и запускает только фиксированный `bash -se`. Весь deploy script передаётся через
SSH stdin: secret-bearing script не попадает в argv удалённого shell и не
записывается во временный файл. Старый SSH action заменён только в DEV workflow.
Скрипт передаётся единым Bash-блоком с stdin `/dev/null`: Bash разбирает его целиком
до выполнения, поэтому `docker exec` не может поглотить последующие checks из SSH stdin.
Явные heredoc payload/Python продолжают работать. Regression tests запускают настоящий
Bash через loopback SSH: stdin-consuming child не пропускает завершающие checks,
а ошибка последующего check завершает deployment с ошибкой.

Helper проверяет фактическую identity, schema CREATE/USAGE, отсутствие admin flags,
ownership tables/sequences и текущие runtime grants внутри READ ONLY transaction.
Expand Down
5 changes: 4 additions & 1 deletion scripts/dev_ci_ssh.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ async function deploy(config, stdout = process.stdout, stderr = process.stderr)
if (code === 0 && !signal) finish();
else finish(new Error('DEV remote deployment failed; inspect masked command output'));
});
stream.end(config.script.endsWith('\n') ? config.script : `${config.script}\n`);
// Bash разбирает весь блок до запуска: дочерние процессы получают EOF,
// поэтому docker exec не может прочитать оставшиеся команды из SSH stdin.
const script = config.script.endsWith('\n') ? config.script : `${config.script}\n`;
stream.end(`{\n${script}} </dev/null\n`);
});
});
try {
Expand Down
26 changes: 25 additions & 1 deletion scripts/tests/test_dev_ci_ssh.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const assert = require('node:assert/strict');
const { once } = require('node:events');
const { PassThrough } = require('node:stream');
const { spawn } = require('node:child_process');
const { test } = require('node:test');
const { Server, utils } = require('ssh2');
const { deploy, fingerprint } = require('../dev_ci_ssh.cjs');
Expand Down Expand Up @@ -30,6 +31,15 @@ async function fixture(context, options = {}) {
observed.commands.push(info.command);
const stream = acceptCommand();
stream.on('data', (chunk) => { observed.input += chunk.toString(); });
if (options.bash) {
const child = spawn(options.bash, ['-se'], { stdio: ['pipe', 'pipe', 'pipe'] });
child.stdout.pipe(stream, { end: false });
child.stderr.pipe(stream.stderr, { end: false });
stream.pipe(child.stdin);
child.on('error', () => { stream.exit(127); stream.end(); });
child.on('close', (code) => { stream.exit(code); stream.end(); });
return;
}
stream.on('end', () => {
if (options.hang) return;
stream.write('remote stdout\n');
Expand Down Expand Up @@ -61,7 +71,7 @@ test('exec argv is fixed; full secret-bearing script is delivered only on stdin'
const f = await fixture(context);
await deploy(f.config, f.stdout, f.stderr);
assert.deepEqual(f.observed.commands, ['bash -se']);
assert.equal(f.observed.input, script);
assert.equal(f.observed.input, `{\n${script}} </dev/null\n`);
assert.equal(f.output(), 'remote stdout\nremote stderr\n');
assert.ok(!f.output().includes('fixture $()'));
});
Expand Down Expand Up @@ -94,3 +104,17 @@ test('deployment timeout closes the SSH connection', async (context) => {
const f = await fixture(context, { hang: true });
await assert.rejects(deploy({ ...f.config, timeoutMs: 1000 }, f.stdout, f.stderr), /timed out/);
});

const bash = process.platform === 'win32' ? process.env.MIGRATION_TEST_BASH : '/bin/bash';

test('real shell: stdin-consuming child cannot swallow later checks or heredocs', { skip: !bash }, async (context) => {
const f = await fixture(context, { bash });
await deploy({ ...f.config, script: "printf 'before\\n'\ncat >/dev/null\nprintf 'after\\n'\ncat <<'PAYLOAD'\nliteral $() `text`\nPAYLOAD\nprintf 'final-smoke-check\\n'\n" }, f.stdout, f.stderr);
assert.equal(f.output(), 'before\nafter\nliteral $() `text`\nfinal-smoke-check\n');
});

test('real shell: failed check after stdin-consuming child fails deployment', { skip: !bash }, async (context) => {
const f = await fixture(context, { bash });
await assert.rejects(deploy({ ...f.config, script: "cat >/dev/null\nfalse\nprintf 'must-not-run\\n'\n" }, f.stdout, f.stderr), /remote deployment failed/);
assert.ok(!f.output().includes('must-not-run'));
});
Loading