mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* fix(managed-agent): mask digit-led passwords and refuse an empty Harness user info
Follow-ups to the review items deferred on #16110 and #16175:
- A refusal hides everything up to a URL's last @, also when the URL
parses: a password that starts with digits and holds a / ? or # reads
as a port to both the URL parser and java.net.URI, so
http://svc:2024#Secret@harness:4170 printed whole. The --server-url
query refusal masks too.
- Without the server's verdict, start refuses an @ before the Harness
host (http://@host, http://:@host), which java.net.URI reads as user
information and the connector refuses, instead of restarting the
Harness for it.
- The health row and start show a status of 0, false or "" instead of
dropping it, and print the URLs they dial through the control-byte
neutralizer.
- A Session id with a lone surrogate fails its row by name instead of
"URI malformed".
- The Flyway reader test covers a path two modules both ship; the user
guide names verify --tenant's --actor condition, and the design docs
state the masking, status and id rules as applied.
* fix(managed-agent): neutralize the DEL and C1 bytes a quoted value keeps
JSON.stringify escapes C0 controls but not DEL or C1 bytes, so a refused
URL or a Session id the verify row echoes could carry U+009B, an 8-bit
CSI introducer, to the terminal, and the id was not bounded. The four
refusals that quote a URL and the id row now go through printable()
after JSON.stringify: C0 controls keep their escapes, DEL and C1 bytes
show as U+FFFD, and the quoted text stops at 200 code points.
* fix(managed-agent): print no password a --server-url carries, and stop predicting java.net.URI
Review round 2 on #16352.
- A --server-url with an @ left after the URL parser reads no user
information is refused: it is the rest of a password that starts with
digits, which the parser reads as a host and a port, so the phase would
dial the wrong host and print the password. check judges --server-url
the same way, since its plan repeats it.
- start's three outcomes and verify's health row print every URL with its
user information masked.
- With the server's verdict false, the refusal names a reason only where
the value shows one the server gives too: a user name or password, a
query or a fragment, or an @ in an authority after //.
- Without a verdict, start takes only a plain http(s) URL, a rule of its
own and narrower than the server's, instead of modelling how
java.net.URI reads user information: http:/@host, a backslash, a tab,
an underscore or a space no longer reach the Harness restart, and an @
in a path still does.
- The design record and the deployment guide say so.
* fix(managed-agent): correct upgrade URL guards and plan output
* fix(managed-agent): keep refusing an @ in --server-url, and spell plan words
affe6c5de3 dropped the refusal of a --server-url with an @ left after
the URL parser read no user information, to let a path hold one. That
is the shape of a password that starts with digits: for
https://svc:12/[email protected] the parser reads host svc and port
12, so start and verify dialled svc with the password in the path. The
refusal is back; an @ a path holds is written %40, which the docs now
say, and a test pins a proxy prefix spelled that way.
The plan printed --server-url through shown(), which turns a control
character into U+FFFD and masks the text before an @, so the printed
command was not the one to run. check refuses such a value before the
plan, and shellWord now spells any control character in any plan word
as the escapes of its UTF-8 bytes inside $'...', which bash reads back
to the value, instead of leaving it raw inside quotes.
The Harness rule start applies without the server's verdict follows
java.net.URI's host grammar: no empty label, no label that ends in a
hyphen, a last label that starts with a letter, a dotted-quad IPv4
address, and an IPv6 address of the standard forms. A differential run
of 2,000,000 generated URLs found none the rule takes that
java.net.URI refuses.
The usage line says what check does with --server-url without
suggesting it dials it.
* fix(managed-agent): accept trailing-dot Harness hostnames
Co-authored-by: Qwen-Coder <[email protected]>
* fix(managed-agent): take the IPv6 Harness hosts that end in IPv4 when the configuration does not resolve
The plain-URL rule start falls back to refused an IPv6 address that ends in
an IPv4 one, such as http://[::ffff:127.0.0.1]:4170, which the server's
Harness connector accepts, so the same Harness URL was refused while the
configuration did not resolve and taken once it did; the previous commit
took the final dot of a fully qualified name for the same reason. The rule
stays narrower than java.net.URI's, never wider: it still refuses a one-label
name that starts with a digit and an octet with a leading zero, which the URL
parser fetch uses reads as another address, and an empty port.
The refusal of an @ in --server-url now names its remedy, %40, as its sibling
refusals do, and the comment that describes quoted() sits above it.
* fix(managed-agent): tighten upgrade URL and plan handling
Co-authored-by: Qwen-Coder <[email protected]>
* fix(managed-agent): harden upgrade rows and Harness URL checks
Co-authored-by: Qwen-Coder <[email protected]>
* fix(managed-agent): take the digit-led Harness hosts fetch reads alike, and refuse an empty --server-url
The no-verdict Harness rule refused a one-label name that starts with a
digit, such as a container's 4harness, which java.net.URI and fetch both
read as that host. It now refuses only what the URL parser fetch uses
reads as another address: a one-label number, in decimal or 0x hex, and
an octet with a leading zero. The last label of a longer name still
starts with a letter, as java.net.URI requires, so harness.1b stays
refused, and an empty port is taken. The refusal says what the rule
needs instead of "a host and port with a path".
check, start and verify test whether --server-url was given rather than
whether it is truthy, so the empty value an unset variable leaves is
refused instead of printed into the plan and then ignored.
A plan's control bytes are written as three-digit octal escapes inside
$'...', which bash, zsh and ksh all read back; ksh read a \x escape on
through a hex digit after it.
---------
Co-authored-by: Qwen-Coder <[email protected]>
4903 lines
173 KiB
JavaScript
4903 lines
173 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Alibaba Group Holding Limited
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import fs, { existsSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
|
import fsPromises, {
|
|
chmod,
|
|
cp,
|
|
lstat,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
readdir,
|
|
readlink,
|
|
realpath,
|
|
rename,
|
|
rm,
|
|
stat,
|
|
symlink,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import http from 'node:http';
|
|
import { syncBuiltinESMExports } from 'node:module';
|
|
import net from 'node:net';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import { deflateRawSync } from 'node:zlib';
|
|
|
|
import {
|
|
compareVersions,
|
|
configurationFiles,
|
|
environmentFileEntry,
|
|
execStartArguments,
|
|
execStartPath,
|
|
fetchRefusal,
|
|
formatEnvironmentFile,
|
|
javaFeatureVersion,
|
|
meetsMinimum,
|
|
migrationScripts,
|
|
parseEnvironmentAssignments,
|
|
parseEnvironmentFile,
|
|
parseMysqlJdbcUrl,
|
|
parseShow,
|
|
runUpgrade,
|
|
serverBaseUrl,
|
|
verifyChecksums,
|
|
withoutCredentials,
|
|
} from '../../packages/managed-agent-server/distribution/bin/qwen-managed-agent-upgrade.mjs';
|
|
|
|
const SERVER = 'qwen-managed-agent-server';
|
|
const HARNESS = 'qwen-hosted-harness';
|
|
const RESOLVER =
|
|
'-Dloader.main=com.alibaba.qwen.code.managedagent.preflight.ResolvedConfiguration';
|
|
|
|
test('reads environment files the way systemd does', () => {
|
|
const values = parseEnvironmentFile(
|
|
[
|
|
'# comment',
|
|
'; also a comment',
|
|
'PLAIN=value ',
|
|
"SINGLE='a b'",
|
|
'DOUBLE="say \\"hi\\""',
|
|
'CONTINUED=one\\',
|
|
'two',
|
|
' INDENTED = spaced',
|
|
'not a line',
|
|
'BAD-KEY=x',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
assert.deepEqual(Object.fromEntries(values), {
|
|
PLAIN: 'value',
|
|
SINGLE: 'a b',
|
|
DOUBLE: 'say "hi"',
|
|
CONTINUED: 'onetwo',
|
|
INDENTED: 'spaced',
|
|
});
|
|
// systemd.exec(5): inside double quotes only \" \\ \` \$ and
|
|
// backslash-newline are escapes; outside quotes a backslash escapes any
|
|
// character. Each value below was read back from systemd 257.
|
|
assert.deepEqual(
|
|
Object.fromEntries(
|
|
parseEnvironmentFile(
|
|
[
|
|
'PASSWORD="p@ss\\word"',
|
|
'BARE=C:\\temp',
|
|
'HEX="p\\x41ss"',
|
|
"RAW='a\\b'",
|
|
'JOINED="a"\'b\'c',
|
|
'SPACED="x " ',
|
|
'MULTI="one',
|
|
'two"',
|
|
'FOLDED="one\\',
|
|
'two"',
|
|
'# a comment ending in a backslash \\',
|
|
'AFTER=1',
|
|
'OPEN="runs to the end',
|
|
].join('\n'),
|
|
),
|
|
),
|
|
{
|
|
PASSWORD: 'p@ss\\word',
|
|
BARE: 'C:temp',
|
|
HEX: 'p\\x41ss',
|
|
RAW: 'a\\b',
|
|
JOINED: 'abc',
|
|
SPACED: 'x ',
|
|
MULTI: 'one\ntwo',
|
|
FOLDED: 'onetwo',
|
|
AFTER: '1',
|
|
OPEN: 'runs to the end',
|
|
},
|
|
);
|
|
});
|
|
|
|
test('writes environment files systemd reads back as written', () => {
|
|
const values = new Map([
|
|
['PASSWORD', 'p"w\\x$HOME`id`'],
|
|
['QUOTE', "it's"],
|
|
['SPACED', ' a b '],
|
|
['MULTI', 'one\ntwo\\\nthree'],
|
|
['HASH', '#not a comment'],
|
|
['EMPTY', ''],
|
|
]);
|
|
assert.deepEqual(parseEnvironmentFile(formatEnvironmentFile(values)), values);
|
|
});
|
|
|
|
test('reads the unit properties systemctl show prints', () => {
|
|
const properties = parseShow(
|
|
'EnvironmentFiles=/a.env (ignore_errors=no)\nEnvironmentFiles=/b.env (ignore_errors=yes)\nUser=svc\n',
|
|
);
|
|
assert.deepEqual(
|
|
properties.get('EnvironmentFiles').map(environmentFileEntry),
|
|
[
|
|
{ path: '/a.env', optional: false },
|
|
{ path: '/b.env', optional: true },
|
|
],
|
|
);
|
|
assert.equal(
|
|
execStartPath(
|
|
'{ path=/opt/q/bin/qwen-managed-agent-server ; argv[]=/opt/q/bin/qwen-managed-agent-server --x ; ignore_errors=no }',
|
|
),
|
|
'/opt/q/bin/qwen-managed-agent-server',
|
|
);
|
|
assert.deepEqual(
|
|
execStartArguments(
|
|
'{ path=/opt/q/bin/s ; argv[]=/opt/q/bin/s ; ignore_errors=no ; start_time=[n/a] }',
|
|
),
|
|
[],
|
|
);
|
|
// "a b" and a then b print alike.
|
|
assert.equal(
|
|
execStartArguments(
|
|
'{ path=/opt/q/bin/s ; argv[]=/opt/q/bin/s a b ; ignore_errors=no }',
|
|
),
|
|
undefined,
|
|
);
|
|
assert.equal(execStartArguments(''), undefined);
|
|
assert.deepEqual(
|
|
Object.fromEntries(
|
|
parseEnvironmentAssignments('"A=1 2" B=x C=\\x41\\x20z D=a\\"b'),
|
|
),
|
|
{ A: '1 2', B: 'x', C: 'A z', D: 'a"b' },
|
|
);
|
|
assert.equal(parseEnvironmentAssignments('').size, 0);
|
|
});
|
|
|
|
test('orders release versions', () => {
|
|
for (const [lower, higher] of [
|
|
['0.1.0-alpha.96', '0.1.0-alpha.97'],
|
|
['0.1.0-alpha.9', '0.1.0-alpha.10'],
|
|
['0.1.0-alpha.1', '0.1.0-beta'],
|
|
['0.1.0-alpha', '0.1.0-alpha.1'],
|
|
['0.1.0-1', '0.1.0-alpha'],
|
|
['0.9.9', '0.10.0'],
|
|
['1.0.0-rc.1', '1.0.0'],
|
|
]) {
|
|
assert.equal(compareVersions(lower, higher), -1, `${lower} < ${higher}`);
|
|
assert.equal(compareVersions(higher, lower), 1, `${higher} > ${lower}`);
|
|
}
|
|
assert.equal(compareVersions('1.0.0+a', '1.0.0+b'), 0);
|
|
assert.throws(() => compareVersions('1.0', '1.0.0'), /not a semantic/);
|
|
});
|
|
|
|
test('reads the tool versions a release requires', () => {
|
|
assert.equal(javaFeatureVersion('openjdk version "21.0.2" 2024-01-16'), '21');
|
|
assert.equal(javaFeatureVersion('java version "1.8.0_391"'), '8');
|
|
assert.equal(javaFeatureVersion('openjdk version "25" 2025-09-16'), '25');
|
|
assert.equal(javaFeatureVersion('nothing'), undefined);
|
|
assert.equal(meetsMinimum('>=21', '21'), true);
|
|
assert.equal(meetsMinimum('>=21', '17'), false);
|
|
assert.equal(meetsMinimum('>=22.0.0', 'v22.22.2'), true);
|
|
assert.equal(meetsMinimum('>=22.12.0', 'v22.9.0'), false);
|
|
assert.equal(meetsMinimum('^22', 'v22.1.0'), undefined);
|
|
});
|
|
|
|
test('reads the datasource, configuration and listener settings', () => {
|
|
assert.deepEqual(
|
|
parseMysqlJdbcUrl(
|
|
'jdbc:mysql://127.0.0.1:3307/qwen_managed_agent?useSSL=false',
|
|
),
|
|
{ host: '127.0.0.1', port: '3307', database: 'qwen_managed_agent' },
|
|
);
|
|
assert.deepEqual(parseMysqlJdbcUrl('jdbc:mysql://[::1]/db'), {
|
|
host: '::1',
|
|
port: '3306',
|
|
database: 'db',
|
|
});
|
|
assert.equal(parseMysqlJdbcUrl('jdbc:mysql://a:1,b:2/db'), undefined);
|
|
// A % that starts no escape is a URL this command cannot read.
|
|
assert.equal(
|
|
parseMysqlJdbcUrl('jdbc:mysql://127.0.0.1/qwen%prod'),
|
|
undefined,
|
|
);
|
|
assert.equal(
|
|
parseMysqlJdbcUrl('jdbc:mysql://127.0.0.1/qwen%25prod').database,
|
|
'qwen%prod',
|
|
);
|
|
assert.equal(parseMysqlJdbcUrl('jdbc:postgresql://a/db'), undefined);
|
|
assert.deepEqual(
|
|
configurationFiles(
|
|
'file:/etc/q/application.yml, optional:file:/etc/q/extra.yml,classpath:x.yml,file:/etc/d/',
|
|
),
|
|
{
|
|
files: [
|
|
'/etc/q/application.yml',
|
|
'/etc/q/extra.yml',
|
|
'/etc/d/application.properties',
|
|
'/etc/d/application.yaml',
|
|
'/etc/d/application.yml',
|
|
],
|
|
unread: ['classpath:x.yml'],
|
|
},
|
|
);
|
|
assert.equal(serverBaseUrl(new Map()), 'http://127.0.0.1:8080');
|
|
assert.equal(
|
|
serverBaseUrl(
|
|
new Map([
|
|
['QWEN_MANAGED_AGENT_SERVER_ADDRESS', '::'],
|
|
['SERVER_PORT', '9090'],
|
|
]),
|
|
),
|
|
'http://[::1]:9090',
|
|
);
|
|
assert.equal(
|
|
serverBaseUrl(new Map([['QWEN_MANAGED_AGENT_SERVER_ADDRESS', '0.0.0.0']])),
|
|
'http://127.0.0.1:8080',
|
|
);
|
|
});
|
|
|
|
test('refuses only the URLs fetch refuses', async () => {
|
|
for (const url of [
|
|
'http://127.0.0.1:8080',
|
|
'https://[::1]:8443/manage',
|
|
'HTTP://Example.test',
|
|
'http://127.0.0.1:80',
|
|
'http://127.0.0.1:4170',
|
|
]) {
|
|
assert.equal(fetchRefusal(url), undefined, url);
|
|
}
|
|
assert.equal(
|
|
fetchRefusal('http://[fe80::1%eth0]:8080'),
|
|
'is not a URL: a URL cannot carry an IPv6 zone',
|
|
);
|
|
assert.equal(fetchRefusal('http://127.0.0.1:70000'), 'is not a URL');
|
|
// A percent outside the brackets is no zone.
|
|
assert.equal(fetchRefusal('http://127.0.0.1:70000/%'), 'is not a URL');
|
|
assert.equal(fetchRefusal('proxy.test:8443'), 'is not an http or https URL');
|
|
for (const url of [
|
|
'http://[email protected]:8080',
|
|
'http://:[email protected]:8080',
|
|
]) {
|
|
assert.equal(
|
|
fetchRefusal(url),
|
|
'carries a user name or password, so fetch refuses it',
|
|
url,
|
|
);
|
|
}
|
|
assert.equal(
|
|
fetchRefusal('https://127.0.0.1:6000'),
|
|
'is on port 6000, so fetch refuses to dial it',
|
|
);
|
|
// A refusal prints the URL with its user information masked, whether or
|
|
// not it parses, and a URL without any comes back as it was.
|
|
for (const [url, shown] of [
|
|
['http://admin:[email protected]:18080', 'http://***@127.0.0.1:18080/'],
|
|
['http://:[email protected]:8080/x', 'http://***@127.0.0.1:8080/x'],
|
|
['http://a:p%40ss@[::1]:1', 'http://***@[::1]:1/'],
|
|
// A token in the user-name position, as a personal access token goes.
|
|
[
|
|
'https://[email protected]:8443',
|
|
'https://***@cp.example.com:8443/',
|
|
],
|
|
// Values the parser rejects, or reads without an authority.
|
|
['http://admin:[email protected]:70000', 'http://***@127.0.0.1:70000'],
|
|
[
|
|
'http://admin:s3cret@[fe80::1%eth0]:8080',
|
|
'http://***@[fe80::1%eth0]:8080',
|
|
],
|
|
['ops:[email protected]:8080', 'ops:***@127.0.0.1:8080'],
|
|
['[email protected]:8080', '***@127.0.0.1:8080'],
|
|
// A password with a / ? or # ends the authority early, so the value does
|
|
// not parse, and nor does one without a scheme: all up to the last @ goes.
|
|
[
|
|
'https://svc:[email protected]:8443',
|
|
'https://***@cp.example.com:8443',
|
|
],
|
|
[
|
|
'https://svc:Pa/[email protected]:8443',
|
|
'https://***@cp.example.com:8443',
|
|
],
|
|
[
|
|
'https://svc:s3#[email protected]:8443',
|
|
'https://***@cp.example.com:8443',
|
|
],
|
|
['//svc:[email protected]:8443', '//***@cp.example.com:8443'],
|
|
['http://a:b@[email protected]:70000/x', 'http://***@127.0.0.1:70000/x'],
|
|
// A password that starts with digits does parse, as a port, with the
|
|
// rest of it in the path, the query or the fragment.
|
|
[
|
|
'http://svc:2024#[email protected]:4170',
|
|
'http://***@harness.internal:4170',
|
|
],
|
|
[
|
|
'https://svc:12/[email protected]:8443',
|
|
'https://***@cp.example.com:8443',
|
|
],
|
|
[
|
|
'http://svc:[email protected]:8443/x',
|
|
'http://***@cp.example.com:8443/x',
|
|
],
|
|
// So an @ anywhere hides all before it, an empty user name's too.
|
|
['http://@127.0.0.1:4170', 'http://***@127.0.0.1:4170'],
|
|
['http://127.0.0.1:8080/a@b?c=d@e', 'http://***@e'],
|
|
['http://127.0.0.1:6000', 'http://127.0.0.1:6000'],
|
|
['proxy.test:8443', 'proxy.test:8443'],
|
|
['http://[fe80::1%eth0]:8080', 'http://[fe80::1%eth0]:8080'],
|
|
]) {
|
|
assert.equal(withoutCredentials(url), shown, url);
|
|
}
|
|
|
|
// Each URL it refuses, the runtime's fetch rejects before it dials: a port
|
|
// it blocks, a scheme it does not fetch, or credentials.
|
|
const refused = [
|
|
'proxy.test:8443',
|
|
'ftp://127.0.0.1:2121',
|
|
'http://admin:[email protected]:18080',
|
|
'http://:[email protected]:18080',
|
|
];
|
|
for (let port = 1; port <= 65535; port++) {
|
|
if (fetchRefusal(`http://127.0.0.1:${port}`)) {
|
|
refused.push(`http://127.0.0.1:${port}`);
|
|
}
|
|
}
|
|
assert.equal(refused.length, 4 + 82);
|
|
for (const url of refused) {
|
|
await assert.rejects(
|
|
fetch(`${url}/actuator/health`, { signal: AbortSignal.timeout(1000) }),
|
|
(error) =>
|
|
error instanceof TypeError &&
|
|
(/credentials/.test(error.message) ||
|
|
['bad port', 'unknown scheme'].includes(error.cause?.message)),
|
|
url,
|
|
);
|
|
}
|
|
});
|
|
|
|
function sha256(text) {
|
|
return createHash('sha256').update(text).digest('hex');
|
|
}
|
|
|
|
const BASELINE = {
|
|
'db/managed-schema/V1__managed_schema.sql': 'create table a;\n',
|
|
};
|
|
|
|
/** A minimal zip: the reader follows the central directory, as for a jar. */
|
|
function zip(entries) {
|
|
const locals = [];
|
|
const centrals = [];
|
|
let offset = 0;
|
|
// An entry may name its method, and carry an extra field in its local
|
|
// header only, as archivers that add timestamps or alignment do.
|
|
for (const [index, [name, text, forced, extra = '']] of entries.entries()) {
|
|
const data = Buffer.from(text);
|
|
// Alternate the two methods a jar uses, unless the entry names one.
|
|
const method = forced ?? (index % 2 === 0 ? 8 : 0);
|
|
const body = method === 8 ? deflateRawSync(data) : data;
|
|
const nameBytes = Buffer.from(name);
|
|
const extraBytes = Buffer.from(extra);
|
|
const local = Buffer.alloc(30);
|
|
local.writeUInt32LE(0x04034b50, 0);
|
|
local.writeUInt16LE(method, 8);
|
|
local.writeUInt32LE(body.length, 18);
|
|
local.writeUInt32LE(data.length, 22);
|
|
local.writeUInt16LE(nameBytes.length, 26);
|
|
local.writeUInt16LE(extraBytes.length, 28);
|
|
const central = Buffer.alloc(46);
|
|
central.writeUInt32LE(0x02014b50, 0);
|
|
central.writeUInt16LE(method, 10);
|
|
central.writeUInt32LE(body.length, 20);
|
|
central.writeUInt32LE(data.length, 24);
|
|
central.writeUInt16LE(nameBytes.length, 28);
|
|
central.writeUInt32LE(offset, 42);
|
|
locals.push(local, nameBytes, extraBytes, body);
|
|
centrals.push(central, nameBytes);
|
|
offset += 30 + nameBytes.length + extraBytes.length + body.length;
|
|
}
|
|
const directory = Buffer.concat(centrals);
|
|
const end = Buffer.alloc(22);
|
|
end.writeUInt32LE(0x06054b50, 0);
|
|
end.writeUInt16LE(entries.length, 8);
|
|
end.writeUInt16LE(entries.length, 10);
|
|
end.writeUInt32LE(directory.length, 12);
|
|
end.writeUInt32LE(offset, 16);
|
|
return Buffer.concat([...locals, directory, end]);
|
|
}
|
|
|
|
// A jar whose scripts live in the nested jar of a module, stored as Spring
|
|
// Boot stores one, as the server ships them since its split into modules.
|
|
function moduleJar(scripts) {
|
|
return zip([
|
|
['BOOT-INF/classes/application.yml', 'server: {}\n'],
|
|
['BOOT-INF/lib/qwencode-sdk.jar', zip([['A.class', 'x']]), 0],
|
|
[
|
|
'BOOT-INF/lib/qwen-managed-agent-persistence.jar',
|
|
zip(Object.entries(scripts)),
|
|
0,
|
|
],
|
|
]);
|
|
}
|
|
|
|
async function writeDistribution(
|
|
root,
|
|
{ version, scripts = BASELINE, files = {}, nested = false },
|
|
) {
|
|
const contents = {
|
|
'manifest.json': `${JSON.stringify(
|
|
{
|
|
schemaVersion: 1,
|
|
name: 'qwen-managed-agent-reference',
|
|
version,
|
|
components: [{ role: 'control-plane', path: 'lib/server.jar' }],
|
|
compatibility: {
|
|
qwenCodeVersion: '1.2.3',
|
|
java: '>=21',
|
|
node: '>=22.0.0',
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
'bin/qwen-managed-agent-server': '#!/usr/bin/env sh\n',
|
|
'config/application.example.yml': 'template\n',
|
|
'lib/server.jar': nested
|
|
? moduleJar(scripts)
|
|
: zip([
|
|
['BOOT-INF/classes/application.yml', 'server: {}\n'],
|
|
...Object.entries(scripts).map(([name, text]) => [
|
|
`BOOT-INF/classes/${name}`,
|
|
text,
|
|
]),
|
|
]),
|
|
'systemd/qwen-managed-agent-server.service': '[Service]\n',
|
|
'systemd/qwen-hosted-harness.service': '[Service]\n',
|
|
...files,
|
|
};
|
|
for (const [name, content] of Object.entries(contents)) {
|
|
await mkdir(path.dirname(path.join(root, name)), { recursive: true });
|
|
await writeFile(path.join(root, name), content);
|
|
}
|
|
await writeFile(
|
|
path.join(root, 'SHA256SUMS'),
|
|
Object.entries(contents)
|
|
.map(([name, content]) => `${sha256(content)} ${name}\n`)
|
|
.sort()
|
|
.join(''),
|
|
);
|
|
}
|
|
|
|
async function executable(file, content = '#!/bin/sh\n') {
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(file, content);
|
|
await chmod(file, 0o755);
|
|
}
|
|
|
|
/** A host with the two units, an installed 0.1.0 and an extracted 0.2.0. */
|
|
async function rig(t, overrides = {}) {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'qwen-upgrade-'));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const opt = path.join(root, 'opt');
|
|
const current = path.join(opt, 'qwen-managed-agent');
|
|
const distribution = path.join(root, 'new');
|
|
const etc = path.join(root, 'etc');
|
|
const tools = path.join(root, 'tools');
|
|
const cgroups = path.join(root, 'cgroup');
|
|
const state = path.join(root, 'state');
|
|
await writeDistribution(current, {
|
|
version: '0.1.0',
|
|
scripts: overrides.installedScripts,
|
|
nested: overrides.installedNested,
|
|
});
|
|
await writeFile(path.join(current, 'config/application.yml'), 'key: x\n', {
|
|
mode: 0o600,
|
|
});
|
|
await writeDistribution(distribution, {
|
|
version: overrides.version ?? '0.2.0',
|
|
scripts: overrides.scripts,
|
|
files: overrides.files,
|
|
nested: overrides.nested,
|
|
});
|
|
// As root extracts it with umask 077.
|
|
await chmod(path.join(distribution, 'lib/server.jar'), 0o600);
|
|
for (const tool of ['java', 'node', 'qwen', 'mysqldump']) {
|
|
await executable(path.join(tools, tool));
|
|
}
|
|
await mkdir(etc, { recursive: true });
|
|
await writeFile(
|
|
path.join(etc, 'server.env'),
|
|
[
|
|
'SPRING_DATASOURCE_URL=jdbc:mysql://127.0.0.1:3306/qwen_managed_agent',
|
|
'SPRING_DATASOURCE_USERNAME=qwen',
|
|
"SPRING_DATASOURCE_PASSWORD='p\"w'",
|
|
`QWEN_MANAGED_AGENT_HARNESS_CREDENTIALS_FILE=${etc}/harness.env`,
|
|
...(overrides.state === null
|
|
? []
|
|
: [`QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY=${state}`]),
|
|
'SERVER_PORT=18080',
|
|
...(overrides.serverEnv ?? []),
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
await writeFile(
|
|
path.join(etc, 'harness.env'),
|
|
[
|
|
'QWEN_SERVER_TOKEN=t',
|
|
'QWEN_MANAGED_RUNTIME_BROKER_TOKEN=b',
|
|
...(overrides.harnessEnv ?? []),
|
|
'',
|
|
].join('\n'),
|
|
{ mode: 0o600 },
|
|
);
|
|
await mkdir(path.join(state, 'workers'), { recursive: true });
|
|
await writeFile(path.join(state, 'workers/w1.json'), '{}\n');
|
|
|
|
const units = {
|
|
[SERVER]: {
|
|
LoadState: 'loaded',
|
|
ActiveState: 'active',
|
|
ExecStart: `{ path=${current}/bin/qwen-managed-agent-server ; argv[]=${current}/bin/qwen-managed-agent-server ; ignore_errors=no }`,
|
|
EnvironmentFiles: [`${etc}/server.env (ignore_errors=no)`],
|
|
Environment: `PATH=${tools}`,
|
|
User: 'svc',
|
|
WorkingDirectory: current,
|
|
ControlGroup: `/system.slice/${SERVER}.service`,
|
|
FragmentPath: `${etc}/${SERVER}.service`,
|
|
DropInPaths: '',
|
|
ActiveEnterTimestampMonotonic: '100',
|
|
InactiveEnterTimestampMonotonic: '50',
|
|
procs: ['10', '11'],
|
|
},
|
|
[HARNESS]: {
|
|
LoadState: 'loaded',
|
|
ActiveState: 'active',
|
|
ExecStart: `{ path=${tools}/qwen ; argv[]=${tools}/qwen serve ; ignore_errors=no }`,
|
|
EnvironmentFiles: [`${etc}/harness.env (ignore_errors=no)`],
|
|
Environment: `PATH=${tools}`,
|
|
User: 'svc',
|
|
WorkingDirectory: '',
|
|
ControlGroup: `/system.slice/${HARNESS}.service`,
|
|
FragmentPath: `${etc}/${HARNESS}.service`,
|
|
DropInPaths: '',
|
|
ActiveEnterTimestampMonotonic: '90',
|
|
InactiveEnterTimestampMonotonic: '40',
|
|
procs: ['20'],
|
|
},
|
|
};
|
|
for (const name of [SERVER, HARNESS]) {
|
|
await writeFile(path.join(etc, `${name}.service`), '[Service]\n');
|
|
}
|
|
|
|
// The unified hierarchy, or with cgroupLayout 'v1' systemd's named one.
|
|
const writeCgroups = async () => {
|
|
const v1 = host.cgroupLayout === 'v1';
|
|
await rm(cgroups, { recursive: true, force: true });
|
|
await mkdir(path.join(cgroups, v1 ? 'systemd' : ''), { recursive: true });
|
|
await writeFile(
|
|
path.join(cgroups, v1 ? 'systemd/cgroup.procs' : 'cgroup.controllers'),
|
|
'',
|
|
);
|
|
for (const [name, unit] of Object.entries(units)) {
|
|
const directory = path.join(
|
|
cgroups,
|
|
v1 ? 'systemd' : '',
|
|
'system.slice',
|
|
`${name}.service`,
|
|
);
|
|
await rm(directory, { recursive: true, force: true });
|
|
if (unit.procs.length > 0) {
|
|
await mkdir(directory, { recursive: true });
|
|
await writeFile(
|
|
path.join(directory, 'cgroup.procs'),
|
|
unit.procs.map((pid) => `${pid}\n`).join(''),
|
|
);
|
|
}
|
|
}
|
|
};
|
|
const host = {
|
|
root,
|
|
current,
|
|
distribution,
|
|
etc,
|
|
state,
|
|
tools,
|
|
units,
|
|
calls: [],
|
|
output: [],
|
|
clock: 0,
|
|
java: 'openjdk version "21.0.2" 2024-01-16',
|
|
qwen: '1.2.3',
|
|
mysqldumpStatus: 0,
|
|
mysqldumpVersion: 'mysqldump Ver 8.4.6',
|
|
preflightStatus: 0,
|
|
// Whether the main process alone stops (KillMode=process) or workers
|
|
// ignore TERM.
|
|
workersIgnoreTerm: false,
|
|
responses: {},
|
|
requests: [],
|
|
// Every host:port a start waited on.
|
|
connections: [],
|
|
// Every systemd-run, with the environment systemd would hand it.
|
|
services: [],
|
|
// What the jar resolves, beyond the documented variables.
|
|
resolved: {},
|
|
resolveFailure: undefined,
|
|
stopFails: false,
|
|
cgroupLayout: 'unified',
|
|
// An errno the next cgroup.procs read fails with.
|
|
cgroupError: undefined,
|
|
};
|
|
await writeCgroups();
|
|
const systemctl = (args) => {
|
|
const [verb, ...rest] = args;
|
|
if (verb === 'show') {
|
|
const unit = units[rest[0]];
|
|
const lines = [];
|
|
for (let index = 1; index < rest.length; index += 2) {
|
|
const property = rest[index + 1];
|
|
const value = unit[property];
|
|
if (property === 'ControlGroup') {
|
|
lines.push(
|
|
`ControlGroup=${unit.procs.length > 0 ? unit.ControlGroup : ''}`,
|
|
);
|
|
} else if (Array.isArray(value)) {
|
|
lines.push(...value.map((each) => `${property}=${each}`));
|
|
} else {
|
|
lines.push(`${property}=${value ?? ''}`);
|
|
}
|
|
}
|
|
return { status: 0, stdout: `${lines.join('\n')}\n`, stderr: '' };
|
|
}
|
|
const name = rest.at(-1);
|
|
const unit = units[name];
|
|
host.clock += 1;
|
|
if (verb === 'stop') {
|
|
unit.ActiveState =
|
|
host.stopFails && name === SERVER ? 'failed' : 'inactive';
|
|
unit.SubState = unit.ActiveState === 'failed' ? 'failed' : 'dead';
|
|
if (host.stopFails && name === SERVER) {
|
|
Object.assign(unit, { Result: 'exit-code', ExecMainStatus: '143' });
|
|
}
|
|
unit.InactiveEnterTimestampMonotonic = String(1000 + host.clock);
|
|
// KillMode=process: the server's workers outlive its main process.
|
|
unit.procs = name === SERVER ? unit.procs.slice(1) : [];
|
|
} else if (verb === 'kill') {
|
|
if (!host.workersIgnoreTerm) {
|
|
unit.procs = [];
|
|
}
|
|
} else if (verb === 'start' || verb === 'restart') {
|
|
Object.assign(unit, {
|
|
ActiveState: 'active',
|
|
SubState: 'running',
|
|
// systemd 257 resets the counter on a manual stop and start.
|
|
NRestarts: host.keepRestarts ? (unit.NRestarts ?? '0') : '0',
|
|
});
|
|
unit.ActiveEnterTimestampMonotonic = String(1000 + host.clock);
|
|
unit.procs = ['30'];
|
|
}
|
|
return { status: 0, stdout: '', stderr: '' };
|
|
};
|
|
const system = {
|
|
uid: () => 0,
|
|
euid: () => host.euid ?? process.geteuid(),
|
|
distribution,
|
|
cgroupRoot: cgroups,
|
|
readCgroup: async (file) => {
|
|
if (host.cgroupError) {
|
|
throw Object.assign(new Error(host.cgroupError), {
|
|
code: host.cgroupError,
|
|
});
|
|
}
|
|
return readFile(file, 'utf8');
|
|
},
|
|
bootId: async () => 'boot-1',
|
|
now: () => host.clock * 1000,
|
|
sleep: async (milliseconds) => {
|
|
host.clock += milliseconds / 1000;
|
|
await writeCgroups();
|
|
},
|
|
connect: async (address, port) => {
|
|
host.connections.push(`${address}:${port}`);
|
|
return true;
|
|
},
|
|
fetch: async (url, init = {}) => {
|
|
host.requests.push({ url, init });
|
|
if (host.hang) {
|
|
// A listener that accepts and never answers: only the signal ends it.
|
|
return new Promise((resolve, reject) => {
|
|
// The open socket a real fetch holds keeps the process alive;
|
|
// AbortSignal.timeout() alone does not.
|
|
const socket = setInterval(() => {}, 1000);
|
|
init.signal?.addEventListener('abort', () => {
|
|
clearInterval(socket);
|
|
reject(init.signal.reason);
|
|
});
|
|
});
|
|
}
|
|
const { pathname, search } = new URL(url);
|
|
const response = host.responses[`${init.method ?? 'GET'} ${pathname}`];
|
|
// A function may answer later, and reject as fetch does.
|
|
const body =
|
|
typeof response === 'function'
|
|
? await response(search, init)
|
|
: response;
|
|
return {
|
|
ok: body !== undefined,
|
|
status: body === undefined ? 404 : 200,
|
|
statusText: body === undefined ? 'Not Found' : 'OK',
|
|
json: async () => body ?? { error: { message: 'missing' } },
|
|
};
|
|
},
|
|
run: (command, args, options = {}) => {
|
|
const name = path.basename(command);
|
|
host.calls.push([name, ...args]);
|
|
if (name === 'systemctl') {
|
|
const result = systemctl(args);
|
|
return result;
|
|
}
|
|
if (name === 'java') {
|
|
return { status: 0, stdout: '', stderr: host.java };
|
|
}
|
|
if (name === 'node') {
|
|
return { status: 0, stdout: 'v22.1.0\n', stderr: '' };
|
|
}
|
|
if (name === 'qwen') {
|
|
return { status: 0, stdout: `${host.qwen}\n`, stderr: '' };
|
|
}
|
|
if (name === 'mysqldump') {
|
|
if (args[0] === '--version') {
|
|
return {
|
|
status: 0,
|
|
stdout: `${host.mysqldumpVersion}\n`,
|
|
stderr: '',
|
|
};
|
|
}
|
|
host.dumpCredentialsFile = args[0].split('=')[1];
|
|
host.dumpCredentials = readFileSyncText(host.dumpCredentialsFile);
|
|
host.dumpEnvironment = options.env;
|
|
writeSync(options.stdio[1], '-- dump\n');
|
|
host.onDump?.();
|
|
return host.mysqldumpStatus === 0
|
|
? { status: 0, stdout: '', stderr: '' }
|
|
: { status: host.mysqldumpStatus, stdout: '', stderr: 'denied' };
|
|
}
|
|
if (name === 'tar') {
|
|
return spawnSync('tar', args, { encoding: 'utf8' });
|
|
}
|
|
if (name === 'systemd-run') {
|
|
// systemd reads each environment file in turn, then runs the command.
|
|
const environment = new Map();
|
|
const files = [];
|
|
for (const arg of args) {
|
|
const file = /^--property=EnvironmentFile=-?(.*)$/.exec(arg)?.[1];
|
|
if (file) {
|
|
const text = readFileSyncText(file);
|
|
files.push({ file, text });
|
|
for (const [key, value] of parseEnvironmentFile(text)) {
|
|
environment.set(key, value);
|
|
}
|
|
}
|
|
}
|
|
const service = { args, environment, files };
|
|
host.services.push(service);
|
|
host.onService?.(service);
|
|
if (!args.includes(RESOLVER)) {
|
|
return { status: host.preflightStatus };
|
|
}
|
|
const jar = args[args.indexOf('-cp') + 1];
|
|
service.jar = {
|
|
path: jar,
|
|
mode: statSync(jar).mode & 0o777,
|
|
directoryMode: statSync(path.dirname(jar)).mode & 0o777,
|
|
};
|
|
if (host.resolveFailure) {
|
|
return { status: 1, stdout: '', stderr: host.resolveFailure };
|
|
}
|
|
// A stand-in for Spring that reads the documented variables only.
|
|
const mode = (environment.get('QWEN_MANAGED_AGENT_AUTH_MODE') ?? 'auto')
|
|
.trim()
|
|
.toLowerCase();
|
|
const resolved = {
|
|
runtimeStateDirectory:
|
|
environment.get('QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY') ?? '',
|
|
runtimeStateOrigin: 'QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY',
|
|
authMode: mode === 'auto' ? 'open' : mode,
|
|
trustedActorHeader:
|
|
environment.get('QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER') ?? '',
|
|
serverUrl: serverBaseUrl(environment),
|
|
healthUrl: `${serverBaseUrl(environment)}/actuator/health`,
|
|
harnessBaseUrl:
|
|
environment.get('QWEN_MANAGED_AGENT_HARNESS_BASE_URL') ??
|
|
'http://127.0.0.1:4170',
|
|
harnessOrigin: 'true',
|
|
...host.resolved,
|
|
};
|
|
return {
|
|
status: 0,
|
|
stdout: `WARNING: a JVM line\nqwen-managed-agent-configuration ${JSON.stringify(resolved)}\n`,
|
|
stderr: '',
|
|
};
|
|
}
|
|
throw new Error(`unexpected command ${command}`);
|
|
},
|
|
out: (line) => host.output.push(line),
|
|
err: (line) => host.output.push(line),
|
|
// The handler the real system runs on SIGINT, SIGTERM or SIGHUP.
|
|
onSignal: (cleanup) => {
|
|
host.signal = cleanup;
|
|
return () => {
|
|
host.signal = undefined;
|
|
};
|
|
},
|
|
};
|
|
host.system = system;
|
|
host.upgrade = async (...argv) => {
|
|
host.output = [];
|
|
return runUpgrade(argv, system);
|
|
};
|
|
// check with the rig's client, which the host's own PATH may lack.
|
|
host.check = (...argv) =>
|
|
host.upgrade(
|
|
'check',
|
|
'--mysqldump',
|
|
path.join(tools, 'mysqldump'),
|
|
...argv,
|
|
);
|
|
host.stop = async () => {
|
|
for (const unit of Object.values(units)) {
|
|
unit.ActiveState = 'inactive';
|
|
unit.procs = [];
|
|
}
|
|
await writeCgroups();
|
|
};
|
|
return host;
|
|
}
|
|
|
|
function readdirSyncSorted(directory) {
|
|
return spawnSync('ls', ['-A', directory], { encoding: 'utf8' })
|
|
.stdout.split('\n')
|
|
.filter(Boolean)
|
|
.sort();
|
|
}
|
|
|
|
function readFileSyncText(file) {
|
|
return spawnSync('cat', [file], { encoding: 'utf8' }).stdout;
|
|
}
|
|
|
|
function rowsOf(output) {
|
|
return Object.fromEntries(
|
|
output
|
|
.map((line) => /^(PASS|WARN|FAIL|SKIP)\s+(\S+)\s+(.*)$/.exec(line))
|
|
.filter(Boolean)
|
|
.map(([, status, name, detail]) => [name, `${status} ${detail}`]),
|
|
);
|
|
}
|
|
|
|
test('reads the Flyway scripts out of a jar by content', async (t) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'qwen-upgrade-jar-'));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const jar = path.join(root, 'server.jar');
|
|
// Deflated, then stored: the digests are of the scripts, not their bytes in the jar.
|
|
await writeFile(
|
|
jar,
|
|
zip([
|
|
['BOOT-INF/classes/db/managed-schema/V1__base.sql', 'create table a;\n'],
|
|
['BOOT-INF/classes/db/managed-schema/V2__more.sql', 'create table b;\n'],
|
|
['BOOT-INF/classes/db/managed-schema/README.md', 'not a script'],
|
|
['BOOT-INF/lib/V3__nested.sql', 'not on the classpath root'],
|
|
['BOOT-INF/classes/db/managed-schema/V4__upper.SQL', 'insert;\n'],
|
|
['BOOT-INF/classes/db/managed-schema/R__view.sql', 'create view;\n'],
|
|
['BOOT-INF/classes/db/managed-schema/U2__undo.sql', 'drop table b;\n'],
|
|
// The prefixes are case-sensitive.
|
|
['BOOT-INF/classes/db/managed-schema/v5__lower.sql', 'ignored'],
|
|
]),
|
|
);
|
|
assert.deepEqual(Object.fromEntries(await migrationScripts(jar)), {
|
|
'db/managed-schema/V1__base.sql': sha256('create table a;\n'),
|
|
'db/managed-schema/V2__more.sql': sha256('create table b;\n'),
|
|
'db/managed-schema/V4__upper.SQL': sha256('insert;\n'),
|
|
'db/managed-schema/R__view.sql': sha256('create view;\n'),
|
|
'db/managed-schema/U2__undo.sql': sha256('drop table b;\n'),
|
|
});
|
|
});
|
|
|
|
test('reads the Flyway scripts out of the nested jar of a module', async (t) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'qwen-upgrade-jar-'));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const jar = path.join(root, 'server.jar');
|
|
const module = zip([
|
|
['META-INF/MANIFEST.MF', 'Manifest-Version: 1.0\n'],
|
|
['db/managed-schema/V2__more.sql', 'create table b;\n'],
|
|
['db/managed-schema/V3__upper.SQL', 'insert;\n'],
|
|
['db/managed-schema/README.md', 'not a script'],
|
|
// Only the nested jar's root is on the classpath.
|
|
['BOOT-INF/classes/db/managed-schema/V8__deep.sql', 'ignored'],
|
|
]);
|
|
await writeFile(
|
|
jar,
|
|
zip([
|
|
['BOOT-INF/classes/db/managed-schema/V1__base.sql', 'create table a;\n'],
|
|
['BOOT-INF/lib/qwen-managed-agent-persistence.jar', module, 0],
|
|
['BOOT-INF/lib/library.jar', zip([['org/A.class', 'x']]), 0],
|
|
// Spring Boot loads no compressed nested jar, and nothing elsewhere.
|
|
[
|
|
'BOOT-INF/lib/deflated.jar',
|
|
zip([['db/managed-schema/V9__deflated.sql', 'ignored']]),
|
|
8,
|
|
],
|
|
['lib/other.jar', zip([['db/managed-schema/V10__x.sql', 'ignored']]), 0],
|
|
]),
|
|
);
|
|
assert.deepEqual(Object.fromEntries(await migrationScripts(jar)), {
|
|
'db/managed-schema/V1__base.sql': sha256('create table a;\n'),
|
|
'db/managed-schema/V2__more.sql': sha256('create table b;\n'),
|
|
'db/managed-schema/V3__upper.SQL': sha256('insert;\n'),
|
|
});
|
|
|
|
// A local header may carry an extra field the central directory does not:
|
|
// each entry's data, and a nested jar's start, lie past it.
|
|
const pad = 'UT\u0005\u0000\u0001abcd';
|
|
await writeFile(
|
|
jar,
|
|
zip([
|
|
[
|
|
'BOOT-INF/classes/db/managed-schema/V1__base.sql',
|
|
'create table a;\n',
|
|
8,
|
|
pad,
|
|
],
|
|
[
|
|
'BOOT-INF/lib/qwen-managed-agent-persistence.jar',
|
|
zip([
|
|
['db/managed-schema/V2__more.sql', 'create table b;\n', 0, pad],
|
|
['db/managed-schema/V3__more.sql', 'create table c;\n', 8, pad],
|
|
]),
|
|
0,
|
|
pad,
|
|
],
|
|
]),
|
|
);
|
|
assert.deepEqual(Object.fromEntries(await migrationScripts(jar)), {
|
|
'db/managed-schema/V1__base.sql': sha256('create table a;\n'),
|
|
'db/managed-schema/V2__more.sql': sha256('create table b;\n'),
|
|
'db/managed-schema/V3__more.sql': sha256('create table c;\n'),
|
|
});
|
|
|
|
// One path shipped twice is one script when the copies match, and a
|
|
// misread when they do not.
|
|
const twice = (text) =>
|
|
zip([
|
|
['BOOT-INF/classes/db/managed-schema/V1__base.sql', 'create table a;\n'],
|
|
[
|
|
'BOOT-INF/lib/qwen-managed-agent-persistence.jar',
|
|
zip([['db/managed-schema/V1__base.sql', text]]),
|
|
0,
|
|
],
|
|
]);
|
|
await writeFile(jar, twice('create table a;\n'));
|
|
assert.deepEqual(Object.fromEntries(await migrationScripts(jar)), {
|
|
'db/managed-schema/V1__base.sql': sha256('create table a;\n'),
|
|
});
|
|
await writeFile(jar, twice('create table b;\n'));
|
|
await assert.rejects(
|
|
migrationScripts(jar),
|
|
/^Error: .*server\.jar ships two different copies of db\/managed-schema\/V1__base\.sql, in BOOT-INF\/classes and in BOOT-INF\/lib\/qwen-managed-agent-persistence\.jar$/,
|
|
);
|
|
// The same holds for modules, and the error names the module that shipped
|
|
// the path first, past a matching copy in another.
|
|
const inModules = (text) =>
|
|
zip([
|
|
[
|
|
'BOOT-INF/lib/qwen-managed-agent-persistence.jar',
|
|
zip([['db/managed-schema/V1__base.sql', 'create table a;\n']]),
|
|
0,
|
|
],
|
|
[
|
|
'BOOT-INF/lib/library.jar',
|
|
zip([
|
|
['org/A.class', 'x'],
|
|
['db/managed-schema/V1__base.sql', 'create table a;\n'],
|
|
]),
|
|
0,
|
|
],
|
|
[
|
|
'BOOT-INF/lib/qwen-managed-agent-adapters.jar',
|
|
zip([
|
|
['db/managed-schema/V2__more.sql', 'create table b;\n'],
|
|
['db/managed-schema/V1__base.sql', text, 8],
|
|
]),
|
|
0,
|
|
],
|
|
]);
|
|
await writeFile(jar, inModules('create table a;\n'));
|
|
assert.deepEqual(Object.fromEntries(await migrationScripts(jar)), {
|
|
'db/managed-schema/V1__base.sql': sha256('create table a;\n'),
|
|
'db/managed-schema/V2__more.sql': sha256('create table b;\n'),
|
|
});
|
|
await writeFile(jar, inModules('create table c;\n'));
|
|
await assert.rejects(
|
|
migrationScripts(jar),
|
|
/^Error: .*server\.jar ships two different copies of db\/managed-schema\/V1__base\.sql, in BOOT-INF\/lib\/qwen-managed-agent-persistence\.jar and in BOOT-INF\/lib\/qwen-managed-agent-adapters\.jar$/,
|
|
);
|
|
|
|
// A nested jar that is none, or whose directory runs past its end, is a
|
|
// misread, not a module without scripts.
|
|
await writeFile(jar, zip([['BOOT-INF/lib/broken.jar', 'not a jar', 0]]));
|
|
await assert.rejects(
|
|
migrationScripts(jar),
|
|
/^Error: BOOT-INF\/lib\/broken\.jar in .*server\.jar is not a zip archive$/,
|
|
);
|
|
const past = Buffer.from(module);
|
|
past.writeUInt32LE(past.length, past.length - 22 + 16);
|
|
await writeFile(
|
|
jar,
|
|
zip([['BOOT-INF/lib/qwen-managed-agent-persistence.jar', past, 0]]),
|
|
);
|
|
await assert.rejects(
|
|
migrationScripts(jar),
|
|
/^Error: BOOT-INF\/lib\/qwen-managed-agent-persistence\.jar in .*server\.jar ends early$/,
|
|
);
|
|
});
|
|
|
|
test('check keeps the releases outside the distribution', async (t) => {
|
|
const host = await rig(t);
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'check',
|
|
'--releases',
|
|
path.join(host.current, 'releases'),
|
|
),
|
|
1,
|
|
);
|
|
assert.match(
|
|
rowsOf(host.output).layout,
|
|
/^FAIL .* is inside the distribution/,
|
|
);
|
|
// install copies the new distribution into the releases directory.
|
|
assert.equal(
|
|
await host.check('--releases', path.join(host.distribution, 'releases')),
|
|
1,
|
|
);
|
|
assert.match(
|
|
rowsOf(host.output).layout,
|
|
new RegExp(`^FAIL .* is inside the distribution ${host.distribution}$`),
|
|
);
|
|
// install renames the installed directory into the releases directory,
|
|
// which a rename cannot do across file systems.
|
|
const device = (await stat(path.dirname(host.current))).dev;
|
|
let elsewhere;
|
|
for (const candidate of ['/dev/shm', '/run', os.tmpdir()]) {
|
|
try {
|
|
if ((await stat(candidate)).dev !== device) {
|
|
elsewhere = candidate;
|
|
break;
|
|
}
|
|
} catch {
|
|
// Not on this host.
|
|
}
|
|
}
|
|
if (!elsewhere) {
|
|
t.diagnostic('no second file system to put the releases on');
|
|
return;
|
|
}
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'check',
|
|
'--releases',
|
|
path.join(elsewhere, `qwen-upgrade-releases-${process.pid}`),
|
|
),
|
|
1,
|
|
);
|
|
assert.match(
|
|
rowsOf(host.output).layout,
|
|
/^FAIL .* is on another file system than/,
|
|
);
|
|
});
|
|
|
|
test('refuses a checksum list that names a path outside the release', async (t) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'qwen-upgrade-sums-'));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
await writeFile(path.join(root, 'a.txt'), 'a\n');
|
|
const digest = sha256('a\n');
|
|
await writeFile(
|
|
path.join(root, 'SHA256SUMS'),
|
|
`${digest} a.txt\n${digest} ../a.txt\n${digest} /etc/passwd\n`,
|
|
);
|
|
const { listed, failed } = await verifyChecksums(root);
|
|
assert.deepEqual(listed, ['a.txt']);
|
|
assert.deepEqual(failed, [`${digest} ../a.txt`, `${digest} /etc/passwd`]);
|
|
});
|
|
|
|
test('check compares the new release with the installed one', async (t) => {
|
|
const host = await rig(t);
|
|
assert.equal(await host.check(), 0);
|
|
const rows = rowsOf(host.output);
|
|
assert.match(rows.distribution, /^PASS 6 file\(s\)/);
|
|
assert.match(rows.deployment, /^PASS/);
|
|
assert.equal(rows.version, 'PASS 0.1.0 -> 0.2.0');
|
|
assert.equal(rows.database, 'PASS the 1 Flyway script(s) did not change');
|
|
assert.match(rows.java, /^PASS .* is Java 21, >=21/);
|
|
assert.match(rows.node, /^PASS .* is v22.1.0/);
|
|
assert.match(rows['qwen-code'], /^PASS/);
|
|
assert.match(rows['harness-env'], /^PASS/);
|
|
assert.match(rows.config, /^PASS install copies application.yml/);
|
|
assert.match(rows['unit-files'], /^PASS/);
|
|
assert.match(rows['backup-tools'], /^PASS backup dumps the database with /);
|
|
assert.equal(
|
|
rows['runtime-state'],
|
|
`PASS backup archives ${host.state} (QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY)`,
|
|
);
|
|
assert.match(
|
|
rows.layout,
|
|
new RegExp(
|
|
`^PASS install moves ${host.current} to ${host.current}-releases/0.1.0`,
|
|
),
|
|
);
|
|
assert.ok(host.output.includes('Plan:'));
|
|
// check never changes a unit.
|
|
assert.ok(
|
|
host.calls.every(
|
|
([command, verb]) => command !== 'systemctl' || verb === 'show',
|
|
),
|
|
);
|
|
});
|
|
|
|
test('check does not tell a newer Harness to go back', async (t) => {
|
|
const host = await rig(t);
|
|
host.qwen = '1.2.10';
|
|
assert.equal(await host.check(), 0);
|
|
assert.match(
|
|
rowsOf(host.output)['qwen-code'],
|
|
/^WARN .* is 1\.2\.10, newer than the 1\.2\.3 this release is built for$/,
|
|
);
|
|
assert.ok(
|
|
host.output.some((line) =>
|
|
line.endsWith(
|
|
'Keep Qwen Code 1.2.10 for the Harness only if you have tested it with this release; otherwise, after stop, install 1.2.3 (manual).',
|
|
),
|
|
),
|
|
);
|
|
// An older one, or one that names no version, gets the release's.
|
|
for (const qwen of ['1.2.3-rc.1', 'a local build']) {
|
|
host.qwen = qwen;
|
|
assert.equal(await host.check(), 0);
|
|
assert.match(
|
|
rowsOf(host.output)['qwen-code'],
|
|
new RegExp(`^WARN .* is ${qwen}; this release is built for 1\\.2\\.3$`),
|
|
);
|
|
assert.ok(
|
|
host.output.some((line) =>
|
|
line.endsWith(
|
|
'After stop, install Qwen Code 1.2.3 for the Harness (manual).',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('check refuses what the new release cannot replace', async (t) => {
|
|
const cases = [
|
|
[{ version: '0.1.0' }, 'version', /^FAIL 0.1.0 is already installed/],
|
|
[{ version: '0.0.9' }, 'version', /^FAIL 0.0.9 is older/],
|
|
[
|
|
{
|
|
scripts: {
|
|
'db/managed-schema/V1__managed_schema.sql':
|
|
'create table a (b int);\n',
|
|
},
|
|
},
|
|
'database',
|
|
/^FAIL this release changed or removed V1__managed_schema.sql, which the installed database ran/,
|
|
],
|
|
[
|
|
{ scripts: {} },
|
|
'database',
|
|
/^WARN cannot compare the Flyway scripts \(no Flyway script found in .*server.jar\)/,
|
|
],
|
|
[
|
|
// Flyway reads the suffix in any case.
|
|
{
|
|
scripts: { ...BASELINE, 'db/managed-schema/V2__seed.SQL': 'insert;\n' },
|
|
},
|
|
'database',
|
|
/^WARN start applies V2__seed.SQL; only the database backup undoes it/,
|
|
],
|
|
[
|
|
// A changed repeatable script runs again; a removed one fails validation.
|
|
{
|
|
installedScripts: { ...BASELINE, 'db/managed-schema/R__view.sql': 'a' },
|
|
scripts: { ...BASELINE, 'db/managed-schema/R__view.sql': 'b' },
|
|
},
|
|
'database',
|
|
/^WARN start applies R__view.sql/,
|
|
],
|
|
[
|
|
{
|
|
installedScripts: { ...BASELINE, 'db/managed-schema/R__view.sql': 'a' },
|
|
scripts: BASELINE,
|
|
},
|
|
'database',
|
|
/^FAIL this release changed or removed R__view.sql/,
|
|
],
|
|
[
|
|
{
|
|
scripts: {
|
|
...BASELINE,
|
|
'db/managed-schema/V2__more.sql': 'create table c;\n',
|
|
},
|
|
},
|
|
'database',
|
|
/^WARN start applies V2__more.sql; only the database backup undoes it/,
|
|
],
|
|
[
|
|
{ files: { 'lib/server.jar': 'not a jar' } },
|
|
'database',
|
|
/^WARN cannot compare the Flyway scripts \(.*server.jar is not a zip archive\)/,
|
|
],
|
|
// Since the server's split into modules its scripts ship in the nested
|
|
// jar of one: moved there unchanged, they are the scripts the database ran.
|
|
[
|
|
{ nested: true },
|
|
'database',
|
|
/^PASS the 1 Flyway script\(s\) did not change$/,
|
|
],
|
|
[
|
|
{
|
|
installedNested: true,
|
|
nested: true,
|
|
scripts: { ...BASELINE, 'db/managed-schema/V2__more.sql': 'b;\n' },
|
|
},
|
|
'database',
|
|
/^WARN start applies V2__more.sql; only the database backup undoes it/,
|
|
],
|
|
[
|
|
{
|
|
installedNested: true,
|
|
nested: true,
|
|
scripts: { 'db/managed-schema/V1__managed_schema.sql': 'changed;\n' },
|
|
},
|
|
'database',
|
|
/^FAIL this release changed or removed V1__managed_schema.sql/,
|
|
],
|
|
[
|
|
{ harnessEnv: ['QWEN_HOSTED_HARNESS_BROKER_ARGS=--x'] },
|
|
'harness-env',
|
|
/^WARN .* sets QWEN_HOSTED_HARNESS_BROKER_ARGS/,
|
|
],
|
|
[
|
|
{ serverEnv: ['QWEN_MANAGED_AGENT_RUNTIME_BROKER_TOKEN=b'] },
|
|
'harness-env',
|
|
/^WARN both .* set the Broker token/,
|
|
],
|
|
[
|
|
{ files: { 'systemd/qwen-hosted-harness.service': '[Service]\nX=1\n' } },
|
|
'unit-files',
|
|
/^WARN systemd\/qwen-hosted-harness.service changed/,
|
|
],
|
|
];
|
|
for (const [overrides, name, expected] of cases) {
|
|
const host = await rig(t, overrides);
|
|
const code = await host.check();
|
|
assert.match(rowsOf(host.output)[name], expected, name);
|
|
assert.equal(code, expected.source.startsWith('^FAIL') ? 1 : 0, name);
|
|
}
|
|
|
|
const old = await rig(t);
|
|
old.java = 'openjdk version "17.0.9"';
|
|
old.qwen = '1.2.2';
|
|
await writeFile(
|
|
path.join(old.distribution, 'bin/qwen-managed-agent-server'),
|
|
'tampered',
|
|
);
|
|
assert.equal(await old.check(), 1);
|
|
assert.ok(
|
|
old.output.includes(
|
|
'stop refuses until the FAIL rows pass. Then the plan is:',
|
|
),
|
|
);
|
|
const rows = rowsOf(old.output);
|
|
assert.match(rows.java, /^FAIL .* is Java 17; this release needs >=21/);
|
|
assert.match(
|
|
rows['qwen-code'],
|
|
/^WARN .* is 1.2.2; this release is built for 1.2.3/,
|
|
);
|
|
assert.match(
|
|
rows.distribution,
|
|
/^FAIL bin\/qwen-managed-agent-server does not match/,
|
|
);
|
|
});
|
|
|
|
test('the Broker settings change after backup and before start', async (t) => {
|
|
const { host, target } = await backedUp(t, {
|
|
harnessEnv: ['QWEN_HOSTED_HARNESS_BROKER_ARGS=--x'],
|
|
serverEnv: ['QWEN_MANAGED_AGENT_RUNTIME_BROKER_TOKEN=b'],
|
|
});
|
|
assert.equal(await host.check(), 0);
|
|
const plan = host.output.slice(host.output.indexOf('Plan:'));
|
|
assert.match(
|
|
plan.join('\n'),
|
|
/install --backup <that directory>\n {2}5\. After backup, replace it with QWEN_MANAGED_RUNTIME_BROKER_URL and QWEN_MANAGED_RUNTIME_BROKER_TOKEN \(manual\)\.\n {2}6\. .* start/,
|
|
);
|
|
// The backup keeps the files the old release reads.
|
|
const listed = spawnSync(
|
|
'tar',
|
|
['-xOf', path.join(target, 'configuration.tar')],
|
|
{
|
|
encoding: 'utf8',
|
|
},
|
|
).stdout;
|
|
assert.match(listed, /QWEN_HOSTED_HARNESS_BROKER_ARGS=--x/);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/start refused: .* sets QWEN_HOSTED_HARNESS_BROKER_ARGS, which this release no longer reads; replace it with/,
|
|
);
|
|
await writeFile(
|
|
path.join(host.etc, 'harness.env'),
|
|
'QWEN_MANAGED_RUNTIME_BROKER_URL=http://127.0.0.1:4182\nQWEN_MANAGED_RUNTIME_BROKER_TOKEN=b\n',
|
|
);
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/start refused: both .* set the Broker token/,
|
|
);
|
|
assert.ok(
|
|
host.calls.every(
|
|
([command, verb]) => command !== 'systemctl' || verb !== 'start',
|
|
),
|
|
);
|
|
});
|
|
|
|
test('every phase but check needs root and a known phase', async (t) => {
|
|
const host = await rig(t);
|
|
assert.equal(await host.upgrade('upgrade'), 2);
|
|
assert.equal(await host.upgrade('verify', '--turn'), 2);
|
|
assert.equal(await host.upgrade('start', '--timeout', '0'), 2);
|
|
host.system.uid = () => 1000;
|
|
assert.equal(await host.upgrade('check'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/check refused: Run the upgrade as root/,
|
|
);
|
|
});
|
|
|
|
test('stop stops the control plane, reaps its workers, then the Harness', async (t) => {
|
|
const host = await rig(t);
|
|
assert.equal(await host.upgrade('stop'), 0);
|
|
const changes = host.calls.filter(
|
|
([command, verb]) => command === 'systemctl' && verb !== 'show',
|
|
);
|
|
assert.deepEqual(changes, [
|
|
['systemctl', 'stop', SERVER],
|
|
['systemctl', 'kill', '--kill-whom=all', '-s', 'TERM', SERVER],
|
|
['systemctl', 'stop', HARNESS],
|
|
]);
|
|
assert.deepEqual(host.units[SERVER].procs, []);
|
|
});
|
|
|
|
test('stop leaves the Harness running while workers outlive the timeout', async (t) => {
|
|
const host = await rig(t);
|
|
host.workersIgnoreTerm = true;
|
|
assert.equal(await host.upgrade('stop', '--timeout', '3'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/Runtime workers still run after 3s \(PID 11\).*-s KILL qwen-managed-agent-server/,
|
|
);
|
|
assert.equal(host.units[HARNESS].ActiveState, 'active');
|
|
});
|
|
|
|
test('stop changes nothing when check fails', async (t) => {
|
|
const host = await rig(t, { version: '0.0.9' });
|
|
assert.equal(await host.upgrade('stop'), 1);
|
|
assert.ok(
|
|
host.calls.every(
|
|
([command, verb]) => command !== 'systemctl' || verb === 'show',
|
|
),
|
|
);
|
|
});
|
|
|
|
test('backup records the database, Runtime state and configuration', async (t) => {
|
|
const host = await rig(t);
|
|
const target = path.join(host.root, 'backup');
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
target,
|
|
'--mysqldump',
|
|
path.join(host.tools, 'mysqldump'),
|
|
),
|
|
1,
|
|
);
|
|
assert.match(host.output.join('\n'), /still run; run stop before backup/);
|
|
|
|
// The Harness unit's file and its drop-in are configuration too.
|
|
const dropIn = path.join(host.etc, `${HARNESS}.service.d/override.conf`);
|
|
await mkdir(path.dirname(dropIn), { recursive: true });
|
|
await writeFile(dropIn, '[Service]\nTimeoutStopSec=30\n');
|
|
host.units[HARNESS].DropInPaths = dropIn;
|
|
await host.stop();
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
target,
|
|
'--mysqldump',
|
|
path.join(host.tools, 'mysqldump'),
|
|
),
|
|
0,
|
|
);
|
|
assert.deepEqual((await readdir(target)).sort(), [
|
|
'backup.json',
|
|
'configuration.tar',
|
|
'database.sql',
|
|
'release-config.tar',
|
|
'runtime-state.tar',
|
|
]);
|
|
assert.equal((await stat(target)).mode & 0o777, 0o700);
|
|
for (const name of await readdir(target)) {
|
|
assert.equal(
|
|
(await stat(path.join(target, name))).mode & 0o777,
|
|
0o600,
|
|
name,
|
|
);
|
|
}
|
|
assert.match(host.dumpCredentials, /^password="p\\"w"$/m);
|
|
// The option file never sits in the backup, and is gone after the dump.
|
|
assert.ok(!host.dumpCredentialsFile.startsWith(`${target}${path.sep}`));
|
|
await assert.rejects(stat(path.dirname(host.dumpCredentialsFile)), {
|
|
code: 'ENOENT',
|
|
});
|
|
const dump = host.calls.find(
|
|
([command, first]) => command === 'mysqldump' && first !== '--version',
|
|
);
|
|
// Only the option file it wrote: root's ~/.my.cnf would come after it.
|
|
assert.ok(dump[1].startsWith('--defaults-file='));
|
|
assert.equal(
|
|
path.dirname(host.dumpEnvironment.MYSQL_TEST_LOGIN_FILE),
|
|
path.dirname(host.dumpCredentialsFile),
|
|
);
|
|
assert.equal(host.dumpEnvironment.PATH, process.env.PATH);
|
|
assert.deepEqual(dump.slice(-2), ['--databases', 'qwen_managed_agent']);
|
|
assert.ok(dump.includes('--set-gtid-purged=OFF'));
|
|
const record = JSON.parse(
|
|
await readFile(path.join(target, 'backup.json'), 'utf8'),
|
|
);
|
|
assert.deepEqual(record.from, {
|
|
version: '0.1.0',
|
|
release: `${host.current}-releases/0.1.0`,
|
|
});
|
|
assert.equal(record.distribution, host.current);
|
|
assert.equal(record.to.version, '0.2.0');
|
|
assert.equal(record.harness.unit, HARNESS);
|
|
assert.equal(record.database, 'database.sql');
|
|
assert.deepEqual(record.datasource, {
|
|
host: '127.0.0.1',
|
|
port: '3306',
|
|
database: 'qwen_managed_agent',
|
|
});
|
|
assert.equal(record.runtimeState, host.state);
|
|
assert.deepEqual(
|
|
record.files.map((file) => file.name),
|
|
[
|
|
'database.sql',
|
|
'runtime-state.tar',
|
|
'configuration.tar',
|
|
'release-config.tar',
|
|
],
|
|
);
|
|
const listed = spawnSync(
|
|
'tar',
|
|
['-tf', path.join(target, 'configuration.tar')],
|
|
{ encoding: 'utf8' },
|
|
).stdout;
|
|
for (const file of [
|
|
'etc/server.env',
|
|
'etc/harness.env',
|
|
'etc/qwen-managed-agent-server.service',
|
|
'etc/qwen-hosted-harness.service',
|
|
'etc/qwen-hosted-harness.service.d/override.conf',
|
|
]) {
|
|
assert.ok(listed.includes(file), file);
|
|
}
|
|
// The installed config/ goes in by its path in the release, which is where
|
|
// the rollback finds it once the distribution path is a link.
|
|
assert.ok(!listed.includes('opt/qwen-managed-agent'));
|
|
assert.deepEqual(
|
|
spawnSync('tar', ['-tf', path.join(target, 'release-config.tar')], {
|
|
encoding: 'utf8',
|
|
})
|
|
.stdout.split('\n')
|
|
.filter(Boolean)
|
|
.sort(),
|
|
['config/', 'config/application.example.yml', 'config/application.yml'],
|
|
);
|
|
assert.ok(
|
|
host.output.includes(
|
|
`install moves the previous release to ${host.current}-releases/0.1.0.`,
|
|
),
|
|
);
|
|
assert.ok(
|
|
spawnSync('tar', ['-tf', path.join(target, 'runtime-state.tar')], {
|
|
encoding: 'utf8',
|
|
}).stdout.includes('./workers/w1.json'),
|
|
);
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
target,
|
|
'--mysqldump',
|
|
path.join(host.tools, 'mysqldump'),
|
|
),
|
|
1,
|
|
);
|
|
assert.match(host.output.join('\n'), /is not empty/);
|
|
});
|
|
|
|
test('a signal during backup removes what a failure would', async (t) => {
|
|
const host = await rig(t);
|
|
await host.stop();
|
|
const backup = (to) =>
|
|
host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
to,
|
|
'--mysqldump',
|
|
path.join(host.tools, 'mysqldump'),
|
|
);
|
|
for (const made of [false, true]) {
|
|
const to = path.join(host.root, made ? 'made' : 'fresh');
|
|
if (made) {
|
|
await mkdir(to);
|
|
}
|
|
let seen;
|
|
// What the real handler does before the process exits as the signal
|
|
// would have, skipping every finally block. In a given directory the
|
|
// first file it removes cannot go, which must not keep the option file.
|
|
const database = path.join(to, 'database.sql');
|
|
const { rmSync: original } = fs;
|
|
t.after(() => {
|
|
fs.rmSync = original;
|
|
syncBuiltinESMExports();
|
|
});
|
|
host.onDump = () => {
|
|
fs.rmSync = (file, options) => {
|
|
if (made && file === database) {
|
|
throw Object.assign(new Error('EBUSY: injected'), { code: 'EBUSY' });
|
|
}
|
|
return original(file, options);
|
|
};
|
|
syncBuiltinESMExports();
|
|
host.signal('SIGTERM');
|
|
fs.rmSync = original;
|
|
syncBuiltinESMExports();
|
|
seen = {
|
|
credentials: existsSync(path.dirname(host.dumpCredentialsFile)),
|
|
to: existsSync(to) && readdirSyncSorted(to),
|
|
};
|
|
host.mysqldumpStatus = 1;
|
|
};
|
|
t.after(() => {
|
|
host.onDump = undefined;
|
|
host.mysqldumpStatus = 0;
|
|
});
|
|
assert.equal(await backup(to), 1);
|
|
assert.ok(host.output.includes('backup stopped by SIGTERM'));
|
|
assert.deepEqual(
|
|
host.output.filter((line) => line.includes(' is left: ')),
|
|
made ? [`${database} is left: EBUSY: injected`] : [],
|
|
);
|
|
assert.deepEqual(seen, {
|
|
credentials: false,
|
|
// A directory the backup made goes; one it was given stays, empty
|
|
// but for the file that could not go.
|
|
to: made ? ['database.sql'] : false,
|
|
});
|
|
assert.equal(host.signal, undefined);
|
|
host.onDump = undefined;
|
|
host.mysqldumpStatus = 0;
|
|
}
|
|
});
|
|
|
|
test('a signal removes the scratch files and the partial copy', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
// The environment files a resolver run reads, Environment= secrets among them.
|
|
// Checked before the throw, since the real process exits without running
|
|
// the finally blocks that would remove it too.
|
|
let scratch;
|
|
let left;
|
|
host.onService = (service) => {
|
|
scratch = path.dirname(service.files[0].file);
|
|
host.onService = undefined;
|
|
host.signal('SIGINT');
|
|
left = existsSync(scratch);
|
|
throw new Error('the process has exited');
|
|
};
|
|
await assert.rejects(host.check(), { message: 'the process has exited' });
|
|
assert.ok(host.output.includes('check stopped by SIGINT'));
|
|
assert.equal(left, false, scratch);
|
|
|
|
// A copy of the release that a signal ends halfway.
|
|
const { cp: original } = fsPromises;
|
|
t.after(() => {
|
|
fsPromises.cp = original;
|
|
syncBuiltinESMExports();
|
|
});
|
|
let partial;
|
|
let copied;
|
|
fsPromises.cp = async (from, to, options) => {
|
|
await original(from, to, options);
|
|
partial = to;
|
|
host.signal('SIGTERM');
|
|
copied = existsSync(partial);
|
|
throw new Error('the process has exited');
|
|
};
|
|
syncBuiltinESMExports();
|
|
await assert.rejects(host.upgrade('install', '--backup', target), {
|
|
message: 'the process has exited',
|
|
});
|
|
assert.ok(host.output.includes('install stopped by SIGTERM'));
|
|
assert.match(path.basename(partial), /^\.0\.2\.0\.partial-[0-9a-f]{8}$/);
|
|
assert.equal(copied, false);
|
|
});
|
|
|
|
test('backup refuses a directory another account owns', async (t) => {
|
|
const host = await rig(t);
|
|
await host.stop();
|
|
const to = path.join(host.root, 'shared');
|
|
await mkdir(to);
|
|
const backup = () =>
|
|
host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
to,
|
|
'--mysqldump',
|
|
path.join(host.tools, 'mysqldump'),
|
|
);
|
|
const owner = (await stat(to)).uid;
|
|
host.euid = owner + 1;
|
|
assert.equal(await backup(), 1);
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
`backup refused: ${to} belongs to uid ${owner}, not to this account; choose a new directory.`,
|
|
);
|
|
assert.deepEqual(await readdir(to), []);
|
|
host.euid = undefined;
|
|
assert.equal(await backup(), 0);
|
|
});
|
|
|
|
test('backup archives what a linked configuration file names', async (t) => {
|
|
const host = await rig(t);
|
|
await host.stop();
|
|
const env = path.join(host.root, 'etc/server.env');
|
|
const secrets = path.join(host.root, 'secrets');
|
|
await mkdir(secrets);
|
|
await rename(env, path.join(secrets, 'server.env'));
|
|
await symlink(path.join(secrets, 'server.env'), env);
|
|
const to = path.join(host.root, 'backup');
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
to,
|
|
'--mysqldump',
|
|
path.join(host.tools, 'mysqldump'),
|
|
),
|
|
0,
|
|
);
|
|
const listed = spawnSync(
|
|
'tar',
|
|
['-tvf', path.join(to, 'configuration.tar')],
|
|
{
|
|
encoding: 'utf8',
|
|
},
|
|
).stdout;
|
|
const named = (file) =>
|
|
listed
|
|
.split('\n')
|
|
.find(
|
|
(line) =>
|
|
line.endsWith(` ${file.slice(1)}`) ||
|
|
line.includes(` ${file.slice(1)} -> `),
|
|
);
|
|
assert.match(named(env), /^l/);
|
|
const real = path.join(await realpath(secrets), 'server.env');
|
|
assert.match(named(real), /^-/);
|
|
});
|
|
|
|
test('backup leaves no partial backup when the dump fails', async (t) => {
|
|
const host = await rig(t);
|
|
await host.stop();
|
|
host.mysqldumpStatus = 2;
|
|
const target = path.join(host.root, 'backup');
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
target,
|
|
'--mysqldump',
|
|
path.join(host.tools, 'mysqldump'),
|
|
),
|
|
1,
|
|
);
|
|
assert.match(host.output.join('\n'), /denied/);
|
|
await assert.rejects(stat(target), { code: 'ENOENT' });
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
target,
|
|
'--mysqldump',
|
|
path.join(host.root, 'none'),
|
|
),
|
|
1,
|
|
);
|
|
assert.match(host.output.join('\n'), /is not installed/);
|
|
await assert.rejects(stat(target), { code: 'ENOENT' });
|
|
});
|
|
|
|
async function modesOf(root, names) {
|
|
return Promise.all(
|
|
names.map(async (name) => (await stat(path.join(root, name))).mode & 0o777),
|
|
);
|
|
}
|
|
|
|
async function backedUp(t, overrides) {
|
|
const host = await rig(t, overrides);
|
|
await host.stop();
|
|
const target = path.join(host.root, 'backup');
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
target,
|
|
'--mysqldump',
|
|
path.join(host.tools, 'mysqldump'),
|
|
),
|
|
0,
|
|
);
|
|
return { host, target };
|
|
}
|
|
|
|
test('install switches the distribution link and keeps the old release', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
// A directory only root reads, and a file no release lists, such as one an
|
|
// operator put there, which keeps its mode.
|
|
await chmod(path.join(host.distribution, 'lib'), 0o700);
|
|
await chmod(
|
|
path.join(host.distribution, 'bin/qwen-managed-agent-server'),
|
|
0o700,
|
|
);
|
|
await writeFile(path.join(host.distribution, 'config/extra.yml'), 'k: v\n', {
|
|
mode: 0o600,
|
|
});
|
|
assert.equal(await host.upgrade('install'), 1);
|
|
assert.match(host.output.join('\n'), /install needs --backup/);
|
|
// A backup of another installed release.
|
|
const stale = path.join(host.root, 'stale');
|
|
await cp(target, stale, { recursive: true });
|
|
const record = JSON.parse(
|
|
await readFile(path.join(stale, 'backup.json'), 'utf8'),
|
|
);
|
|
record.from.version = '0.0.9';
|
|
await writeFile(path.join(stale, 'backup.json'), JSON.stringify(record));
|
|
assert.equal(await host.upgrade('install', '--backup', stale), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/the backup is of 0.0.9, but 0.1.0 is installed; run backup again/,
|
|
);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
|
|
const releases = `${host.current}-releases`;
|
|
assert.ok((await lstat(host.current)).isSymbolicLink());
|
|
assert.equal(await readlink(host.current), path.join(releases, '0.2.0'));
|
|
const previous = path.join(releases, '0.1.0');
|
|
assert.match(
|
|
await readFile(path.join(previous, 'manifest.json'), 'utf8'),
|
|
/"0.1.0"/,
|
|
);
|
|
const copied = path.join(releases, '0.2.0/config/application.yml');
|
|
assert.equal(await readFile(copied, 'utf8'), 'key: x\n');
|
|
assert.equal((await stat(copied)).mode & 0o777, 0o600);
|
|
assert.deepEqual((await readdir(releases)).sort(), ['0.1.0', '0.2.0']);
|
|
// The service account reads the copy of what root extracted 0600.
|
|
assert.equal(
|
|
(await stat(path.join(releases, '0.2.0/lib/server.jar'))).mode & 0o777,
|
|
0o644,
|
|
);
|
|
assert.equal(
|
|
(await stat(path.join(releases, '0.2.0/lib'))).mode & 0o777,
|
|
0o755,
|
|
);
|
|
assert.equal(
|
|
(await stat(path.join(releases, '0.2.0/bin/qwen-managed-agent-server')))
|
|
.mode & 0o777,
|
|
0o755,
|
|
);
|
|
assert.equal(
|
|
(await stat(path.join(releases, '0.2.0/config/extra.yml'))).mode & 0o777,
|
|
0o600,
|
|
);
|
|
|
|
// A second run, as after an interrupted session, finds the release
|
|
// installed and points at start, not at another backup.
|
|
assert.equal(await host.upgrade('install', '--backup', target), 1);
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
`install refused: 0.2.0 is already installed at ${host.current}; run start or verify for this release.`,
|
|
);
|
|
// Nor would a backup now be of the release to roll back to.
|
|
const late = path.join(host.root, 'late');
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
late,
|
|
'--mysqldump',
|
|
path.join(host.tools, 'mysqldump'),
|
|
),
|
|
1,
|
|
);
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
`backup refused: 0.2.0 is already installed at ${host.current}; roll back with the backup install was given.`,
|
|
);
|
|
await assert.rejects(stat(late), { code: 'ENOENT' });
|
|
});
|
|
|
|
test('install refuses a backup the control plane has run past', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
host.units[SERVER].ActiveEnterTimestampMonotonic = '5000';
|
|
assert.equal(await host.upgrade('install', '--backup', target), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/has started since the backup; run backup again/,
|
|
);
|
|
|
|
const second = await backedUp(t);
|
|
// Same size, other bytes: only the checksum tells them apart.
|
|
const dump = path.join(second.target, 'database.sql');
|
|
const original = await readFile(dump, 'utf8');
|
|
await writeFile(dump, original.replace('-- dump', '-- DUMP'));
|
|
assert.equal(
|
|
await second.host.upgrade('install', '--backup', second.target),
|
|
1,
|
|
);
|
|
assert.match(
|
|
second.host.output.join('\n'),
|
|
/database.sql in the backup changed/,
|
|
);
|
|
assert.equal((await lstat(second.host.current)).isDirectory(), true);
|
|
|
|
// A reboot restarts the monotonic clock, so the boot ID ties the
|
|
// timestamps to the boot they were read in.
|
|
const third = await backedUp(t);
|
|
third.host.system.bootId = async () => 'boot-2';
|
|
assert.equal(
|
|
await third.host.upgrade('install', '--backup', third.target),
|
|
1,
|
|
);
|
|
assert.match(
|
|
third.host.output.join('\n'),
|
|
/has started since the backup; run backup again/,
|
|
);
|
|
|
|
// start migrates the database the unit names, which the dump must be of.
|
|
const fourth = await backedUp(t);
|
|
const environment = path.join(fourth.host.etc, 'server.env');
|
|
await writeFile(
|
|
environment,
|
|
(await readFile(environment, 'utf8')).replace(
|
|
'/qwen_managed_agent',
|
|
'/qwen_managed_agent_b',
|
|
),
|
|
);
|
|
assert.equal(
|
|
await fourth.host.upgrade('install', '--backup', fourth.target),
|
|
1,
|
|
);
|
|
assert.match(
|
|
fourth.host.output.join('\n'),
|
|
/SPRING_DATASOURCE_URL names another database than the backup's; run backup again/,
|
|
);
|
|
|
|
// The rollback finds the old release where backup said install puts it.
|
|
const fifth = await backedUp(t);
|
|
assert.equal(
|
|
await fifth.host.upgrade(
|
|
'install',
|
|
'--backup',
|
|
fifth.target,
|
|
'--releases',
|
|
path.join(fifth.host.root, 'elsewhere'),
|
|
),
|
|
1,
|
|
);
|
|
assert.match(
|
|
fifth.host.output.join('\n'),
|
|
/the backup expects the previous release at .*-releases\/0.1.0, not .*elsewhere\/0.1.0; pass the --releases backup had/,
|
|
);
|
|
});
|
|
|
|
test('install replaces an existing link in one step', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
// The next release, installed over the link the first one created.
|
|
const next = path.join(host.root, 'next');
|
|
await writeDistribution(next, { version: '0.3.0' });
|
|
host.system.distribution = next;
|
|
const second = path.join(host.root, 'backup-2');
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
second,
|
|
'--mysqldump',
|
|
path.join(host.tools, 'mysqldump'),
|
|
),
|
|
0,
|
|
);
|
|
assert.equal(await host.upgrade('install', '--backup', second), 0);
|
|
assert.equal(await readlink(host.current), `${host.current}-releases/0.3.0`);
|
|
assert.equal(
|
|
await readFile(path.join(host.current, 'config/application.yml'), 'utf8'),
|
|
'key: x\n',
|
|
);
|
|
});
|
|
|
|
test('start runs the preflight between the Harness and the control plane', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/holds 0.1.0, not 0.2.0; run install first/,
|
|
);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
|
|
// A worker left in the stopped server's cgroup would race the new one.
|
|
host.units[SERVER].procs = ['40'];
|
|
await host.system.sleep(0);
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/Runtime workers of qwen-managed-agent-server still run \(PID 40\); run stop first/,
|
|
);
|
|
assert.equal(host.units[HARNESS].ActiveState, 'inactive');
|
|
host.units[SERVER].procs = [];
|
|
await host.system.sleep(0);
|
|
|
|
host.preflightStatus = 1;
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/preflight failed, so qwen-managed-agent-server stays stopped/,
|
|
);
|
|
assert.equal(host.units[HARNESS].ActiveState, 'active');
|
|
assert.equal(host.units[SERVER].ActiveState, 'inactive');
|
|
const preflight = host.services.at(-1);
|
|
assert.ok(preflight.args.includes('--uid=svc'));
|
|
// Environment= reaches it through a file, read before server.env, and
|
|
// never through the command line any local user can read.
|
|
assert.ok(!preflight.args.some((arg) => arg.startsWith('--setenv')));
|
|
assert.deepEqual(
|
|
preflight.files.map(({ file }) => path.basename(file)),
|
|
['unit.env', 'server.env'],
|
|
);
|
|
assert.equal(preflight.environment.get('PATH'), host.tools);
|
|
assert.equal(preflight.environment.get('SPRING_DATASOURCE_PASSWORD'), 'p"w');
|
|
assert.deepEqual(preflight.args.slice(-2), [
|
|
path.join(host.current, 'bin/qwen-managed-agent-server'),
|
|
'--check',
|
|
]);
|
|
// The scratch directory goes with the command.
|
|
await assert.rejects(stat(path.dirname(preflight.files[0].file)), {
|
|
code: 'ENOENT',
|
|
});
|
|
|
|
host.preflightStatus = 0;
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
host.calls = [];
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
assert.equal(host.units[SERVER].ActiveState, 'active');
|
|
// The Harness the failed preflight left running reads what was fixed.
|
|
assert.deepEqual(
|
|
host.calls.filter(
|
|
([command, verb]) => command === 'systemctl' && verb !== 'show',
|
|
),
|
|
[
|
|
['systemctl', 'restart', HARNESS],
|
|
['systemctl', 'start', SERVER],
|
|
],
|
|
);
|
|
assert.ok(
|
|
host.output.includes(
|
|
`Restarting ${HARNESS}, which an earlier start left running`,
|
|
),
|
|
);
|
|
assert.ok(
|
|
host.requests.some(
|
|
({ url }) => url === 'http://127.0.0.1:18080/actuator/health',
|
|
),
|
|
);
|
|
});
|
|
|
|
test('start gives up when the control plane never reports UP', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.responses['GET /actuator/health'] = { status: 'DOWN' };
|
|
assert.equal(await host.upgrade('start', '--timeout', '5'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/did not report UP at http:\/\/127.0.0.1:18080\/actuator\/health within 5s \(the last answer: HTTP 200, status DOWN\) and keeps running; .* run stop to start it again/,
|
|
);
|
|
assert.equal(host.units[SERVER].ActiveState, 'active');
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/already runs; run verify, or run stop to start it again/,
|
|
);
|
|
|
|
// The release is installed, and stop still stops it.
|
|
host.calls = [];
|
|
assert.equal(await host.upgrade('stop'), 0);
|
|
assert.deepEqual(
|
|
host.calls.filter(
|
|
([command, verb]) => command === 'systemctl' && verb !== 'show',
|
|
),
|
|
[
|
|
['systemctl', 'stop', SERVER],
|
|
['systemctl', 'stop', HARNESS],
|
|
],
|
|
);
|
|
// check keeps refusing to upgrade to what is installed.
|
|
assert.equal(await host.check(), 1);
|
|
assert.match(rowsOf(host.output).version, /^FAIL 0.2.0 is already installed/);
|
|
|
|
// A unit that fails is told apart from one that is slow.
|
|
host.responses['GET /actuator/health'] = () => {
|
|
host.units[SERVER].ActiveState = 'failed';
|
|
return { status: 'DOWN' };
|
|
};
|
|
assert.equal(await host.upgrade('start', '--timeout', '5'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/qwen-managed-agent-server exited before it reported UP at .*, and is stopped now; see journalctl -u qwen-managed-agent-server, then run start again/,
|
|
);
|
|
|
|
// The shipped units restart a server that exits, so it never shows as
|
|
// failed: waiting to run again, or already run again.
|
|
for (const exited of [
|
|
{ ActiveState: 'activating', SubState: 'auto-restart' },
|
|
{ ActiveState: 'active', SubState: 'running', NRestarts: '1' },
|
|
]) {
|
|
host.responses['GET /actuator/health'] = () => {
|
|
Object.assign(host.units[SERVER], exited);
|
|
return { status: 'DOWN' };
|
|
};
|
|
const before = host.clock;
|
|
host.calls = [];
|
|
assert.equal(await host.upgrade('start', '--timeout', '300'), 1);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/exited before it reported UP at .*, and is stopped now/,
|
|
);
|
|
assert.ok(host.clock - before < 60, `${host.clock - before}s`);
|
|
assert.ok(
|
|
host.calls.some(
|
|
([command, verb, unit]) =>
|
|
command === 'systemctl' && verb === 'stop' && unit === SERVER,
|
|
),
|
|
);
|
|
assert.equal(host.units[SERVER].ActiveState, 'inactive');
|
|
}
|
|
|
|
// One exit is enough, though the restart RestartSec=5s brings would come
|
|
// up: start polls every second, so it sees the wait and does not take it.
|
|
let probes = 0;
|
|
host.responses['GET /actuator/health'] = () => {
|
|
probes += 1;
|
|
if (probes === 1) {
|
|
Object.assign(host.units[SERVER], {
|
|
ActiveState: 'activating',
|
|
SubState: 'auto-restart',
|
|
});
|
|
} else if (probes > 5) {
|
|
Object.assign(host.units[SERVER], {
|
|
ActiveState: 'active',
|
|
SubState: 'running',
|
|
NRestarts: '1',
|
|
});
|
|
return { status: 'UP' };
|
|
}
|
|
return { status: 'DOWN' };
|
|
};
|
|
assert.equal(await host.upgrade('start', '--timeout', '300'), 1);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/exited before it reported UP at .*, and is stopped now/,
|
|
);
|
|
assert.equal(host.units[SERVER].ActiveState, 'inactive');
|
|
});
|
|
|
|
test('start counts the restarts from its own start', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
// A counter that a manual start leaves as it was, from earlier exits.
|
|
host.keepRestarts = true;
|
|
host.units[SERVER].NRestarts = '7';
|
|
host.units[HARNESS].NRestarts = '3';
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
host.calls = [];
|
|
host.requests = [];
|
|
assert.equal(await host.upgrade('start'), 0, host.output.join('\n'));
|
|
assert.ok(host.requests.some(({ url }) => url.endsWith('/actuator/health')));
|
|
assert.ok(
|
|
!host.calls.some(
|
|
([command, verb]) => command === 'systemctl' && verb === 'stop',
|
|
),
|
|
);
|
|
|
|
// One more than the start left is still an exit.
|
|
assert.equal(await host.upgrade('stop'), 0);
|
|
host.responses['GET /actuator/health'] = () => {
|
|
host.units[SERVER].NRestarts = '8';
|
|
return { status: 'DOWN' };
|
|
};
|
|
assert.equal(await host.upgrade('start', '--timeout', '300'), 1);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/exited before it reported UP at .*, and is stopped now/,
|
|
);
|
|
});
|
|
|
|
test('a health probe that never answers ends at the timeout', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.hang = true;
|
|
const started = Date.now();
|
|
assert.equal(await host.upgrade('start', '--timeout', '1'), 1);
|
|
assert.ok(Date.now() - started < 4000, `${Date.now() - started}ms`);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/within 1s \(the last answer: The operation was aborted due to timeout\) and keeps running/,
|
|
);
|
|
assert.ok(host.requests.every(({ init }) => init.signal));
|
|
});
|
|
|
|
test('start names what the endpoint answered, not a probe the deadline cut short', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
// An endpoint that takes 50 ms to answer, on the real clock: the last
|
|
// probe, which the deadline leaves 1 ms, times out before it does.
|
|
const timedOut = [];
|
|
host.requests = [];
|
|
host.responses['GET /actuator/health'] = (search, { signal }) =>
|
|
new Promise((resolve, reject) => {
|
|
const abort = () => {
|
|
clearTimeout(answer);
|
|
timedOut.push(host.requests.length);
|
|
reject(signal.reason);
|
|
};
|
|
const answer = setTimeout(() => {
|
|
signal.removeEventListener('abort', abort);
|
|
resolve({ status: 'DOWN' });
|
|
}, 50);
|
|
signal.addEventListener('abort', abort);
|
|
});
|
|
assert.equal(await host.upgrade('start', '--timeout', '3'), 1);
|
|
assert.equal(host.requests.length, 4);
|
|
assert.deepEqual(timedOut, [4]);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/within 3s \(the last answer: HTTP 200, status DOWN\) and keeps running/,
|
|
);
|
|
|
|
// One that answered and then stopped: a probe that runs out of its own
|
|
// 5 s, not the deadline's, says so.
|
|
assert.equal(await host.upgrade('stop'), 0);
|
|
host.requests = [];
|
|
host.responses['GET /actuator/health'] = async () => {
|
|
if (host.requests.length === 1) {
|
|
return { status: 'DOWN' };
|
|
}
|
|
host.clock += 5;
|
|
throw new DOMException(
|
|
'The operation was aborted due to timeout',
|
|
'TimeoutError',
|
|
);
|
|
};
|
|
assert.equal(await host.upgrade('start', '--timeout', '6'), 1);
|
|
assert.equal(host.requests.length, 2);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/within 6s \(the last answer: The operation was aborted due to timeout\) and keeps running/,
|
|
);
|
|
|
|
// On real sockets. A body that stalls after its headers is cut short as
|
|
// well: the second probe reads 200, then nothing until the deadline.
|
|
let served = 0;
|
|
const stalls = http.createServer((request, response) => {
|
|
served += 1;
|
|
if (served === 1) {
|
|
response.writeHead(503, { 'content-type': 'application/json' });
|
|
response.end('{"status":"DOWN"}');
|
|
} else {
|
|
response.writeHead(200, { 'content-type': 'application/json' });
|
|
response.write('{"status":');
|
|
}
|
|
});
|
|
await new Promise((resolve) => stalls.listen(0, '127.0.0.1', resolve));
|
|
t.after(() => {
|
|
stalls.closeAllConnections();
|
|
stalls.close();
|
|
});
|
|
host.system.fetch = fetch;
|
|
assert.equal(await host.upgrade('stop'), 0);
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'start',
|
|
'--server-url',
|
|
`http://127.0.0.1:${stalls.address().port}`,
|
|
'--timeout',
|
|
'2',
|
|
),
|
|
1,
|
|
);
|
|
assert.ok(served >= 2, `${served} requests`);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/within 2s \(the last answer: HTTP 503, status DOWN\) and keeps running/,
|
|
);
|
|
|
|
// A refused connection after an answer is what the endpoint says now,
|
|
// though the deadline cut that probe's time too.
|
|
const closes = http.createServer((request, response) => {
|
|
closes.close();
|
|
response.writeHead(503, {
|
|
'content-type': 'application/json',
|
|
connection: 'close',
|
|
});
|
|
response.end('{"status":"DOWN"}');
|
|
});
|
|
await new Promise((resolve) => closes.listen(0, '127.0.0.1', resolve));
|
|
// The handler closes the server itself once a request lands; tear it down
|
|
// here too so an assertion that fails before any request does not leave the
|
|
// listener open and hang node --test to the job's 45-minute ceiling.
|
|
t.after(() => {
|
|
closes.closeAllConnections();
|
|
if (closes.listening) closes.close();
|
|
});
|
|
const { port } = closes.address();
|
|
assert.equal(await host.upgrade('stop'), 0);
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'start',
|
|
'--server-url',
|
|
`http://127.0.0.1:${port}`,
|
|
'--timeout',
|
|
'3',
|
|
),
|
|
1,
|
|
);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
new RegExp(
|
|
`within 3s \\(the last answer: connect ECONNREFUSED 127\\.0\\.0\\.1:${port}\\) and keeps running`,
|
|
),
|
|
);
|
|
});
|
|
|
|
test('verify says what the health endpoint answered instead of UP', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
const fake = host.system.fetch;
|
|
for (const [answer, health] of [
|
|
[
|
|
'HTTP 404',
|
|
() => {
|
|
delete host.responses['GET /actuator/health'];
|
|
return fake;
|
|
},
|
|
],
|
|
// UP counts only with a success status.
|
|
[
|
|
'HTTP 503, status UP',
|
|
() => async () => ({
|
|
ok: false,
|
|
status: 503,
|
|
json: async () => ({ status: 'UP' }),
|
|
}),
|
|
],
|
|
// A proxy's error page, and a body that breaks off after a success.
|
|
[
|
|
'HTTP 502',
|
|
() => async () => ({
|
|
ok: false,
|
|
status: 502,
|
|
json: async () => JSON.parse('<html>Bad Gateway</html>'),
|
|
}),
|
|
],
|
|
[
|
|
'HTTP 200, terminated',
|
|
() => async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => {
|
|
throw new TypeError('terminated');
|
|
},
|
|
}),
|
|
],
|
|
// The parser's message quotes the body, control bytes and all.
|
|
[
|
|
`HTTP 200, Unexpected token 'x', "x\uFFFD[2K\uFFFD]52;c;" is not valid JSON`,
|
|
() => async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => JSON.parse('x\u001b[2K\u001b]52;c;'),
|
|
}),
|
|
],
|
|
// fetch words every failure as "fetch failed"; its cause says why.
|
|
[
|
|
'self-signed certificate',
|
|
() => async () => {
|
|
throw new TypeError('fetch failed', {
|
|
cause: Object.assign(new Error('self-signed certificate'), {
|
|
code: 'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
}),
|
|
});
|
|
},
|
|
],
|
|
// A refused connection on each address of a name has only its code.
|
|
[
|
|
'ECONNREFUSED',
|
|
() => async () => {
|
|
throw new TypeError('fetch failed', {
|
|
cause: Object.assign(new AggregateError([], ''), {
|
|
code: 'ECONNREFUSED',
|
|
}),
|
|
});
|
|
},
|
|
],
|
|
]) {
|
|
host.system.fetch = health();
|
|
assert.equal(await host.upgrade('verify'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).health,
|
|
`FAIL http://127.0.0.1:18080/actuator/health is not UP: ${answer}`,
|
|
);
|
|
}
|
|
|
|
// The runtime's own fetch, at a port nothing listens on.
|
|
const closed = net.createServer();
|
|
await new Promise((resolve) => closed.listen(0, '127.0.0.1', resolve));
|
|
const { port } = closed.address();
|
|
await new Promise((resolve) => closed.close(resolve));
|
|
host.resolved = {
|
|
serverUrl: `http://127.0.0.1:${port}`,
|
|
healthUrl: `http://127.0.0.1:${port}/actuator/health`,
|
|
};
|
|
host.system.fetch = fetch;
|
|
assert.equal(await host.upgrade('verify'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).health,
|
|
`FAIL http://127.0.0.1:${port}/actuator/health is not UP: connect ECONNREFUSED 127.0.0.1:${port}`,
|
|
);
|
|
host.system.fetch = fake;
|
|
});
|
|
|
|
test('verify runs the online preflight and reads the API', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
assert.equal(await host.upgrade('verify'), 1);
|
|
assert.match(host.output.join('\n'), /not running; run start first/);
|
|
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
assert.equal(await host.upgrade('verify'), 0);
|
|
assert.match(rowsOf(host.output).session, /^SKIP no --tenant/);
|
|
assert.deepEqual(
|
|
host.calls
|
|
.filter(([command]) => command === 'systemd-run')
|
|
.at(-1)
|
|
.slice(-2),
|
|
['--check', '--online'],
|
|
);
|
|
|
|
host.responses['GET /v1/agents/sessions'] = { data: [{ id: 's/1' }] };
|
|
host.responses['GET /v1/agents/sessions/s%2F1'] = { id: 's/1' };
|
|
host.responses['GET /v1/agents/sessions/s%2F1/items'] = { data: [{}, {}] };
|
|
host.responses['POST /v1/agents/sessions'] = { id: 's2' };
|
|
let polls = 0;
|
|
host.responses['GET /v1/agents/sessions/s2/events'] = (search) => {
|
|
polls += 1;
|
|
return polls === 1
|
|
? { data: [{ sequence: 1, type: 'turn.started' }] }
|
|
: {
|
|
data: [
|
|
{ sequence: 2, type: 'turn.completed', terminal: true, search },
|
|
],
|
|
};
|
|
};
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme', '--turn'), 0);
|
|
const rows = rowsOf(host.output);
|
|
assert.equal(rows.session, 'PASS read Session s/1 and 2 item(s)');
|
|
assert.match(rows.turn, /^PASS Session s2 ended with turn.completed/);
|
|
const create = host.requests.find(({ init }) => init.method === 'POST');
|
|
assert.equal(create.init.headers['X-Qwen-Tenant-Id'], 'acme');
|
|
assert.match(create.init.headers['Idempotency-Key'], /^upgrade-verify-/);
|
|
// The public API keeps no other metadata yet; the title names the Session.
|
|
assert.deepEqual(JSON.parse(create.init.body), {
|
|
agent_id: 'qwen-code',
|
|
metadata: { title: 'qwen-managed-agent-upgrade verify' },
|
|
input: [{ type: 'input_text', text: 'Reply with the single word OK.' }],
|
|
});
|
|
assert.ok(
|
|
host.requests.some(({ url }) =>
|
|
url.endsWith('/v1/agents/sessions/s2/events?after=1&limit=100'),
|
|
),
|
|
);
|
|
|
|
polls = 0;
|
|
host.responses['GET /v1/agents/sessions/s2/events'] = () => ({
|
|
data: [{ sequence: 3, type: 'turn.failed', terminal: true }],
|
|
});
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme', '--turn'), 1);
|
|
assert.match(
|
|
rowsOf(host.output).turn,
|
|
/^FAIL Session s2 ended with turn.failed/,
|
|
);
|
|
|
|
// A sequence written as a string moves the cursor all the same.
|
|
polls = 0;
|
|
host.responses['GET /v1/agents/sessions/s2/events'] = () => {
|
|
polls += 1;
|
|
return polls === 1
|
|
? { data: [{ sequence: '4', type: 'turn.started' }] }
|
|
: { data: [{ sequence: '5', type: 'turn.completed', terminal: true }] };
|
|
};
|
|
host.requests = [];
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme', '--turn'), 0);
|
|
assert.ok(
|
|
host.requests.some(({ url }) =>
|
|
url.endsWith('/v1/agents/sessions/s2/events?after=4&limit=100'),
|
|
),
|
|
);
|
|
// Nothing but a whole number moves it, and only forward: not text Number()
|
|
// reads as one, nor the Infinity a JSON 1e400 parses to, nor a fraction,
|
|
// nor an object, nor an earlier sequence after a later one.
|
|
polls = 0;
|
|
host.responses['GET /v1/agents/sessions/s2/events'] = () => {
|
|
polls += 1;
|
|
return polls === 1
|
|
? {
|
|
data: [
|
|
{ sequence: '4', type: 'turn.started' },
|
|
...[
|
|
'Infinity',
|
|
'0x10',
|
|
' 9 ',
|
|
'1e3',
|
|
Infinity,
|
|
12.5,
|
|
{ toString: 1 },
|
|
3,
|
|
].map((sequence) => ({ sequence, type: 'turn.delta' })),
|
|
],
|
|
}
|
|
: { data: [{ sequence: 5, type: 'turn.completed', terminal: true }] };
|
|
};
|
|
host.requests = [];
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme', '--turn'), 0);
|
|
assert.deepEqual(
|
|
host.requests
|
|
.map(({ url }) => /\/s2\/events\?after=([^&]*)/.exec(url)?.[1])
|
|
.filter(Boolean),
|
|
['0', '4'],
|
|
);
|
|
});
|
|
|
|
test('start and verify reach the listeners the server resolves', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
// The server port, a management port and the Harness URL set in a
|
|
// configuration file, which server.env does not show.
|
|
host.resolved = {
|
|
serverUrl: 'http://127.0.0.1:9090',
|
|
healthUrl: 'http://127.0.0.1:9091/manage/actuator/health',
|
|
harnessBaseUrl: 'http://127.0.0.1:4171',
|
|
};
|
|
host.responses['GET /manage/actuator/health'] = { status: 'UP' };
|
|
const urls = () => [...new Set(host.requests.map(({ url }) => url))];
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
assert.deepEqual([...new Set(host.connections)], ['127.0.0.1:4171']);
|
|
assert.deepEqual(urls(), ['http://127.0.0.1:9091/manage/actuator/health']);
|
|
assert.ok(
|
|
host.output.includes(
|
|
`${SERVER} is UP at http://127.0.0.1:9091/manage/actuator/health; its API is at http://127.0.0.1:9090. Run verify before reopening ingress.`,
|
|
),
|
|
);
|
|
|
|
host.requests = [];
|
|
host.responses['GET /v1/agents/sessions'] = { data: [{ id: 's1' }] };
|
|
host.responses['GET /v1/agents/sessions/s1'] = { id: 's1' };
|
|
host.responses['GET /v1/agents/sessions/s1/items'] = { data: [] };
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 0);
|
|
const rows = rowsOf(host.output);
|
|
assert.equal(
|
|
rows.health,
|
|
'PASS http://127.0.0.1:9091/manage/actuator/health is UP',
|
|
);
|
|
assert.equal(rows.session, 'PASS read Session s1 and 0 item(s)');
|
|
// The health row comes first, and each read needs the one before it.
|
|
assert.deepEqual(urls(), [
|
|
'http://127.0.0.1:9091/manage/actuator/health',
|
|
'http://127.0.0.1:9090/v1/agents/sessions?limit=1',
|
|
'http://127.0.0.1:9090/v1/agents/sessions/s1',
|
|
'http://127.0.0.1:9090/v1/agents/sessions/s1/items?limit=20',
|
|
]);
|
|
|
|
// --server-url names the control plane, and the Harness stays where the
|
|
// configuration puts it. An @ its path holds is written %40, since a bare
|
|
// one cannot be told from the rest of a password, and is dialled as given.
|
|
await host.stop();
|
|
host.connections = [];
|
|
host.requests = [];
|
|
host.responses['GET /prefix%40acme/actuator/health'] = { status: 'UP' };
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'start',
|
|
'--server-url',
|
|
'http://proxy.test:8443/prefix%40acme/',
|
|
),
|
|
0,
|
|
);
|
|
assert.deepEqual([...new Set(host.connections)], ['127.0.0.1:4171']);
|
|
assert.deepEqual(urls(), [
|
|
'http://proxy.test:8443/prefix%40acme/actuator/health',
|
|
]);
|
|
host.requests = [];
|
|
host.responses['GET /prefix%40acme/v1/agents/sessions'] = {
|
|
data: [{ id: 's1' }],
|
|
};
|
|
host.responses['GET /prefix%40acme/v1/agents/sessions/s1'] = { id: 's1' };
|
|
host.responses['GET /prefix%40acme/v1/agents/sessions/s1/items'] = {
|
|
data: [],
|
|
};
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'verify',
|
|
'--tenant',
|
|
'acme',
|
|
'--server-url',
|
|
'http://proxy.test:8443/prefix%40acme',
|
|
),
|
|
0,
|
|
);
|
|
assert.deepEqual(urls(), [
|
|
'http://proxy.test:8443/prefix%40acme/actuator/health',
|
|
'http://proxy.test:8443/prefix%40acme/v1/agents/sessions?limit=1',
|
|
'http://proxy.test:8443/prefix%40acme/v1/agents/sessions/s1',
|
|
'http://proxy.test:8443/prefix%40acme/v1/agents/sessions/s1/items?limit=20',
|
|
]);
|
|
});
|
|
|
|
test('start and verify use server.env when the configuration does not resolve', async (t) => {
|
|
const { host, target } = await backedUp(t, {
|
|
serverEnv: ['QWEN_MANAGED_AGENT_HARNESS_BASE_URL=http://127.0.0.1:4172'],
|
|
});
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.resolveFailure = 'cannot resolve the server configuration: refused';
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
assert.ok(
|
|
host.output.includes(
|
|
"Cannot resolve the server's listeners: cannot resolve the server configuration: refused; using the URLs server.env names, or pass --server-url.",
|
|
),
|
|
);
|
|
assert.deepEqual([...new Set(host.connections)], ['127.0.0.1:4172']);
|
|
assert.deepEqual(
|
|
[...new Set(host.requests.map(({ url }) => url))],
|
|
['http://127.0.0.1:18080/actuator/health'],
|
|
);
|
|
// --server-url leaves nothing to fall back to but the Harness.
|
|
await host.stop();
|
|
host.connections = [];
|
|
assert.equal(
|
|
await host.upgrade('start', '--server-url', 'http://127.0.0.1:18081'),
|
|
0,
|
|
);
|
|
assert.ok(
|
|
host.output.includes(
|
|
"Cannot resolve the server's listeners: cannot resolve the server configuration: refused; using the Harness URL server.env names.",
|
|
),
|
|
);
|
|
assert.deepEqual([...new Set(host.connections)], ['127.0.0.1:4172']);
|
|
// An empty one is refused, as check refuses it, rather than dropped.
|
|
assert.equal(await host.upgrade('verify', '--server-url', ''), 1);
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
'verify refused: --server-url "" is not a URL.',
|
|
);
|
|
// verify reaches no Harness, so it has nothing to resolve.
|
|
host.services = [];
|
|
assert.equal(
|
|
await host.upgrade('verify', '--server-url', 'http://127.0.0.1:18081'),
|
|
0,
|
|
);
|
|
assert.ok(!host.output.some((line) => line.startsWith('Cannot resolve')));
|
|
assert.ok(!host.services.some(({ args }) => args.includes(RESOLVER)));
|
|
assert.equal(
|
|
rowsOf(host.output).health,
|
|
'PASS http://127.0.0.1:18081/actuator/health is UP',
|
|
);
|
|
|
|
await host.stop();
|
|
assert.equal(await host.upgrade('start', '--server-url', ''), 1);
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
'start refused: --server-url "" is not a URL.',
|
|
);
|
|
assert.ok(!host.output.includes(`Starting ${HARNESS}`));
|
|
|
|
// Without the resolver's verdict, start takes only a plain http(s) URL, a
|
|
// rule of its own and narrower than the server's, since a pool would drop
|
|
// every member over a value the server refuses, and leaves the rest to
|
|
// the preflight.
|
|
const env = path.join(host.etc, 'server.env');
|
|
const base = await readFile(env, 'utf8');
|
|
for (const harnessUrl of [
|
|
'http://127.0.0.1:4172/?tenant=acme',
|
|
'http://svc:[email protected]:4172',
|
|
// java.net.URI reads user information where the URL parser finds none.
|
|
'http://@127.0.0.1:4172',
|
|
'http://:@127.0.0.1:4172',
|
|
' http:\\\\@127.0.0.1:4172',
|
|
'http://svc:2024#[email protected]:4172',
|
|
// No host for java.net.URI, an authority it does not read, and a
|
|
// backslash it refuses; systemd reads a backslash in an
|
|
// EnvironmentFile as an escape, so the file doubles it.
|
|
'http://qwen_harness:4172',
|
|
'http:/@127.0.0.1:4172',
|
|
'http://127.0.0.1:4172\\\\@x',
|
|
// What the URL parser fetch uses reads as another address: a number,
|
|
// with or without a final dot or a port, in hex in either case too, or
|
|
// a bare 0x, and an octet with a leading zero, which it reads as octal.
|
|
'http://1234:4172',
|
|
'http://1234.:4172',
|
|
'http://1234/',
|
|
'http://0x1f:4172',
|
|
'http://0X1F:4172',
|
|
'http://0x:4172',
|
|
'http://010.0.0.1:4172',
|
|
]) {
|
|
await writeFile(
|
|
env,
|
|
`${base}QWEN_MANAGED_AGENT_HARNESS_BASE_URL=${harnessUrl}\n`,
|
|
);
|
|
assert.equal(await host.upgrade('start'), 1, harnessUrl);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/^start refused: The control plane reaches qwen-hosted-harness at "([^"]*\*\*\*@(127\.0\.0\.1:4172\/?|x)|http:\/\/127\.0\.0\.1:4172\/\?tenant=acme|http:\/\/((qwen_harness|1234\.?|0x1f|0X1F|0x|010\.0\.0\.1):4172|1234\/))", which (carries (a query or a fragment|a user name or password|an @ before its host)|is not a plain http or https URL, a host java.net.URI and fetch read alike with a port of up to five digits and a path of URI characters, which is all start takes while the configuration does not resolve); correct/,
|
|
);
|
|
assert.ok(!host.output.join('\n').includes('s3cret'), harnessUrl);
|
|
assert.ok(!host.output.includes(`Starting ${HARNESS}`), harnessUrl);
|
|
}
|
|
await writeFile(
|
|
env,
|
|
`${base}QWEN_MANAGED_AGENT_HARNESS_BASE_URL=http://127.0.0.1:6000\n`,
|
|
);
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
'start refused: The control plane reaches qwen-hosted-harness at "http://127.0.0.1:6000", which is on port 6000, so fetch refuses to dial it; correct qwen.managed-agent.harness.base-url.',
|
|
);
|
|
assert.ok(!host.output.includes(`Starting ${HARNESS}`));
|
|
// The rest goes to the preflight, such as a plain URL with an @ in its
|
|
// path, which java.net.URI reads as the path.
|
|
host.preflightStatus = 1;
|
|
for (const [harnessUrl, waited] of [
|
|
['http://127.0.0.1:4172/@acme/harness', '127.0.0.1:4172'],
|
|
['http://gateway.example.com/@acme/harness', 'gateway.example.com:80'],
|
|
// An escape, an IPv6 address, one that ends in an IPv4 address, a
|
|
// label that starts with a digit before the last one, and the final dot
|
|
// of a fully qualified name, all of which java.net.URI reads as a host.
|
|
['http://127.0.0.1:4172/a%20b', '127.0.0.1:4172'],
|
|
['http://[::1]:4172', '::1:4172'],
|
|
['http://[::ffff:127.0.0.1]:4172', '::ffff:7f00:1:4172'],
|
|
['http://1st.harness.internal:4172/', '1st.harness.internal:4172'],
|
|
['http://harness.internal.:4170/', 'harness.internal.:4170'],
|
|
// One label that starts with a digit, as a container's name may, with
|
|
// or without the final dot, and an empty port, which is the scheme's:
|
|
// fetch reads each as java.net.URI does.
|
|
['http://4harness:4172', '4harness:4172'],
|
|
['http://4harness.:4172', '4harness.:4172'],
|
|
['http://harness.internal:/', 'harness.internal:80'],
|
|
// The top port, and port 0, on which no Harness listens: the
|
|
// preflight fails that one.
|
|
['http://127.0.0.1:65535', '127.0.0.1:65535'],
|
|
['http://127.0.0.1:0'],
|
|
]) {
|
|
await writeFile(
|
|
env,
|
|
`${base}QWEN_MANAGED_AGENT_HARNESS_BASE_URL=${harnessUrl}\n`,
|
|
);
|
|
host.connections = [];
|
|
assert.equal(await host.upgrade('start'), 1, harnessUrl);
|
|
assert.ok(host.output.includes(`Starting ${HARNESS}`), harnessUrl);
|
|
if (waited) {
|
|
assert.deepEqual([...new Set(host.connections)], [waited], harnessUrl);
|
|
}
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
`start refused: The preflight failed, so ${SERVER} stays stopped; fix its FAIL rows and run start again.`,
|
|
);
|
|
await host.stop();
|
|
}
|
|
host.preflightStatus = 0;
|
|
await writeFile(env, base);
|
|
|
|
// An address from server.env that no URL can carry is refused as well.
|
|
await host.stop();
|
|
await writeFile(
|
|
path.join(host.etc, 'server.env'),
|
|
'QWEN_MANAGED_AGENT_SERVER_ADDRESS=fe80::1%eth0\n',
|
|
{ flag: 'a' },
|
|
);
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/start refused: The server's configuration puts the health endpoint at "http:\/\/\[fe80::1%eth0\]:18080\/actuator\/health", which is not a URL: a URL cannot carry an IPv6 zone\./,
|
|
);
|
|
});
|
|
|
|
test('refusals neutralize the DEL and C1 bytes JSON leaves in a quoted value', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.services = [];
|
|
// eslint-disable-next-line no-control-regex -- detecting surviving control bytes is the assertion
|
|
const control = /[\u0000-\u001F\u007F-\u009F]/;
|
|
// U+009B is an 8-bit CSI introducer, so \u009b2K would erase the row;
|
|
// U+0085 would split it.
|
|
for (const [setup, args, shown] of [
|
|
[
|
|
() => (host.resolved = {}),
|
|
['--server-url', 'http://127.0.0.1:18080/x\u009b2K\u0085y?q=1'],
|
|
'--server-url "http://127.0.0.1:18080/x\uFFFD2K\uFFFDy?q=1" carries a query',
|
|
],
|
|
[
|
|
() => (host.resolved = {}),
|
|
['--server-url', 'http://127.0.0.1:6000/x\u009b2K'],
|
|
'--server-url "http://127.0.0.1:6000/x\uFFFD2K" is on port 6000',
|
|
],
|
|
[
|
|
() =>
|
|
(host.resolved = {
|
|
healthUrl: 'http://127.0.0.1:6000/x\u009b2K/actuator/health',
|
|
}),
|
|
[],
|
|
'puts the health endpoint at "http://127.0.0.1:6000/x\uFFFD2K/actuator/health", which is on port 6000',
|
|
],
|
|
[
|
|
() =>
|
|
(host.resolved = {
|
|
harnessBaseUrl: 'http://127.0.0.1:4170/x\u009b2K?q',
|
|
harnessOrigin: 'false',
|
|
}),
|
|
[],
|
|
'reaches qwen-hosted-harness at "http://127.0.0.1:4170/x\uFFFD2K?q", which carries a query or a fragment',
|
|
],
|
|
]) {
|
|
setup();
|
|
assert.equal(await host.upgrade('start', ...args), 1, shown);
|
|
assert.ok(host.output.at(-1).includes(shown), host.output.at(-1));
|
|
assert.ok(
|
|
!host.output.some((line) => control.test(line)),
|
|
JSON.stringify(host.output),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('start refuses listeners it cannot reach before it starts anything', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.services = [];
|
|
host.resolved = { serverUrl: '', healthUrl: '' };
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/start refused: The server's configuration gives the health endpoint no fixed port: 0 picks one at start, and a negative one opens none\. Pass --server-url <a URL that serves the API and \/actuator\/health>\./,
|
|
);
|
|
// management.server.port -1 or 0, with the server on a fixed port.
|
|
host.resolved = { healthUrl: '' };
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/gives the health endpoint no fixed port/,
|
|
);
|
|
host.resolved = { harnessBaseUrl: '' };
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/start refused: The control plane reaches qwen-hosted-harness at "", which is not a URL; correct qwen\.managed-agent\.harness\.base-url\./,
|
|
);
|
|
host.resolved = { harnessBaseUrl: 'proxy.test:4170' };
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/start refused: The control plane reaches qwen-hosted-harness at "proxy\.test:4170", which is not an http or https URL; correct qwen\.managed-agent\.harness\.base-url\./,
|
|
);
|
|
// The Harness URL gets the same zone explanation --server-url gets: a
|
|
// link-local address that still looks well formed must say why it is refused.
|
|
host.resolved = { harnessBaseUrl: 'http://[fe80::1%eth0]:4170' };
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/start refused: The control plane reaches qwen-hosted-harness at "http:\/\/\[fe80::1%eth0\]:4170", which is not a URL: a URL cannot carry an IPv6 zone; correct qwen\.managed-agent\.harness\.base-url\./,
|
|
);
|
|
// The preflight takes only an http(s) origin, so whatever else it would
|
|
// fail after the Harness restarts is refused first, credentials masked:
|
|
// the resolver passes on its verdict, and the refusal words it.
|
|
for (const [harnessBaseUrl, shown, reason] of [
|
|
[
|
|
'http://127.0.0.1:4170/?tenant=acme',
|
|
'http://127.0.0.1:4170/?tenant=acme',
|
|
'carries a query or a fragment',
|
|
],
|
|
[
|
|
'http://127.0.0.1:4170/?',
|
|
'http://127.0.0.1:4170/?',
|
|
'carries a query or a fragment',
|
|
],
|
|
[
|
|
'http://127.0.0.1:4170#x',
|
|
'http://127.0.0.1:4170#x',
|
|
'carries a query or a fragment',
|
|
],
|
|
[
|
|
'http://svc:[email protected]:4170',
|
|
'http://***@127.0.0.1:4170/',
|
|
'carries a user name or password',
|
|
],
|
|
[
|
|
'http://@127.0.0.1:4170',
|
|
'http://***@127.0.0.1:4170',
|
|
'carries an @ before its host',
|
|
],
|
|
// java.net.URI reads the padding untrimmed, so it reads no authority
|
|
// there; nor in http:/@…, whose @ follows a single slash. The refusal
|
|
// then says only that the server does not take the value.
|
|
[
|
|
' http://@127.0.0.1:4170',
|
|
'***@127.0.0.1:4170',
|
|
'the server does not take as an http or https base URL, reading it untrimmed as java.net.URI does',
|
|
],
|
|
[
|
|
'http:/@127.0.0.1:4170',
|
|
'http:***@127.0.0.1:4170',
|
|
'the server does not take as an http or https base URL, reading it untrimmed as java.net.URI does',
|
|
],
|
|
[
|
|
'http://svc:2024#[email protected]:4170',
|
|
'http://***@127.0.0.1:4170',
|
|
'carries a query or a fragment',
|
|
],
|
|
[
|
|
'ftp://deploy:[email protected]:4170',
|
|
'ftp://***@harness.internal:4170/',
|
|
'is not an http or https URL',
|
|
],
|
|
]) {
|
|
host.resolved = { harnessBaseUrl, harnessOrigin: 'false' };
|
|
assert.equal(await host.upgrade('start'), 1, harnessBaseUrl);
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
`start refused: The control plane reaches qwen-hosted-harness at ${JSON.stringify(shown)}, which ${reason}; correct qwen.managed-agent.harness.base-url.`,
|
|
);
|
|
// Each run prints afresh, so each is searched for the password.
|
|
assert.ok(!host.output.join('\n').includes('s3cret'), harnessBaseUrl);
|
|
}
|
|
// Without a verdict, the plain-URL rule refuses before the Harness
|
|
// restarts what the URL parser would repair: the tabs it drops, and a
|
|
// backslash it reads as a slash, which java.net.URI refuses.
|
|
for (const harnessBaseUrl of [
|
|
'http:/\t/\[email protected]:4170',
|
|
'http://h\\@x',
|
|
'http:/@127.0.0.1:4170',
|
|
// A space java.net.URI refuses in a path, and a malformed escape.
|
|
'http://127.0.0.1:4170/a b',
|
|
'http://127.0.0.1:4170/a%zz',
|
|
// Hosts java.net.URI reads no host in: an empty label, a label that
|
|
// ends in a hyphen, and a last label that starts with a digit, of a name
|
|
// of more than one.
|
|
'http://harness..internal:4170',
|
|
'http://harness-.internal:4170',
|
|
'http://1.2.3:4170',
|
|
'http://harness.1b:4170',
|
|
// Fetch reads these as 0.0.4.210 and 8.0.0.1 respectively.
|
|
'http://1234:4170',
|
|
'http://010.0.0.1:4170',
|
|
]) {
|
|
host.resolved = { harnessBaseUrl, harnessOrigin: undefined };
|
|
assert.equal(
|
|
await host.upgrade('start'),
|
|
1,
|
|
JSON.stringify(harnessBaseUrl),
|
|
);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/, which is not a plain http or https URL, a host java.net.URI and fetch read alike with a port of up to five digits and a path of URI characters, which is all start takes while the configuration does not resolve; correct qwen\.managed-agent\.harness\.base-url\.$/,
|
|
);
|
|
assert.ok(!host.output.includes(`Starting ${HARNESS}`));
|
|
}
|
|
// Past those, the preflight's own verdict, which the resolver passes on:
|
|
// java.net.URI reads no host in a name with an underscore, and padding
|
|
// fails there too. A Harness an earlier start left running is not
|
|
// restarted for a URL the preflight would fail.
|
|
Object.assign(host.units[HARNESS], {
|
|
ActiveState: 'active',
|
|
SubState: 'running',
|
|
});
|
|
const entered = host.units[HARNESS].ActiveEnterTimestampMonotonic;
|
|
for (const harnessBaseUrl of [
|
|
'http://qwen_harness:4170',
|
|
// The resolver prints the value as the server parses it, padding and all.
|
|
' http://127.0.0.1:4170 ',
|
|
]) {
|
|
host.resolved = { harnessBaseUrl, harnessOrigin: 'false' };
|
|
assert.equal(await host.upgrade('start'), 1, harnessBaseUrl);
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
`start refused: The control plane reaches qwen-hosted-harness at ${JSON.stringify(harnessBaseUrl)}, which the server does not take as an http or https base URL, reading it untrimmed as java.net.URI does; correct qwen.managed-agent.harness.base-url.`,
|
|
);
|
|
assert.ok(!host.output.some((line) => line.startsWith('Restarting')));
|
|
}
|
|
assert.equal(host.units[HARNESS].ActiveEnterTimestampMonotonic, entered);
|
|
Object.assign(host.units[HARNESS], {
|
|
ActiveState: 'inactive',
|
|
SubState: 'dead',
|
|
});
|
|
// A link-local listener keeps its zone, which no URL can carry.
|
|
const zoned = 'http://[fe80:0:0:0:0:0:0:1%1]';
|
|
host.resolved = {
|
|
serverUrl: `${zoned}:8080`,
|
|
healthUrl: `${zoned}:8080/actuator/health`,
|
|
};
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/start refused: The server's configuration puts the health endpoint at "http:\/\/\[fe80:0:0:0:0:0:0:1%1\]:8080\/actuator\/health", which is not a URL: a URL cannot carry an IPv6 zone\. Bind it where fetch can reach it, or pass --server-url <a URL that serves the API and \/actuator\/health>\./,
|
|
);
|
|
host.resolved = { healthUrl: `${zoned}:9091/actuator/health` };
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/puts the health endpoint at "http:\/\/\[fe80:0:0:0:0:0:0:1%1\]:9091\/actuator\/health", which is not a URL: a URL/,
|
|
);
|
|
host.resolved = {
|
|
serverUrl: 'http://127.0.0.1:70000',
|
|
healthUrl: 'http://127.0.0.1:70000/actuator/health',
|
|
};
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/puts the health endpoint at "http:\/\/127\.0\.0\.1:70000\/actuator\/health", which is not a URL\. Bind it/,
|
|
);
|
|
host.resolved = {};
|
|
assert.equal(
|
|
await host.upgrade('start', '--server-url', 'http://[fe80::1%eth0]:8080'),
|
|
1,
|
|
);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/start refused: --server-url "http:\/\/\[fe80::1%eth0\]:8080" is not a URL: a URL cannot carry an IPv6 zone\./,
|
|
);
|
|
// fetch would refuse each probe: a port it blocks, a URL without a scheme
|
|
// and credentials, which the health endpoint never needs.
|
|
host.resolved = {
|
|
serverUrl: 'http://127.0.0.1:6000',
|
|
healthUrl: 'http://127.0.0.1:6000/actuator/health',
|
|
};
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/puts the health endpoint at "http:\/\/127\.0\.0\.1:6000\/actuator\/health", which is on port 6000, so fetch refuses to dial it\. Bind it where fetch can reach it, or pass --server-url/,
|
|
);
|
|
host.resolved = {};
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
host.requests = [];
|
|
for (const [serverUrl, refusal, shown = serverUrl.replace(/\/$/, '')] of [
|
|
['', 'is not a URL'],
|
|
['proxy.test:8443', 'is not an http or https URL'],
|
|
[
|
|
'http://admin:[email protected]:18080',
|
|
'carries a user name or password, so fetch refuses it',
|
|
'http://***@127.0.0.1:18080/',
|
|
],
|
|
[
|
|
'http://admin:[email protected]:70000',
|
|
'is not a URL',
|
|
'http://***@127.0.0.1:70000',
|
|
],
|
|
[
|
|
'https://svc:secret@[fe80::1%eth0]:8080',
|
|
'is not a URL: a URL cannot carry an IPv6 zone',
|
|
'https://***@[fe80::1%eth0]:8080',
|
|
],
|
|
[
|
|
'http://127.0.0.1:10080/',
|
|
'is on port 10080, so fetch refuses to dial it',
|
|
],
|
|
// The paths are appended to it, so they would land in either.
|
|
[
|
|
'http://proxy.test:8443/?tenant=acme',
|
|
'carries a query or a fragment, where the /actuator/health and API paths appended to it would land',
|
|
],
|
|
[
|
|
'http://proxy.test:8443/?',
|
|
'carries a query or a fragment, where the /actuator/health and API paths appended to it would land',
|
|
'http://proxy.test:8443/?',
|
|
],
|
|
[
|
|
'http://proxy.test:8443#ingress',
|
|
'carries a query or a fragment, where the /actuator/health and API paths appended to it would land',
|
|
],
|
|
// A password that starts with digits parses as a port, with the rest of
|
|
// it in the fragment.
|
|
[
|
|
'http://admin:2024#[email protected]:18080',
|
|
'carries a query or a fragment, where the /actuator/health and API paths appended to it would land',
|
|
'http://***@127.0.0.1:18080',
|
|
],
|
|
]) {
|
|
assert.equal(await host.upgrade('start', '--server-url', serverUrl), 1);
|
|
assert.ok(
|
|
host.output.includes(
|
|
`start refused: --server-url ${JSON.stringify(shown)} ${refusal}.`,
|
|
),
|
|
host.output.join('\n'),
|
|
);
|
|
// The password the operator typed stays out of what each run prints.
|
|
assert.ok(
|
|
!host.output.join('\n').includes('secret'),
|
|
host.output.join('\n'),
|
|
);
|
|
}
|
|
assert.deepEqual(host.requests, []);
|
|
assert.equal(host.units[HARNESS].ActiveState, 'inactive');
|
|
assert.equal(host.units[SERVER].ActiveState, 'inactive');
|
|
assert.ok(host.services.every(({ args }) => args.includes(RESOLVER)));
|
|
|
|
host.resolved = { healthUrl: '' };
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(
|
|
await host.upgrade('start', '--server-url', 'http://127.0.0.1:18080'),
|
|
0,
|
|
);
|
|
host.resolved = { serverUrl: '', healthUrl: '' };
|
|
assert.equal(await host.upgrade('verify'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/verify refused: The server's configuration gives the health endpoint no fixed port/,
|
|
);
|
|
});
|
|
|
|
test('start asks nothing of a Harness URL the server does not read', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
// Disabled, the Harness row skips before it reads the value, and the server
|
|
// never dials it; start still waits for the Harness there.
|
|
for (const [harnessBaseUrl, address] of [
|
|
[' http://127.0.0.1:4170 ', '127.0.0.1:4170'],
|
|
['http://qwen_harness:4170', 'qwen_harness:4170'],
|
|
['http://127.0.0.1:4170/?tenant=acme', '127.0.0.1:4170'],
|
|
['http://svc:[email protected]:4170', '127.0.0.1:4170'],
|
|
]) {
|
|
await host.stop();
|
|
host.connections = [];
|
|
host.resolved = { harnessBaseUrl, harnessOrigin: 'skip' };
|
|
assert.equal(await host.upgrade('start'), 0, harnessBaseUrl);
|
|
assert.ok(host.output.includes(`Starting ${HARNESS}`), harnessBaseUrl);
|
|
assert.deepEqual([...new Set(host.connections)], [address]);
|
|
assert.ok(!host.output.join('\n').includes('s3cret'), harnessBaseUrl);
|
|
}
|
|
// It has to name a host start can wait on all the same.
|
|
await host.stop();
|
|
host.resolved = { harnessBaseUrl: 'proxy.test:4170', harnessOrigin: 'skip' };
|
|
assert.equal(await host.upgrade('start'), 1);
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
'start refused: The control plane reaches qwen-hosted-harness at "proxy.test:4170", which is not an http or https URL; correct qwen.managed-agent.harness.base-url.',
|
|
);
|
|
assert.equal(host.units[HARNESS].ActiveState, 'inactive');
|
|
});
|
|
|
|
test('start dials only the health endpoint, and verify reads the API only with --tenant', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
// The API on a port fetch refuses, the health endpoint on its own
|
|
// management port: start never dials the API, so it does not refuse it.
|
|
host.resolved = {
|
|
serverUrl: 'http://127.0.0.1:6000',
|
|
healthUrl: 'http://127.0.0.1:9091/actuator/health',
|
|
};
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
host.requests = [];
|
|
assert.equal(await host.upgrade('start'), 0, host.output.join('\n'));
|
|
assert.deepEqual(
|
|
[...new Set(host.requests.map(({ url }) => url))],
|
|
['http://127.0.0.1:9091/actuator/health'],
|
|
);
|
|
// Nor does verify without --tenant, which reads no API.
|
|
assert.equal(await host.upgrade('verify'), 0, host.output.join('\n'));
|
|
assert.equal(
|
|
rowsOf(host.output).health,
|
|
'PASS http://127.0.0.1:9091/actuator/health is UP',
|
|
);
|
|
// verify --tenant reads it, so it says why it cannot, before any probe.
|
|
host.requests = [];
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
'verify refused: The server\'s configuration puts the control plane at "http://127.0.0.1:6000", which is on port 6000, so fetch refuses to dial it. Bind it where fetch can reach it, or pass --server-url <a URL that serves the API and /actuator/health>.',
|
|
);
|
|
assert.deepEqual(host.requests, []);
|
|
// --actor without a trusted actor header reads no API either: its session
|
|
// row fails, and the API URL is not checked.
|
|
host.resolved.trustedActorHeader = '';
|
|
assert.equal(
|
|
await host.upgrade('verify', '--tenant', 'acme', '--actor', 'alice'),
|
|
1,
|
|
);
|
|
assert.match(
|
|
rowsOf(host.output).session,
|
|
/^FAIL --actor needs a trusted actor header/,
|
|
);
|
|
assert.ok(!host.output.some((line) => line.startsWith('verify refused')));
|
|
delete host.resolved.trustedActorHeader;
|
|
// An API port picked at start, the same way.
|
|
host.resolved = {
|
|
serverUrl: '',
|
|
healthUrl: 'http://127.0.0.1:9091/actuator/health',
|
|
};
|
|
assert.equal(await host.upgrade('verify'), 0, host.output.join('\n'));
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/^verify refused: The server's configuration gives the control plane no fixed port/,
|
|
);
|
|
assert.equal(await host.upgrade('stop'), 0);
|
|
assert.equal(await host.upgrade('start'), 0, host.output.join('\n'));
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
'qwen-managed-agent-server is UP at http://127.0.0.1:9091/actuator/health. Run verify before reopening ingress.',
|
|
);
|
|
});
|
|
|
|
test('health reports neutralize terminal control bytes from the endpoint', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
// A queryable endpoint must not drive the terminal that reports it: cursor
|
|
// moves that repaint a FAIL verdict as PASS, a newline that splits the row,
|
|
// and an OSC-52 clipboard write all ride in the status field as control bytes.
|
|
const malicious =
|
|
'DOWN\u001b[1A\u001b[2K\u001b[32mPASS\u001b[0m\nsplit\u001b]52;c;SGVsbG8=\u0007';
|
|
host.responses['GET /actuator/health'] = { status: malicious };
|
|
assert.equal(await host.upgrade('verify'), 1);
|
|
const row = rowsOf(host.output).health;
|
|
assert.ok(
|
|
row.startsWith(
|
|
'FAIL http://127.0.0.1:18080/actuator/health is not UP: HTTP 200, status DOWN',
|
|
),
|
|
row,
|
|
);
|
|
assert.ok(
|
|
// eslint-disable-next-line no-control-regex -- detecting surviving control bytes is the assertion
|
|
!/[\u0000-\u001F\u007F-\u009F]/.test(row),
|
|
`a control byte survived: ${JSON.stringify(row)}`,
|
|
);
|
|
assert.ok(row.includes('\uFFFD'), row);
|
|
assert.ok(
|
|
row.includes('PASS') && row.includes('split'),
|
|
`readable text stays: ${row}`,
|
|
);
|
|
|
|
// A status that is valid JSON and no string, one String() throws on, is
|
|
// shown as its JSON: verify still prints its row, and start keeps probing.
|
|
host.responses['GET /actuator/health'] = { status: { toString: 1 } };
|
|
assert.equal(await host.upgrade('verify'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).health,
|
|
'FAIL http://127.0.0.1:18080/actuator/health is not UP: HTTP 200, status {"toString":1}',
|
|
);
|
|
assert.equal(await host.upgrade('stop'), 0);
|
|
assert.equal(await host.upgrade('start', '--timeout', '3'), 1);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/within 3s \(the last answer: HTTP 200, status \{"toString":1\}\) and keeps running/,
|
|
);
|
|
|
|
// One nested deeper than JSON.stringify() recurses parses all the same,
|
|
// and is shown as unprintable rather than ending the report.
|
|
host.responses['GET /actuator/health'] = {
|
|
status: JSON.parse('['.repeat(20000) + ']'.repeat(20000)),
|
|
};
|
|
assert.equal(await host.upgrade('stop'), 0);
|
|
assert.equal(await host.upgrade('start', '--timeout', '3'), 1);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/within 3s \(the last answer: HTTP 200, status \[unprintable\]\) and keeps running/,
|
|
);
|
|
assert.equal(await host.upgrade('verify'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).health,
|
|
'FAIL http://127.0.0.1:18080/actuator/health is not UP: HTTP 200, status [unprintable]',
|
|
);
|
|
|
|
// Nor can the endpoint scroll the report away with a long one: the text
|
|
// stops at 200 code points, never inside a surrogate pair, and says how
|
|
// long it was.
|
|
host.responses['GET /actuator/health'] = {
|
|
status: `${'😀'.repeat(150)}${'A'.repeat(99850)}`,
|
|
};
|
|
assert.equal(await host.upgrade('verify'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).health,
|
|
`FAIL http://127.0.0.1:18080/actuator/health is not UP: HTTP 200, status ${'😀'.repeat(150)}${'A'.repeat(50)}… (100000 characters)`,
|
|
);
|
|
assert.ok(host.output.join('\n').length < 2000);
|
|
// Text of exactly the bound shows whole, a control byte counted as one.
|
|
host.responses['GET /actuator/health'] = {
|
|
status: `${'😀'.repeat(150)}\u001b${'A'.repeat(49)}`,
|
|
};
|
|
assert.equal(await host.upgrade('verify'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).health,
|
|
`FAIL http://127.0.0.1:18080/actuator/health is not UP: HTTP 200, status ${'😀'.repeat(150)}\uFFFD${'A'.repeat(49)}`,
|
|
);
|
|
host.responses['GET /actuator/health'] = {
|
|
status: `${'😀'.repeat(150)}${'A'.repeat(99850)}`,
|
|
};
|
|
assert.equal(await host.upgrade('stop'), 0);
|
|
assert.equal(await host.upgrade('start', '--timeout', '2'), 1);
|
|
assert.ok([...host.output.at(-1)].length < 600, host.output.at(-1));
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/… \(100000 characters\)\) and keeps running/,
|
|
);
|
|
|
|
// A status of 0, false or an empty string is a status all the same, which
|
|
// a test of its truth would drop; a null one, like none, names nothing.
|
|
for (const [status, shown] of [
|
|
[0, ', status 0'],
|
|
[false, ', status false'],
|
|
['', ', status ""'],
|
|
[null, ''],
|
|
]) {
|
|
host.responses['GET /actuator/health'] = { status };
|
|
assert.equal(await host.upgrade('verify'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).health,
|
|
`FAIL http://127.0.0.1:18080/actuator/health is not UP: HTTP 200${shown}`,
|
|
JSON.stringify(status),
|
|
);
|
|
}
|
|
|
|
// The health URL comes from the configuration or the command line, not
|
|
// the endpoint, but a control byte in it would split the row all the same.
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'verify',
|
|
'--server-url',
|
|
'http://127.0.0.1:18080/x\u001b[2K\u0085y',
|
|
),
|
|
1,
|
|
);
|
|
const shown = rowsOf(host.output).health;
|
|
assert.ok(
|
|
shown.startsWith(
|
|
'FAIL http://127.0.0.1:18080/x\uFFFD[2K\uFFFDy/actuator/health is not UP: ',
|
|
),
|
|
shown,
|
|
);
|
|
// eslint-disable-next-line no-control-regex -- detecting surviving control bytes is the assertion
|
|
const control = /[\u0000-\u001F\u007F-\u009F]/;
|
|
assert.ok(
|
|
!host.output.some((line) => control.test(line)),
|
|
JSON.stringify(host.output),
|
|
);
|
|
// start prints it in each of its outcomes.
|
|
const steered = 'http://127.0.0.1:18080/x\u001b[2K\u0085y';
|
|
const flat = 'http:\\/\\/127\\.0\\.0\\.1:18080\\/x\uFFFD\\[2K\uFFFDy';
|
|
for (const [answer, outcome] of [
|
|
[
|
|
{ status: 'UP' },
|
|
`is UP at ${flat}\\/actuator\\/health; its API is at ${flat}\\. Run verify`,
|
|
],
|
|
[
|
|
{ status: 'DOWN' },
|
|
`did not report UP at ${flat}\\/actuator\\/health within 2s`,
|
|
],
|
|
[
|
|
() => {
|
|
host.units[SERVER].ActiveState = 'failed';
|
|
return { status: 'DOWN' };
|
|
},
|
|
`exited before it reported UP at ${flat}\\/actuator\\/health, and is stopped now`,
|
|
],
|
|
]) {
|
|
assert.equal(await host.upgrade('stop'), 0);
|
|
host.responses[`GET ${new URL(`${steered}/actuator/health`).pathname}`] =
|
|
answer;
|
|
await host.upgrade('start', '--server-url', steered, '--timeout', '2');
|
|
assert.match(host.output.at(-1), new RegExp(outcome));
|
|
assert.ok(
|
|
!host.output.some((line) => control.test(line)),
|
|
JSON.stringify(host.output),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('no report prints a password a URL carries', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
// An @ the configuration puts in the path: every outcome masks up to it.
|
|
host.resolved = {
|
|
serverUrl: 'http://127.0.0.1:18080/s3cret@b',
|
|
healthUrl: 'http://127.0.0.1:18080/s3cret@b/actuator/health',
|
|
};
|
|
for (const [answer, outcome] of [
|
|
[
|
|
{ status: 'UP' },
|
|
'is UP at http://***@b/actuator/health; its API is at http://***@b. Run verify',
|
|
],
|
|
[
|
|
{ status: 'DOWN' },
|
|
'did not report UP at http://***@b/actuator/health within 2s',
|
|
],
|
|
[
|
|
() => {
|
|
host.units[SERVER].ActiveState = 'failed';
|
|
return { status: 'DOWN' };
|
|
},
|
|
'exited before it reported UP at http://***@b/actuator/health, and is stopped now',
|
|
],
|
|
]) {
|
|
await host.upgrade('stop');
|
|
host.responses['GET /s3cret@b/actuator/health'] = answer;
|
|
await host.upgrade('start', '--timeout', '2');
|
|
assert.ok(host.output.at(-1).includes(outcome), host.output.at(-1));
|
|
assert.ok(
|
|
!host.output.join('\n').includes('s3cret'),
|
|
JSON.stringify(host.output),
|
|
);
|
|
}
|
|
await host.upgrade('stop');
|
|
host.responses['GET /s3cret@b/actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start', '--timeout', '2'), 0);
|
|
assert.equal(await host.upgrade('verify'), 0);
|
|
assert.equal(
|
|
rowsOf(host.output).health,
|
|
'PASS http://***@b/actuator/health is UP',
|
|
);
|
|
host.responses['GET /s3cret@b/actuator/health'] = { status: 'DOWN' };
|
|
assert.equal(await host.upgrade('verify'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).health,
|
|
'FAIL http://***@b/actuator/health is not UP: HTTP 200, status DOWN',
|
|
);
|
|
// A --server-url with an @ left after the URL parser has read no user
|
|
// information is the rest of a password that starts with digits: the
|
|
// parser reads host svc, port 12, so the phase would dial the wrong host
|
|
// and print the password. Each phase refuses it before it dials.
|
|
host.resolved = {};
|
|
for (const phase of ['verify', 'start']) {
|
|
if (phase === 'start') {
|
|
await host.upgrade('stop');
|
|
}
|
|
assert.equal(
|
|
await host.upgrade(
|
|
phase,
|
|
'--server-url',
|
|
'https://svc:12/[email protected]:8443',
|
|
),
|
|
1,
|
|
);
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
`${phase} refused: --server-url "https://***@cp.example.com:8443" carries an @, as a user name or password would before its host; write an @ in a path as %40.`,
|
|
);
|
|
assert.ok(!host.output.join('\n').includes('s3cret'), phase);
|
|
assert.equal(
|
|
host.requests.filter((request) => request.url.includes('svc')).length,
|
|
0,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('check judges the --server-url its plan repeats', async (t) => {
|
|
const host = await rig(t);
|
|
assert.equal(await host.upgrade('--help'), 0);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/--server-url <url>\s+start, verify: control-plane URL \(default: the listener\s+the server's configuration resolves to\); check refuses\s+a URL they would refuse, as its plan repeats it/,
|
|
);
|
|
for (const [serverUrl, shown, reason] of [
|
|
[
|
|
'https://ops:[email protected]',
|
|
'https://***@cp.example.com/',
|
|
'carries a user name or password, so fetch refuses it',
|
|
],
|
|
[
|
|
'https://svc:12/[email protected]',
|
|
'https://***@cp.example.com',
|
|
'carries an @, as a user name or password would before its host; write an @ in a path as %40',
|
|
],
|
|
// What --server-url "https://$HOST" leaves with HOST unset, which the
|
|
// option parser strips to https:, though https:/actuator/health parses.
|
|
['https://', 'https:', 'is not a URL'],
|
|
// And --server-url "$CONTROL_PLANE", which start and verify would
|
|
// otherwise drop for the configured URL.
|
|
['', '', 'is not a URL'],
|
|
]) {
|
|
assert.equal(await host.check('--server-url', serverUrl), 1);
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
`check refused: --server-url "${shown}" ${reason}.`,
|
|
);
|
|
assert.ok(!host.output.join('\n').includes('s3cret'), serverUrl);
|
|
}
|
|
assert.equal(
|
|
await host.check('--server-url', 'https://cp.example.com:8443'),
|
|
0,
|
|
);
|
|
const plan = host.output.slice(host.output.indexOf('Plan:')).join('\n');
|
|
assert.ok(
|
|
plan.includes(' start --server-url https://cp.example.com:8443\n'),
|
|
plan,
|
|
);
|
|
// A control character in a value the plan repeats reaches the terminal as
|
|
// an escape, which the shell reads back as the value, a digit after it
|
|
// included, which ksh would read on with a \x escape.
|
|
const releases = `${host.root}/rel\u001b[2K'\u009b\\x\t9b\u001b1`;
|
|
assert.equal(await host.check('--releases', releases), 0);
|
|
const printed = host.output.slice(host.output.indexOf('Plan:')).join('\n');
|
|
assert.ok(
|
|
// eslint-disable-next-line no-control-regex -- matching control bytes is the point
|
|
!/[\u0000-\u0009\u000B-\u001F\u007F-\u009F]/.test(printed),
|
|
printed,
|
|
);
|
|
const word = printed.match(/ stop --releases (\S+)/)[1];
|
|
assert.equal(
|
|
word,
|
|
`$'${host.root}/rel\\033[2K\\'\\302\\233\\\\x\\0119b\\0331'`,
|
|
);
|
|
// As each of the shells that read $'...' and are here reads it back.
|
|
for (const shell of ['bash', 'zsh', 'ksh']) {
|
|
const echoed = spawnSync(shell, ['-c', `printf %s ${word}`], {
|
|
encoding: 'utf8',
|
|
});
|
|
if (echoed.error?.code === 'ENOENT' && shell !== 'bash') {
|
|
continue;
|
|
}
|
|
assert.equal(echoed.stdout, releases, shell);
|
|
}
|
|
});
|
|
|
|
test('check layout rows neutralize control bytes without shortening paths', async (t) => {
|
|
const host = await rig(t);
|
|
// eslint-disable-next-line no-control-regex -- detecting surviving control bytes is the assertion
|
|
const control = /[\u0000-\u001F\u007F-\u009F]/;
|
|
const releases = `${host.root}/${'release-'.repeat(30)}\u001b[2K\u009b`;
|
|
assert.ok(releases.length > 200);
|
|
assert.equal(await host.check('--releases', releases), 0);
|
|
assert.ok(
|
|
!host.output.some((line) => control.test(line)),
|
|
JSON.stringify(host.output),
|
|
);
|
|
const shownReleases = releases.split(control).join('\uFFFD');
|
|
assert.equal(
|
|
rowsOf(host.output).layout,
|
|
`PASS install moves ${host.current} to ${path.join(shownReleases, '0.1.0')} and links it to ${path.join(shownReleases, '0.2.0')}`,
|
|
);
|
|
});
|
|
|
|
test('check runtime-state rows neutralize control bytes', async (t) => {
|
|
const host = await rig(t);
|
|
// eslint-disable-next-line no-control-regex -- detecting surviving control bytes is the assertion
|
|
const control = /[\u0000-\u001F\u007F-\u009F]/;
|
|
const runtimeState = `${host.root}/${'state-'.repeat(40)}\u001b[2K\u009b`;
|
|
assert.equal(await host.check('--runtime-state', runtimeState), 1);
|
|
assert.ok(
|
|
!host.output.some((line) => control.test(line)),
|
|
JSON.stringify(host.output),
|
|
);
|
|
assert.equal(
|
|
rowsOf(host.output)['runtime-state'],
|
|
`FAIL ${runtimeState.split(control).join('\uFFFD')} is not a directory`,
|
|
);
|
|
});
|
|
|
|
test('verify sanitizes a malicious health body over a real socket', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
// The same attack over a real socket and the runtime's own fetch, through the
|
|
// real health() -> printable() -> row() -> out() path, not the rig's fake.
|
|
const malicious =
|
|
'DOWN\u001b[1A\u001b[2K\u001b[32mPASS\u001b[0m\nsplit\u001b]52;c;SGVsbG8=\u0007';
|
|
const server = http.createServer((request, response) => {
|
|
if (request.url === '/actuator/health') {
|
|
response.writeHead(200, { 'content-type': 'application/json' });
|
|
response.end(JSON.stringify({ status: malicious }));
|
|
} else {
|
|
response.writeHead(404);
|
|
response.end();
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
t.after(() => {
|
|
server.closeAllConnections();
|
|
server.close();
|
|
});
|
|
host.system.fetch = fetch;
|
|
const url = `http://127.0.0.1:${server.address().port}`;
|
|
assert.equal(await host.upgrade('verify', '--server-url', url), 1);
|
|
const row = rowsOf(host.output).health;
|
|
assert.ok(
|
|
row.startsWith(
|
|
`FAIL ${url}/actuator/health is not UP: HTTP 200, status DOWN`,
|
|
),
|
|
row,
|
|
);
|
|
assert.ok(
|
|
// eslint-disable-next-line no-control-regex -- detecting surviving control bytes is the assertion
|
|
!/[\u0000-\u001F\u007F-\u009F]/.test(row),
|
|
`a control byte survived: ${JSON.stringify(row)}`,
|
|
);
|
|
assert.ok(
|
|
row.includes('\uFFFD') && row.includes('PASS') && row.includes('split'),
|
|
row,
|
|
);
|
|
});
|
|
|
|
test('verify says why an API read failed, in text the endpoint cannot steer', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
// fetch words every failure as "fetch failed"; the session row says why,
|
|
// as the health row above it does.
|
|
host.responses['GET /v1/agents/sessions'] = () => {
|
|
throw new TypeError('fetch failed', {
|
|
cause: Object.assign(
|
|
new Error('self-signed certificate in certificate chain'),
|
|
{ code: 'SELF_SIGNED_CERT_IN_CHAIN' },
|
|
),
|
|
});
|
|
};
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).session,
|
|
'FAIL GET /v1/agents/sessions?limit=1: self-signed certificate in certificate chain',
|
|
);
|
|
|
|
// Text the API hands back cannot drive the terminal either: an error
|
|
// reason, a Session ID and an event type.
|
|
const steer = 'x\u001b[2K\u001b[32mPASS\u001b[0m\nsplit\u009b1A';
|
|
// eslint-disable-next-line no-control-regex -- detecting surviving control bytes is the assertion
|
|
const control = /[\u0000-\u001F\u007F-\u009F]/;
|
|
const fake = host.system.fetch;
|
|
host.system.fetch = async (url, init) =>
|
|
url.includes('/v1/agents/sessions?limit=1')
|
|
? {
|
|
ok: false,
|
|
status: 500,
|
|
statusText: 'Internal Server Error',
|
|
json: async () => ({ error: { message: steer } }),
|
|
}
|
|
: fake(url, init);
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
const failed = rowsOf(host.output).session;
|
|
assert.ok(failed.startsWith('FAIL GET /v1/agents/sessions?limit=1: 500 x'));
|
|
assert.ok(!control.test(failed), JSON.stringify(failed));
|
|
// An error reason that is no string is shown as its JSON.
|
|
host.system.fetch = async (url, init) =>
|
|
url.includes('/v1/agents/sessions?limit=1')
|
|
? {
|
|
ok: false,
|
|
status: 500,
|
|
json: async () => ({ error: { message: { toString: 1 } } }),
|
|
}
|
|
: fake(url, init);
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).session,
|
|
'FAIL GET /v1/agents/sessions?limit=1: 500 {"toString":1}',
|
|
);
|
|
// A cause can quote what the peer sent, as a TLS alert or a parser does.
|
|
host.system.fetch = async () => {
|
|
throw new TypeError('fetch failed', { cause: new Error(steer) });
|
|
};
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
const caused = rowsOf(host.output);
|
|
for (const name of ['health', 'session']) {
|
|
assert.ok(caused[name].startsWith('FAIL '), caused[name]);
|
|
assert.ok(caused[name].includes('x\uFFFD[2K'), caused[name]);
|
|
assert.ok(!control.test(caused[name]), JSON.stringify(caused[name]));
|
|
}
|
|
// A success whose body is no JSON: the parse error quotes the body.
|
|
host.system.fetch = async (url, init) =>
|
|
url.includes('/v1/agents/sessions?limit=1')
|
|
? { ok: true, status: 200, json: async () => JSON.parse(steer) }
|
|
: fake(url, init);
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
const unreadable = rowsOf(host.output).session;
|
|
assert.match(
|
|
unreadable,
|
|
/^FAIL .*: 200 with an unreadable body \(.*x\uFFFD\[2K/,
|
|
);
|
|
assert.ok(!control.test(unreadable), JSON.stringify(unreadable));
|
|
host.system.fetch = fake;
|
|
|
|
const id = encodeURIComponent(steer);
|
|
host.responses['GET /v1/agents/sessions'] = { data: [{ id: steer }] };
|
|
host.responses[`GET /v1/agents/sessions/${id}`] = { id: steer };
|
|
host.responses[`GET /v1/agents/sessions/${id}/items`] = { data: [{}, {}] };
|
|
host.responses['POST /v1/agents/sessions'] = { id: steer };
|
|
host.responses[`GET /v1/agents/sessions/${id}/events`] = {
|
|
data: [{ sequence: 1, type: `turn.completed${steer}`, terminal: true }],
|
|
};
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme', '--turn'), 0);
|
|
const rows = rowsOf(host.output);
|
|
for (const name of ['session', 'turn']) {
|
|
assert.ok(rows[name].startsWith('PASS '), rows[name]);
|
|
assert.ok(!control.test(rows[name]), JSON.stringify(rows[name]));
|
|
assert.ok(rows[name].includes('\uFFFD') && rows[name].includes('split'));
|
|
}
|
|
host.responses[`GET /v1/agents/sessions/${id}/events`] = {
|
|
data: [{ sequence: 2, type: `turn.failed${steer}`, terminal: true }],
|
|
};
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme', '--turn'), 1);
|
|
const turn = rowsOf(host.output).turn;
|
|
assert.ok(turn.startsWith('FAIL Session x'), turn);
|
|
assert.ok(!control.test(turn), JSON.stringify(turn));
|
|
// An id the API lists and then fails to serve: the request the row names
|
|
// stops at the bound, as the id itself does.
|
|
host.responses['GET /v1/agents/sessions'] = {
|
|
data: [{ id: 'S'.repeat(100000) }],
|
|
};
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
const unserved = rowsOf(host.output).session;
|
|
assert.match(
|
|
unserved,
|
|
/^FAIL GET \/v1\/agents\/sessions\/S{180}… \(100020 characters\): 404/,
|
|
);
|
|
assert.ok(unserved.length < 300, unserved.length);
|
|
|
|
// A value the API chose is typed before a route or a verdict uses it:
|
|
// coercing an object calls its toString, and an array nested deeper than
|
|
// the stack cannot be joined, so either would throw before the row could
|
|
// say what came back.
|
|
const deep = JSON.parse('['.repeat(20000) + ']'.repeat(20000));
|
|
host.responses['GET /v1/agents/sessions/s1'] = { id: 's1' };
|
|
host.responses['GET /v1/agents/sessions/s1/items'] = { data: [] };
|
|
for (const [value, shown] of [
|
|
[{ toString: 1 }, '{"toString":1}'],
|
|
[deep, '[unprintable]'],
|
|
[7, '7'],
|
|
]) {
|
|
host.responses['GET /v1/agents/sessions'] = { data: [{ id: value }] };
|
|
host.responses['POST /v1/agents/sessions'] = { id: value };
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme', '--turn'), 1);
|
|
const typed = rowsOf(host.output);
|
|
for (const name of ['session', 'turn']) {
|
|
assert.equal(
|
|
typed[name],
|
|
`FAIL the API answered a Session without a string id (${shown})`,
|
|
);
|
|
}
|
|
// An event's type names no outcome unless it is a string, and a null
|
|
// event is passed over.
|
|
host.responses['GET /v1/agents/sessions'] = { data: [{ id: 's1' }] };
|
|
host.responses['POST /v1/agents/sessions'] = { id: 's1' };
|
|
host.responses['GET /v1/agents/sessions/s1/events'] = {
|
|
data: [null, { sequence: value, type: value, terminal: true }],
|
|
};
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme', '--turn'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).turn,
|
|
`FAIL Session s1 ended with ${shown}`,
|
|
);
|
|
}
|
|
// A listed entry that is no object is no Session either.
|
|
for (const [entry, shown] of [
|
|
[0, '0'],
|
|
['', ''],
|
|
[false, 'false'],
|
|
[null, 'null'],
|
|
[{ name: 's1' }, '{"name":"s1"}'],
|
|
]) {
|
|
host.responses['GET /v1/agents/sessions'] = { data: [entry] };
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).session,
|
|
`FAIL the API answered a Session without a string id (${shown})`,
|
|
);
|
|
}
|
|
// Data that is no list is not read as one: iterating it would throw, or
|
|
// walk a string, and its length would be whatever it holds.
|
|
host.responses['GET /v1/agents/sessions'] = { data: [{ id: 's1' }] };
|
|
for (const [data, shown] of [
|
|
[{ length: 2 }, '{"length":2}'],
|
|
[1, '1'],
|
|
['abc', 'abc'],
|
|
]) {
|
|
host.responses['GET /v1/agents/sessions/s1/items'] = { data };
|
|
host.responses['GET /v1/agents/sessions/s1/events'] = { data };
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme', '--turn'), 1);
|
|
const listless = rowsOf(host.output);
|
|
assert.equal(
|
|
listless.session,
|
|
`FAIL GET /v1/agents/sessions/s1/items?limit=20 answered no data list (${shown})`,
|
|
);
|
|
assert.equal(
|
|
listless.turn,
|
|
`FAIL Session s1: GET /v1/agents/sessions/s1/events answered no data list (${shown})`,
|
|
);
|
|
assert.ok(!control.test(listless.session), JSON.stringify(listless));
|
|
}
|
|
// A page without a data list is not an empty one.
|
|
host.responses['GET /v1/agents/sessions/s1/items'] = { data: [] };
|
|
for (const [page, shown] of [
|
|
[[{ id: 's1' }], '[{"id":"s1"}]'],
|
|
[{ data: null }, 'null'],
|
|
['oops', 'oops'],
|
|
[{}, '{}'],
|
|
]) {
|
|
host.responses['GET /v1/agents/sessions'] = page;
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).session,
|
|
`FAIL GET /v1/agents/sessions?limit=1 answered no data list (${shown})`,
|
|
);
|
|
}
|
|
// An id a route cannot carry as one segment: fetch resolves the dots, an
|
|
// empty one would read the list, and a lone surrogate has no UTF-8 to
|
|
// percent-encode.
|
|
for (const id of ['', '.', '..', '\uD800', 's\uDFFF1']) {
|
|
host.responses['GET /v1/agents/sessions'] = { data: [{ id }] };
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).session,
|
|
`FAIL the API answered a Session whose id ${JSON.stringify(id)} names no route`,
|
|
);
|
|
}
|
|
// The id is the endpoint's, so its echo is neutralized and bounded as
|
|
// other endpoint text is: JSON escapes the lone surrogate but leaves the
|
|
// CSI introducer U+009B.
|
|
host.responses['GET /v1/agents/sessions'] = {
|
|
data: [{ id: `\u009b2K${'a'.repeat(300)}\uD800` }],
|
|
};
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).session,
|
|
`FAIL the API answered a Session whose id "\uFFFD2K${'a'.repeat(196)}… (311 characters) names no route`,
|
|
);
|
|
// An event ends the turn only when the API marks it terminal.
|
|
host.responses['GET /v1/agents/sessions'] = { data: [{ id: 's1' }] };
|
|
host.responses['POST /v1/agents/sessions'] = { id: 's1' };
|
|
host.responses['GET /v1/agents/sessions/s1/events'] = {
|
|
data: [{ sequence: 1, type: 'turn.completed', terminal: 'false' }],
|
|
};
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'verify',
|
|
'--tenant',
|
|
'acme',
|
|
'--turn',
|
|
'--timeout',
|
|
'3',
|
|
),
|
|
1,
|
|
);
|
|
assert.equal(
|
|
rowsOf(host.output).turn,
|
|
'FAIL Session s1: no terminal event within 3s',
|
|
);
|
|
});
|
|
|
|
test('verify refuses a URL fetch refuses before it probes', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
// verify reaches listeners() with reachesHarness:false; the URL refusal must
|
|
// still run before any probe. Gating that loop on reachesHarness would let
|
|
// verify wait out --timeout and report a health row instead of refusing.
|
|
const probes = host.requests.length;
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'verify',
|
|
'--server-url',
|
|
'http://127.0.0.1:6000',
|
|
'--timeout',
|
|
'9',
|
|
),
|
|
1,
|
|
);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/verify refused: --server-url "http:\/\/127\.0\.0\.1:6000" is on port 6000, so fetch refuses to dial it\./,
|
|
);
|
|
assert.equal(host.requests.length, probes, 'verify refused before probing');
|
|
});
|
|
|
|
test('verify leaves signed APIs to the ingress', async (t) => {
|
|
const { host, target } = await backedUp(t, {
|
|
serverEnv: ['QWEN_MANAGED_AGENT_AUTH_MODE=signed'],
|
|
});
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 0);
|
|
assert.match(
|
|
rowsOf(host.output).session,
|
|
/^SKIP the API takes signed requests only/,
|
|
);
|
|
assert.ok(!host.requests.some(({ url }) => url.includes('/v1/')));
|
|
// --turn says it ran no Turn, rather than leaving the row out.
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme', '--turn'), 0);
|
|
assert.equal(
|
|
rowsOf(host.output).turn,
|
|
'SKIP no Turn without the Session read above',
|
|
);
|
|
// verify reads no API under signed auth, so an API port fetch would refuse
|
|
// is no reason to refuse it; the health endpoint is checked all the same.
|
|
host.resolved = {
|
|
serverUrl: 'http://127.0.0.1:6000',
|
|
healthUrl: 'http://127.0.0.1:9091/actuator/health',
|
|
};
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 0);
|
|
assert.equal(
|
|
rowsOf(host.output).health,
|
|
'PASS http://127.0.0.1:9091/actuator/health is UP',
|
|
);
|
|
assert.match(
|
|
rowsOf(host.output).session,
|
|
/^SKIP the API takes signed requests only/,
|
|
);
|
|
assert.ok(!host.requests.some(({ url }) => url.includes('/v1/')));
|
|
host.resolved.healthUrl = 'http://127.0.0.1:6000/actuator/health';
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/^verify refused: The server's configuration puts the health endpoint at "http:\/\/127\.0\.0\.1:6000\/actuator\/health", which is on port 6000/,
|
|
);
|
|
});
|
|
|
|
test('check reads the options the later phases are given', async (t) => {
|
|
const host = await rig(t);
|
|
const releases = path.join(host.root, 'qwen releases');
|
|
assert.equal(await host.check('--releases', releases), 0);
|
|
const plan = host.output.slice(host.output.indexOf('Plan:')).join('\n');
|
|
const mysqldump = path.join(host.tools, 'mysqldump');
|
|
assert.ok(plan.includes(` stop --releases '${releases}'\n`), plan);
|
|
assert.ok(
|
|
plan.includes(
|
|
` backup --to <new empty directory> --releases '${releases}' --mysqldump ${mysqldump}\n`,
|
|
),
|
|
plan,
|
|
);
|
|
assert.ok(
|
|
plan.includes(
|
|
` install --backup <that directory> --releases '${releases}'\n`,
|
|
),
|
|
plan,
|
|
);
|
|
// The client the operator named, though it is not on PATH.
|
|
assert.equal(
|
|
rowsOf(host.output)['backup-tools'],
|
|
`PASS backup dumps the database with ${mysqldump}`,
|
|
);
|
|
|
|
assert.equal(
|
|
await host.upgrade('check', '--mysqldump', path.join(host.root, 'none')),
|
|
1,
|
|
);
|
|
assert.match(
|
|
rowsOf(host.output)['backup-tools'],
|
|
/^FAIL .*none is not installed/,
|
|
);
|
|
assert.ok(
|
|
host.output.includes(
|
|
'backup refuses until the FAIL rows pass; pass them before stop. Then the plan is:',
|
|
),
|
|
);
|
|
assert.equal(await host.upgrade('check', '--skip-database'), 0);
|
|
assert.equal(
|
|
rowsOf(host.output)['backup-tools'],
|
|
'SKIP backup dumps no database, as --skip-database asks',
|
|
);
|
|
assert.ok(
|
|
host.output.some((line) =>
|
|
line.endsWith(' backup --to <new empty directory> --skip-database'),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('backup archives the Runtime state directory the server uses', async (t) => {
|
|
// The server's configuration names another directory than the variable.
|
|
const host = await rig(t);
|
|
const elsewhere = path.join(host.root, 'state-from-yaml');
|
|
await mkdir(path.join(elsewhere, 'workers'), { recursive: true });
|
|
await writeFile(path.join(elsewhere, 'workers/w2.json'), '{}\n');
|
|
host.resolved = {
|
|
runtimeStateDirectory: elsewhere,
|
|
runtimeStateOrigin: 'URL [file:/etc/qwen/runtime.yml] - 4:24',
|
|
};
|
|
assert.equal(await host.check(), 0);
|
|
assert.equal(
|
|
rowsOf(host.output)['runtime-state'],
|
|
`PASS backup archives ${elsewhere} (URL [file:/etc/qwen/runtime.yml] - 4:24)`,
|
|
);
|
|
await host.stop();
|
|
const target = path.join(host.root, 'backup');
|
|
const mysqldump = path.join(host.tools, 'mysqldump');
|
|
assert.equal(
|
|
await host.upgrade('backup', '--to', target, '--mysqldump', mysqldump),
|
|
0,
|
|
);
|
|
const members = spawnSync(
|
|
'tar',
|
|
['-tf', path.join(target, 'runtime-state.tar')],
|
|
{ encoding: 'utf8' },
|
|
).stdout;
|
|
assert.ok(members.includes('./workers/w2.json'), members);
|
|
assert.ok(!members.includes('./workers/w1.json'), members);
|
|
const record = JSON.parse(
|
|
await readFile(path.join(target, 'backup.json'), 'utf8'),
|
|
);
|
|
assert.equal(record.runtimeState, elsewhere);
|
|
|
|
// A configuration the jar cannot resolve is not one with no state.
|
|
host.resolveFailure =
|
|
"WARNING: a JVM line\ncannot resolve the server configuration: Config data resource 'file [/etc/qwen/runtime.yml]' does not exist";
|
|
const again = path.join(host.root, 'backup-2');
|
|
assert.equal(
|
|
await host.upgrade('backup', '--to', again, '--mysqldump', mysqldump),
|
|
1,
|
|
);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/backup refused: cannot resolve the Runtime state directory: cannot resolve the server configuration: Config data resource .*; pass --runtime-state <the directory the server uses>, or none when it keeps no Runtime state, to check and backup\./,
|
|
);
|
|
await assert.rejects(stat(again), { code: 'ENOENT' });
|
|
// --runtime-state stands in for the jar, which then does not run.
|
|
const runs = host.services.length;
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
again,
|
|
'--mysqldump',
|
|
mysqldump,
|
|
'--runtime-state',
|
|
elsewhere,
|
|
),
|
|
0,
|
|
);
|
|
assert.equal(host.services.length, runs);
|
|
assert.equal(
|
|
JSON.parse(await readFile(path.join(again, 'backup.json'), 'utf8'))
|
|
.runtimeState,
|
|
elsewhere,
|
|
);
|
|
});
|
|
|
|
test('check runs the server jar as the unit runs the server', async (t) => {
|
|
const host = await rig(t);
|
|
assert.equal(await host.check(), 0);
|
|
assert.equal(
|
|
rowsOf(host.output)['runtime-state'],
|
|
`PASS backup archives ${host.state} (QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY)`,
|
|
);
|
|
const [service] = host.services;
|
|
assert.ok(service.args.includes('--uid=svc'));
|
|
assert.ok(service.args.includes(`--working-directory=${host.current}`));
|
|
assert.ok(!service.args.some((arg) => arg.startsWith('--setenv')));
|
|
// The launcher's default follows the unit's own files.
|
|
const config = path.join(host.current, 'config/application.yml');
|
|
assert.deepEqual(
|
|
service.files.map(({ file }) => path.basename(file)),
|
|
['unit.env', 'server.env', 'launcher.env'],
|
|
);
|
|
assert.equal(
|
|
service.environment.get('SPRING_CONFIG_ADDITIONAL_LOCATION'),
|
|
`file:${config}`,
|
|
);
|
|
// This release's jar, copied where the service account can read it, with
|
|
// the unit's java and no further argument.
|
|
const java = service.args.indexOf(path.join(host.tools, 'java'));
|
|
assert.deepEqual(service.args.slice(java + 1), [
|
|
RESOLVER,
|
|
'-cp',
|
|
service.jar.path,
|
|
'org.springframework.boot.loader.launch.PropertiesLauncher',
|
|
]);
|
|
assert.ok(!service.jar.path.startsWith(host.distribution));
|
|
assert.equal(service.jar.mode, 0o644);
|
|
assert.equal(service.jar.directoryMode, 0o711);
|
|
await assert.rejects(stat(service.jar.path), { code: 'ENOENT' });
|
|
|
|
// An empty variable is the launcher's default too; a set one is not.
|
|
for (const [serverEnv, expected] of [
|
|
[['SPRING_CONFIG_ADDITIONAL_LOCATION='], 'default'],
|
|
[
|
|
['SPRING_CONFIG_ADDITIONAL_LOCATION=file:/etc/qwen/extra.yml'],
|
|
'file:/etc/qwen/extra.yml',
|
|
],
|
|
]) {
|
|
const other = await rig(t, { serverEnv });
|
|
assert.equal(await other.check(), 0);
|
|
assert.equal(
|
|
other.services[0].environment.get('SPRING_CONFIG_ADDITIONAL_LOCATION'),
|
|
expected === 'default'
|
|
? `file:${path.join(other.current, 'config/application.yml')}`
|
|
: expected,
|
|
serverEnv[0],
|
|
);
|
|
}
|
|
await rm(config);
|
|
host.services = [];
|
|
assert.equal(await host.check(), 0);
|
|
assert.ok(
|
|
!host.services[0].files.some(({ file }) => file.endsWith('launcher.env')),
|
|
);
|
|
|
|
// Arguments systemctl prints joined cannot be passed on faithfully.
|
|
host.units[SERVER].ExecStart =
|
|
`{ path=${host.current}/bin/qwen-managed-agent-server ; argv[]=${host.current}/bin/qwen-managed-agent-server --spring.profiles.active=edge ; ignore_errors=no }`;
|
|
assert.equal(await host.check(), 1);
|
|
assert.equal(
|
|
rowsOf(host.output)['runtime-state'],
|
|
`FAIL cannot resolve the Runtime state directory: ${SERVER} passes arguments to bin/qwen-managed-agent-server, which systemctl does not print exactly`,
|
|
);
|
|
});
|
|
|
|
test('backup says when it archives no Runtime state', async (t) => {
|
|
const host = await rig(t, { state: null });
|
|
assert.equal(await host.check(), 0);
|
|
assert.equal(
|
|
rowsOf(host.output)['runtime-state'],
|
|
"SKIP the server's configuration names no Runtime state directory, so backup archives none",
|
|
);
|
|
await host.stop();
|
|
const target = path.join(host.root, 'backup');
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
target,
|
|
'--mysqldump',
|
|
path.join(host.tools, 'mysqldump'),
|
|
),
|
|
0,
|
|
);
|
|
assert.ok(!(await readdir(target)).includes('runtime-state.tar'));
|
|
assert.ok(
|
|
host.output.includes(
|
|
"No Runtime state archived: the server's configuration names no Runtime state directory, so backup archives none.",
|
|
),
|
|
);
|
|
|
|
const missing = path.join(host.root, 'gone');
|
|
const other = await rig(t, {
|
|
serverEnv: [`QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY=${missing}`],
|
|
});
|
|
assert.equal(await other.check(), 0);
|
|
assert.equal(
|
|
rowsOf(other.output)['runtime-state'],
|
|
`WARN ${missing} (QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY) does not exist, so backup archives no Runtime state`,
|
|
);
|
|
});
|
|
|
|
test('backup --skip-database runs no client', async (t) => {
|
|
const host = await rig(t);
|
|
await host.stop();
|
|
const target = path.join(host.root, 'backup');
|
|
assert.equal(
|
|
await host.upgrade('backup', '--to', target, '--skip-database'),
|
|
0,
|
|
);
|
|
assert.ok(!(await readdir(target)).includes('database.sql'));
|
|
const record = JSON.parse(
|
|
await readFile(path.join(target, 'backup.json'), 'utf8'),
|
|
);
|
|
assert.equal(record.database, 'skipped');
|
|
assert.ok(!host.calls.some(([command]) => command === 'mysqldump'));
|
|
});
|
|
|
|
test("backup leaves out an option MariaDB's mysqldump lacks", async (t) => {
|
|
const host = await rig(t);
|
|
await host.stop();
|
|
host.mysqldumpVersion = 'mysqldump Ver 10.19 Distrib 10.11.6-MariaDB';
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
path.join(host.root, 'backup'),
|
|
'--mysqldump',
|
|
path.join(host.tools, 'mysqldump'),
|
|
),
|
|
0,
|
|
);
|
|
const dump = host.calls.find(
|
|
([command, first]) => command === 'mysqldump' && first !== '--version',
|
|
);
|
|
assert.ok(!dump.some((arg) => arg.startsWith('--set-gtid-purged')), dump);
|
|
});
|
|
|
|
test('check refuses a directory in the installed config', async (t) => {
|
|
const host = await rig(t);
|
|
await mkdir(path.join(host.current, 'config/certs'));
|
|
assert.equal(await host.check(), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).config,
|
|
`FAIL ${host.current}/config holds certs, which is not a file`,
|
|
);
|
|
});
|
|
|
|
test('check reports an incomplete extraction as a row', async (t) => {
|
|
const host = await rig(t);
|
|
await rm(path.join(host.distribution, 'manifest.json'));
|
|
assert.equal(await host.check(), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).distribution,
|
|
'FAIL manifest.json does not match SHA256SUMS',
|
|
);
|
|
assert.equal(
|
|
rowsOf(host.output).deployment,
|
|
`FAIL ${host.distribution}/manifest.json is unreadable`,
|
|
);
|
|
assert.ok(
|
|
host.output.includes(
|
|
`${''.padEnd(21)} fix: extract the verified archive again`,
|
|
),
|
|
);
|
|
await host.stop();
|
|
assert.equal(
|
|
await host.upgrade('backup', '--to', path.join(host.root, 'backup')),
|
|
1,
|
|
);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/backup refused: .*manifest.json is unreadable; extract the verified archive again/,
|
|
);
|
|
});
|
|
|
|
test('install uses an existing release directory only when it is this build', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
const releases = `${host.current}-releases`;
|
|
const release = path.join(releases, '0.2.0');
|
|
// Another build of 0.2.0 that matches its own SHA256SUMS.
|
|
await writeDistribution(release, {
|
|
version: '0.2.0',
|
|
files: { 'bin/qwen-managed-agent-server': '#!/bin/sh\n# another build\n' },
|
|
});
|
|
assert.equal(await host.upgrade('install', '--backup', target), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/0.2.0 exists but is not this release; remove it and run install again/,
|
|
);
|
|
assert.ok((await lstat(host.current)).isDirectory());
|
|
|
|
// The same build, but its operator file is not the installed one.
|
|
await rm(release, { recursive: true });
|
|
await cp(host.distribution, release, { recursive: true });
|
|
const config = path.join(release, 'config/application.yml');
|
|
await writeFile(config, 'key: y\n');
|
|
assert.equal(await host.upgrade('install', '--backup', target), 1);
|
|
// The paths as the command resolves them, which a symlinked TMPDIR changes.
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/^install refused: \S+-releases\/0\.2\.0\/config\/application\.yml differs from \S+\/qwen-managed-agent\/config\/application\.yml; reconcile them and run install again\.$/,
|
|
);
|
|
assert.ok((await lstat(host.current)).isDirectory());
|
|
assert.equal(await readFile(config, 'utf8'), 'key: y\n');
|
|
|
|
// The archive extracted there is this build, and is used. Extracted with
|
|
// modes only root reads, it is made readable as a copy would be, except
|
|
// for a file no release lists.
|
|
await writeFile(config, 'key: x\n');
|
|
await chmod(path.join(release, 'lib'), 0o700);
|
|
await chmod(path.join(release, 'lib/server.jar'), 0o600);
|
|
await chmod(path.join(release, 'bin/qwen-managed-agent-server'), 0o700);
|
|
await writeFile(path.join(release, 'config/extra.yml'), 'k: v\n', {
|
|
mode: 0o600,
|
|
});
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
assert.ok(host.output.includes(`Using ${release}`));
|
|
assert.ok(!host.output.includes('Copied config/application.yml'));
|
|
assert.equal(await readlink(host.current), release);
|
|
assert.deepEqual(
|
|
await modesOf(release, [
|
|
'lib',
|
|
'lib/server.jar',
|
|
'bin/qwen-managed-agent-server',
|
|
'config/extra.yml',
|
|
]),
|
|
[0o755, 0o644, 0o755, 0o600],
|
|
);
|
|
});
|
|
|
|
test('install lets the service account read a release it runs from', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
// Extracted where install puts it, with modes only root reads, and run
|
|
// from there.
|
|
const release = path.join(`${host.current}-releases`, '0.2.0');
|
|
await mkdir(path.dirname(release));
|
|
await cp(host.distribution, release, { recursive: true });
|
|
host.system.distribution = await realpath(release);
|
|
await chmod(path.join(release, 'lib'), 0o700);
|
|
await chmod(path.join(release, 'lib/server.jar'), 0o600);
|
|
await chmod(path.join(release, 'bin/qwen-managed-agent-server'), 0o700);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
assert.ok(host.output.includes(`Using ${release}`), host.output.join('\n'));
|
|
assert.equal(await readlink(host.current), release);
|
|
assert.deepEqual(
|
|
await modesOf(release, [
|
|
'lib',
|
|
'lib/server.jar',
|
|
'bin/qwen-managed-agent-server',
|
|
]),
|
|
[0o755, 0o644, 0o755],
|
|
);
|
|
});
|
|
|
|
test('install leaves no partial copy, and an interrupted one is reported', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
const releases = `${host.current}-releases`;
|
|
// A copy that fails halfway: fs.cp refuses a FIFO.
|
|
const pipe = path.join(host.distribution, 'pipe');
|
|
if (spawnSync('mkfifo', [pipe]).status !== 0) {
|
|
t.diagnostic('no mkfifo on this host');
|
|
return;
|
|
}
|
|
await assert.rejects(host.upgrade('install', '--backup', target), {
|
|
code: 'ERR_FS_CP_FIFO_PIPE',
|
|
});
|
|
assert.deepEqual(await readdir(releases), []);
|
|
assert.ok((await lstat(host.current)).isDirectory());
|
|
await rm(pipe);
|
|
|
|
// A crash between moving the directory and renaming the link over it,
|
|
// beside an operator's link that install would not have named.
|
|
await cp(host.distribution, path.join(releases, '0.2.0'), {
|
|
recursive: true,
|
|
});
|
|
await rename(host.current, path.join(releases, '0.1.0'));
|
|
const link = `${host.current}.upgrade-0000abcd`;
|
|
await symlink(path.join(releases, '0.2.0'), link);
|
|
await symlink(releases, `${host.current}.upgrade-backup`);
|
|
assert.equal(await host.check(), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).deployment,
|
|
`FAIL ${host.current} is missing and ${link} is left from an interrupted install`,
|
|
);
|
|
assert.ok(
|
|
host.output.some((line) =>
|
|
line.endsWith(
|
|
`fix: finish the install with mv -T ${link} ${host.current}, then run start`,
|
|
),
|
|
),
|
|
);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/install refused: .*finish the install with mv -T/,
|
|
);
|
|
});
|
|
|
|
test('install refuses a copy that does not match SHA256SUMS', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
const releases = `${host.current}-releases`;
|
|
// The extraction changes after check verified it, as on a failing disk.
|
|
const { out } = host.system;
|
|
host.system.out = (line) => {
|
|
out(line);
|
|
if (line.startsWith('Copying ')) {
|
|
writeFileSync(
|
|
path.join(host.distribution, 'bin/qwen-managed-agent-server'),
|
|
'damaged\n',
|
|
);
|
|
}
|
|
};
|
|
assert.equal(await host.upgrade('install', '--backup', target), 1);
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/^install refused: The copy at \S+\/\.0\.2\.0\.partial-[0-9a-f]{8} does not match SHA256SUMS \(bin\/qwen-managed-agent-server\)\.$/,
|
|
);
|
|
assert.deepEqual(await readdir(releases), []);
|
|
assert.ok((await lstat(host.current)).isDirectory());
|
|
});
|
|
|
|
test('install removes what an interrupted install left', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
const releases = `${host.current}-releases`;
|
|
const release = path.join(releases, '0.2.0');
|
|
// Copies killed halfway, of this release and of another.
|
|
await mkdir(path.join(releases, '.0.2.0.partial-0000abcd/lib'), {
|
|
recursive: true,
|
|
});
|
|
await mkdir(path.join(releases, '.0.1.9.partial-1111abcd'));
|
|
// A link made before anything moved, beside an operator's file and links
|
|
// named alike.
|
|
await symlink(release, `${host.current}.upgrade-2222abcd`);
|
|
await writeFile(`${host.current}.upgrade-notes`, 'mine\n');
|
|
const theirs = [
|
|
`${host.current}.upgrade-backup`,
|
|
`${host.current}.upgrade-2222abcd.old`,
|
|
`${host.current}.upgrade-2222abcde`,
|
|
];
|
|
for (const each of theirs) {
|
|
await symlink(releases, each);
|
|
}
|
|
// The release, with a configuration file killed before its rename.
|
|
await cp(host.distribution, release, { recursive: true });
|
|
await writeFile(
|
|
path.join(release, 'config/application.yml.tmp-3333abcd'),
|
|
'key: x\n',
|
|
);
|
|
const left = [
|
|
path.join(releases, '.0.1.9.partial-1111abcd'),
|
|
path.join(releases, '.0.2.0.partial-0000abcd'),
|
|
`${host.current}.upgrade-2222abcd`,
|
|
path.join(release, 'config/application.yml.tmp-3333abcd'),
|
|
];
|
|
assert.equal(await host.check(), 0);
|
|
assert.ok(
|
|
rowsOf(host.output).layout.endsWith(
|
|
`, after removing what an interrupted install left: ${left.join(', ')}`,
|
|
),
|
|
rowsOf(host.output).layout,
|
|
);
|
|
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
for (const each of left) {
|
|
assert.ok(
|
|
host.output.includes(
|
|
`Removed ${each}, which an interrupted install left`,
|
|
),
|
|
each,
|
|
);
|
|
await assert.rejects(lstat(each), { code: 'ENOENT' });
|
|
}
|
|
// The link went, not the release it named; the operator's entries stay.
|
|
assert.equal(await readlink(host.current), release);
|
|
assert.equal(
|
|
await readFile(`${host.current}.upgrade-notes`, 'utf8'),
|
|
'mine\n',
|
|
);
|
|
for (const each of theirs) {
|
|
assert.equal(await readlink(each), releases, each);
|
|
}
|
|
assert.deepEqual((await readdir(path.join(release, 'config'))).sort(), [
|
|
'application.example.yml',
|
|
'application.yml',
|
|
]);
|
|
});
|
|
|
|
test('install keeps the link when it can neither switch nor undo', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
const { rename: original } = fsPromises;
|
|
t.after(() => {
|
|
fsPromises.rename = original;
|
|
syncBuiltinESMExports();
|
|
});
|
|
// Moving the directory away works; nothing then renames onto its path.
|
|
fsPromises.rename = async (from, to) => {
|
|
if (to === host.current) {
|
|
throw Object.assign(new Error('EIO: injected'), { code: 'EIO' });
|
|
}
|
|
return original(from, to);
|
|
};
|
|
syncBuiltinESMExports();
|
|
assert.equal(await host.upgrade('install', '--backup', target), 1);
|
|
const [, link] = /mv -T (\S+) /.exec(host.output.at(-1));
|
|
assert.match(
|
|
host.output.at(-1),
|
|
/^install refused: \S+\.upgrade-[0-9a-f]{8} did not replace \S+ \(EIO: injected\), and \S+\/0\.1\.0 did not move back \(EIO: injected\); finish the install with mv -T \S+ \S+, or undo it with mv -T \S+ \S+\.$/,
|
|
);
|
|
assert.equal(await readlink(link), `${host.current}-releases/0.2.0`);
|
|
fsPromises.rename = original;
|
|
syncBuiltinESMExports();
|
|
|
|
// check finds the link the install left and says the same.
|
|
assert.equal(await host.check(), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).deployment,
|
|
`FAIL ${host.current} is missing and ${link} is left from an interrupted install`,
|
|
);
|
|
});
|
|
|
|
test('--runtime-state none backs up a deployment that keeps no state', async (t) => {
|
|
// The jar cannot resolve the configuration, and the operator knows better.
|
|
const host = await rig(t, { state: null });
|
|
host.resolveFailure = 'cannot resolve the server configuration: refused';
|
|
assert.equal(await host.check(), 1);
|
|
assert.equal(await host.check('--runtime-state', 'none'), 0);
|
|
assert.equal(
|
|
rowsOf(host.output)['runtime-state'],
|
|
'SKIP backup archives no Runtime state, as --runtime-state none asks',
|
|
);
|
|
assert.ok(
|
|
host.output.some((line) =>
|
|
line.endsWith(
|
|
` backup --to <new empty directory> --mysqldump ${path.join(host.tools, 'mysqldump')} --runtime-state none`,
|
|
),
|
|
),
|
|
);
|
|
await host.stop();
|
|
const target = path.join(host.root, 'backup');
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'backup',
|
|
'--to',
|
|
target,
|
|
'--mysqldump',
|
|
path.join(host.tools, 'mysqldump'),
|
|
'--runtime-state',
|
|
'none',
|
|
),
|
|
0,
|
|
);
|
|
assert.ok(!(await readdir(target)).includes('runtime-state.tar'));
|
|
assert.equal(
|
|
JSON.parse(await readFile(path.join(target, 'backup.json'), 'utf8'))
|
|
.runtimeState,
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('stop counts the workers in either cgroup hierarchy and refuses an unreadable one', async (t) => {
|
|
// cgroup v1 and the hybrid layout: systemd's named hierarchy.
|
|
const host = await rig(t);
|
|
host.cgroupLayout = 'v1';
|
|
await host.system.sleep(0);
|
|
host.workersIgnoreTerm = true;
|
|
assert.equal(await host.upgrade('stop', '--timeout', '2'), 1);
|
|
assert.match(
|
|
host.output.join('\n'),
|
|
/Runtime workers still run after 2s \(PID 11\)/,
|
|
);
|
|
|
|
// A procs file that cannot be read is no proof that nothing runs.
|
|
const other = await rig(t);
|
|
const procs = path.join(
|
|
other.root,
|
|
'cgroup/system.slice',
|
|
`${SERVER}.service`,
|
|
'cgroup.procs',
|
|
);
|
|
other.system.sleep = async () => {
|
|
other.clock += 1;
|
|
};
|
|
await rm(procs);
|
|
await mkdir(procs);
|
|
assert.equal(await other.upgrade('stop'), 1);
|
|
assert.equal(
|
|
other.output.at(-1),
|
|
`stop refused: cannot read ${procs} (EISDIR), so the Runtime workers of ${SERVER} cannot be counted.`,
|
|
);
|
|
assert.equal(other.units[HARNESS].ActiveState, 'active');
|
|
|
|
// A cgroup that systemd is removing after the kill answers ENODEV.
|
|
const dying = await rig(t);
|
|
const kill = dying.system.run;
|
|
dying.system.run = (command, args, options) => {
|
|
const result = kill(command, args, options);
|
|
if (command === 'systemctl' && args[0] === 'kill') {
|
|
dying.cgroupError = 'ENODEV';
|
|
}
|
|
return result;
|
|
};
|
|
assert.equal(await dying.upgrade('stop'), 0);
|
|
assert.equal(dying.units[HARNESS].ActiveState, 'inactive');
|
|
// Any other error is no proof that a worker that outlived stop is gone.
|
|
dying.units[SERVER].procs = ['12'];
|
|
await dying.system.sleep(0);
|
|
dying.cgroupError = 'EACCES';
|
|
assert.equal(
|
|
await dying.upgrade('backup', '--to', path.join(dying.root, 'b')),
|
|
1,
|
|
);
|
|
assert.match(dying.output.at(-1), /cannot read .*cgroup\.procs \(EACCES\)/);
|
|
|
|
const bare = await rig(t);
|
|
bare.system.sleep = async () => {
|
|
bare.clock += 1;
|
|
};
|
|
await rm(path.join(bare.root, 'cgroup'), { recursive: true });
|
|
await mkdir(path.join(bare.root, 'cgroup'));
|
|
assert.equal(await bare.upgrade('stop'), 1);
|
|
assert.match(
|
|
bare.output.at(-1),
|
|
/holds neither the unified cgroup hierarchy nor systemd's/,
|
|
);
|
|
assert.equal(bare.units[HARNESS].ActiveState, 'active');
|
|
});
|
|
|
|
test('stop says why the control plane shows as failed', async (t) => {
|
|
const host = await rig(t);
|
|
host.stopFails = true;
|
|
assert.equal(await host.upgrade('stop'), 0);
|
|
assert.equal(
|
|
host.output.at(-1),
|
|
`${SERVER} shows as failed: its unit counts the exit status 143 of a stopped JVM as a failure. This release's unit template adds SuccessExitStatus=143; carry it over before start, then systemctl reset-failed ${SERVER} clears the state.`,
|
|
);
|
|
// Any other failure points at the journal.
|
|
const other = await rig(t);
|
|
other.stopFails = true;
|
|
const stop = other.system.run;
|
|
other.system.run = (command, args, options) => {
|
|
const result = stop(command, args, options);
|
|
if (command === 'systemctl' && args[0] === 'stop') {
|
|
other.units[SERVER].ExecMainStatus = '1';
|
|
}
|
|
return result;
|
|
};
|
|
assert.equal(await other.upgrade('stop'), 0);
|
|
assert.equal(
|
|
other.output.at(-1),
|
|
`${SERVER} shows as failed (exit-code, status 1); see journalctl -u ${SERVER}. start runs it again.`,
|
|
);
|
|
// A stop that ended an ExecStartPost= command fails with the signal even
|
|
// when the JVM exited 143, as measured on systemd 257.
|
|
const post = await rig(t);
|
|
post.stopFails = true;
|
|
const stopPost = post.system.run;
|
|
post.system.run = (command, args, options) => {
|
|
const result = stopPost(command, args, options);
|
|
if (command === 'systemctl' && args[0] === 'stop') {
|
|
post.units[SERVER].Result = 'signal';
|
|
}
|
|
return result;
|
|
};
|
|
assert.equal(await post.upgrade('stop'), 0);
|
|
assert.equal(
|
|
post.output.at(-1),
|
|
`${SERVER} shows as failed (signal, status 143); see journalctl -u ${SERVER}. start runs it again.`,
|
|
);
|
|
});
|
|
|
|
test('check refuses a new script Flyway would not run', async (t) => {
|
|
const installedScripts = {
|
|
'db/migration/V1__managed_schema.sql': 'CREATE TABLE a (id INT);\n',
|
|
'db/migration/V2__more.sql': 'CREATE TABLE b (id INT);\n',
|
|
};
|
|
for (const [added, expected] of [
|
|
[
|
|
'V1.5__backport.sql',
|
|
/^FAIL this release adds V1.5__backport.sql, which does not sort after V2__more.sql, the newest script the installed database ran, so Flyway refuses it/,
|
|
],
|
|
[
|
|
'V2__again.sql',
|
|
/^FAIL this release adds V2__again.sql, which does not sort after V2__more.sql/,
|
|
],
|
|
['V1_10__later.sql', /^FAIL this release adds V1_10__later.sql/],
|
|
[
|
|
'V3__later.sql',
|
|
/^WARN start applies V3__later.sql; only the database backup undoes it/,
|
|
],
|
|
['V2_1__later.sql', /^WARN start applies V2_1__later.sql/],
|
|
]) {
|
|
const host = await rig(t, {
|
|
installedScripts,
|
|
scripts: {
|
|
...installedScripts,
|
|
[`db/migration/${added}`]: 'SELECT 1;\n',
|
|
},
|
|
});
|
|
assert.equal(
|
|
await host.check(),
|
|
expected.source.startsWith('^FAIL') ? 1 : 0,
|
|
added,
|
|
);
|
|
assert.match(rowsOf(host.output).database, expected, added);
|
|
}
|
|
});
|
|
|
|
test('verify bounds every request and retries a poll the restart failed', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
host.responses['GET /v1/agents/sessions'] = { data: [{ id: 's1' }] };
|
|
host.responses['GET /v1/agents/sessions/s1'] = { id: 's1' };
|
|
host.responses['GET /v1/agents/sessions/s1/items'] = { data: [] };
|
|
host.responses['POST /v1/agents/sessions'] = { id: 's2' };
|
|
// A Harness that reconnects answers 503 once.
|
|
const fetch = host.system.fetch;
|
|
let polls = 0;
|
|
host.system.fetch = async (url, init) => {
|
|
if (url.includes('/events')) {
|
|
polls += 1;
|
|
if (polls === 1) {
|
|
host.requests.push({ url, init });
|
|
return {
|
|
ok: false,
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
json: async () => ({ error: { message: 'dependency_unavailable' } }),
|
|
};
|
|
}
|
|
host.responses['GET /v1/agents/sessions/s2/events'] = {
|
|
data: [{ sequence: 1, type: 'turn.completed', terminal: true }],
|
|
};
|
|
}
|
|
return fetch(url, init);
|
|
};
|
|
host.requests = [];
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme', '--turn'), 0);
|
|
assert.match(
|
|
rowsOf(host.output).turn,
|
|
/^PASS Session s2 ended with turn.completed/,
|
|
);
|
|
assert.equal(polls, 2);
|
|
assert.ok(host.requests.length > 0);
|
|
assert.ok(
|
|
host.requests.every(({ init }) => init.signal),
|
|
'every request is bounded',
|
|
);
|
|
|
|
// A 4xx is an answer, not a blip.
|
|
host.system.fetch = fetch;
|
|
delete host.responses['GET /v1/agents/sessions/s2/events'];
|
|
const polled = host.requests.length;
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme', '--turn'), 1);
|
|
assert.match(
|
|
rowsOf(host.output).turn,
|
|
/^FAIL Session s2: GET .*events.*: 404 missing/,
|
|
);
|
|
assert.equal(
|
|
host.requests.slice(polled).filter(({ url }) => url.includes('/events'))
|
|
.length,
|
|
1,
|
|
);
|
|
|
|
// A poll that keeps failing ends at --timeout and names the last failure.
|
|
host.system.fetch = async (url, init) =>
|
|
url.includes('/events')
|
|
? {
|
|
ok: false,
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
json: async () => ({ error: { message: 'dependency_unavailable' } }),
|
|
}
|
|
: fetch(url, init);
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'verify',
|
|
'--tenant',
|
|
'acme',
|
|
'--turn',
|
|
'--timeout',
|
|
'3',
|
|
),
|
|
1,
|
|
);
|
|
assert.equal(
|
|
rowsOf(host.output).turn,
|
|
'FAIL Session s2: no terminal event within 3s; the last poll failed: GET /v1/agents/sessions/s2/events?after=0&limit=100: 503 dependency_unavailable',
|
|
);
|
|
host.system.fetch = fetch;
|
|
|
|
// A control plane that accepts and never answers ends at --timeout.
|
|
host.hang = true;
|
|
const started = Date.now();
|
|
assert.equal(
|
|
await host.upgrade(
|
|
'verify',
|
|
'--tenant',
|
|
'acme',
|
|
'--turn',
|
|
'--timeout',
|
|
'1',
|
|
),
|
|
1,
|
|
);
|
|
assert.ok(Date.now() - started < 8000, `${Date.now() - started}ms`);
|
|
const rows = rowsOf(host.output);
|
|
assert.match(
|
|
rows.health,
|
|
/^FAIL http:\/\/127\.0\.0\.1:18080\/actuator\/health is not UP: .*timeout/i,
|
|
);
|
|
assert.match(
|
|
rows.session,
|
|
/^FAIL GET \/v1\/agents\/sessions\?limit=1: .*timeout/i,
|
|
);
|
|
});
|
|
|
|
test('verify waits the whole budget for a slow health answer', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
// Longer than one probe of start's loop may take.
|
|
const fetch = host.system.fetch;
|
|
host.system.fetch = async (url, init) => {
|
|
if (url.endsWith('/actuator/health')) {
|
|
await new Promise((resolve) => setTimeout(resolve, 5300));
|
|
init.signal.throwIfAborted();
|
|
}
|
|
return fetch(url, init);
|
|
};
|
|
assert.equal(await host.upgrade('verify', '--timeout', '30'), 0);
|
|
assert.match(rowsOf(host.output).health, /^PASS/);
|
|
});
|
|
|
|
test('verify reads Sessions as the actor it is given', async (t) => {
|
|
const { host, target } = await backedUp(t, {
|
|
serverEnv: ['QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER=X-Actor'],
|
|
});
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
// Without an actor the API lists Sessions outside Workspaces only.
|
|
host.responses['GET /v1/agents/sessions'] = (search, init) =>
|
|
init.headers['X-Actor'] === 'alice'
|
|
? { data: [{ id: 'w1' }] }
|
|
: { data: [] };
|
|
host.responses['GET /v1/agents/sessions/w1'] = { id: 'w1' };
|
|
host.responses['GET /v1/agents/sessions/w1/items'] = { data: [{}] };
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 0);
|
|
assert.deepEqual(
|
|
[
|
|
rowsOf(host.output).session,
|
|
host.output.find((line) => line.includes('fix: pass --actor'))?.trim(),
|
|
],
|
|
[
|
|
'WARN tenant acme has no Session that a request without an actor reads',
|
|
'fix: pass --actor <id> to read Workspace Sessions as one of their readers',
|
|
],
|
|
);
|
|
assert.equal(
|
|
await host.upgrade('verify', '--tenant', 'acme', '--actor', 'alice'),
|
|
0,
|
|
);
|
|
assert.equal(
|
|
rowsOf(host.output).session,
|
|
'PASS read Session w1 and 1 item(s)',
|
|
);
|
|
assert.ok(
|
|
host.requests
|
|
.filter(({ url }) => url.includes('/w1'))
|
|
.every(({ init }) => init.headers['X-Actor'] === 'alice'),
|
|
);
|
|
assert.equal(await host.upgrade('verify', '--actor', 'alice'), 2);
|
|
|
|
// A server that names no actor header takes none.
|
|
host.resolved = { trustedActorHeader: '' };
|
|
assert.equal(
|
|
await host.upgrade('verify', '--tenant', 'acme', '--actor', 'alice'),
|
|
1,
|
|
);
|
|
assert.match(
|
|
rowsOf(host.output).session,
|
|
/^FAIL --actor needs a trusted actor header/,
|
|
);
|
|
});
|
|
|
|
test('verify takes the auth mode from the configuration the server resolves', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
// signed in a configuration file, which no variable shows.
|
|
host.resolved = { authMode: 'signed' };
|
|
host.services = [];
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 0);
|
|
assert.match(
|
|
rowsOf(host.output).session,
|
|
/^SKIP the API takes signed requests only/,
|
|
);
|
|
assert.ok(!host.requests.some(({ url }) => url.includes('/v1/')));
|
|
// The installed release is this one, so its own jar runs in place.
|
|
const resolver = host.services.find(({ args }) => args.includes(RESOLVER));
|
|
assert.equal(
|
|
resolver.jar.path,
|
|
path.join(
|
|
await realpath(path.join(`${host.current}-releases`, '0.2.0')),
|
|
'lib/server.jar',
|
|
),
|
|
);
|
|
host.resolveFailure = 'cannot resolve the server configuration: refused';
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 0);
|
|
assert.equal(
|
|
rowsOf(host.output).session,
|
|
"SKIP cannot resolve the API's auth mode: cannot resolve the server configuration: refused",
|
|
);
|
|
assert.ok(!host.requests.some(({ url }) => url.includes('/v1/')));
|
|
});
|
|
|
|
test('verify fails a read whose body breaks off after a success status', async (t) => {
|
|
const { host, target } = await backedUp(t);
|
|
assert.equal(await host.upgrade('install', '--backup', target), 0);
|
|
host.responses['GET /actuator/health'] = { status: 'UP' };
|
|
assert.equal(await host.upgrade('start'), 0);
|
|
host.responses['GET /v1/agents/sessions'] = { data: [{ id: 's1' }] };
|
|
host.responses['GET /v1/agents/sessions/s1'] = { id: 's1' };
|
|
host.responses['GET /v1/agents/sessions/s1/items'] = { data: [{}] };
|
|
host.responses['POST /v1/agents/sessions'] = { id: 's2' };
|
|
host.responses['GET /v1/agents/sessions/s2/events'] = {
|
|
data: [{ sequence: 1, type: 'turn.completed', terminal: true }],
|
|
};
|
|
// The headers arrive with 200, then the body is cut off.
|
|
const fetch = host.system.fetch;
|
|
let breaks = 0;
|
|
const breakOff = (part, times = Infinity) => {
|
|
breaks = 0;
|
|
host.system.fetch = async (url, init) => {
|
|
const response = await fetch(url, init);
|
|
if (!url.includes(part) || breaks >= times) {
|
|
return response;
|
|
}
|
|
breaks += 1;
|
|
return {
|
|
...response,
|
|
json: async () => {
|
|
throw Object.assign(new Error('This operation was aborted'), {
|
|
name: 'AbortError',
|
|
});
|
|
},
|
|
};
|
|
};
|
|
};
|
|
breakOff('/items');
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
assert.equal(
|
|
rowsOf(host.output).session,
|
|
'FAIL GET /v1/agents/sessions/s1/items?limit=20: 200 with an unreadable body (This operation was aborted)',
|
|
);
|
|
// A listing that breaks off is a failed read, not a tenant without Sessions.
|
|
breakOff('/v1/agents/sessions?limit=1');
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme'), 1);
|
|
assert.match(
|
|
rowsOf(host.output).session,
|
|
/^FAIL GET \/v1\/agents\/sessions\?limit=1: 200 with an unreadable body/,
|
|
);
|
|
// A poll whose body breaks off once is polled again.
|
|
breakOff('/events', 1);
|
|
assert.equal(await host.upgrade('verify', '--tenant', 'acme', '--turn'), 0);
|
|
assert.match(
|
|
rowsOf(host.output).turn,
|
|
/^PASS Session s2 ended with turn.completed/,
|
|
);
|
|
assert.equal(breaks, 1);
|
|
});
|