mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
2835 lines
91 KiB
JavaScript
2835 lines
91 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const {
|
|
generateKeyPairSync,
|
|
randomBytes,
|
|
randomUUID,
|
|
sign,
|
|
} = require('node:crypto');
|
|
const fs = require('node:fs');
|
|
const yaml = require('js-yaml');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { createRequire } = require('node:module');
|
|
const { spawnSync } = require('node:child_process');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const K3S_IMAGE = 'rancher/k3s:v1.34.3-k3s1';
|
|
const K3S_DIGEST =
|
|
'sha256:71abd3a56f57884c62732e0e0d87606052cb5f8555b7db7e8e33c04570b8175c';
|
|
const POSTGRES_IMAGE = 'postgres:18';
|
|
const POSTGRES_DATABASE = 'ql3_contract';
|
|
const POSTGRES_SUPERUSER_PASSWORD = 'postgres';
|
|
const NAMESPACE = 'qinglong3-system';
|
|
const DEPLOYMENT = 'ql3-worker-credential-management';
|
|
const SERVICE = 'ql3-worker-credential-management';
|
|
const IDENTITY_SECRET = 'ql3-worker-credential-management-identity';
|
|
const TLS_SECRET = 'ql3-worker-credential-management-tls';
|
|
const DATABASE_SECRET = 'ql3-worker-credential-management-database';
|
|
const SERVERNAME = `${SERVICE}.${NAMESPACE}.svc`;
|
|
const MANAGEMENT_PATH = '/api/v3/worker-credentials/management';
|
|
const ISSUER = 'https://identity.qinglong.test/';
|
|
const AUDIENCE = 'qinglong3-worker-credential-management';
|
|
const POSTGRES_ROLES = Object.freeze({
|
|
migration: Object.freeze({
|
|
user: 'ql3_migration',
|
|
password: 'ql3_migration_test',
|
|
}),
|
|
runtime: Object.freeze({ user: 'ql3_runtime', password: 'ql3_runtime_test' }),
|
|
admin: Object.freeze({ user: 'ql3_admin', password: 'ql3_admin_test' }),
|
|
automationManager: Object.freeze({
|
|
user: 'ql3_automation_manager',
|
|
password: 'ql3_automation_manager_test',
|
|
}),
|
|
approvalManager: Object.freeze({
|
|
user: 'ql3_approval_manager',
|
|
password: 'ql3_approval_manager_test',
|
|
}),
|
|
packageManager: Object.freeze({
|
|
user: 'ql3_package_manager',
|
|
password: 'ql3_package_manager_test',
|
|
}),
|
|
packageExecutor: Object.freeze({
|
|
user: 'ql3_package_executor',
|
|
password: 'ql3_package_executor_test',
|
|
}),
|
|
workerCredentialManager: Object.freeze({
|
|
user: 'ql3_worker_credential_manager',
|
|
password: 'ql3_worker_credential_manager_test',
|
|
}),
|
|
workerCredentialExecutor: Object.freeze({
|
|
user: 'ql3_worker_credential_executor',
|
|
password: 'ql3_worker_credential_executor_test',
|
|
}),
|
|
workerIngress: Object.freeze({
|
|
user: 'ql3_worker_ingress',
|
|
password: 'ql3_worker_ingress_test',
|
|
}),
|
|
});
|
|
const clusterRequire = createRequire(
|
|
path.join(ROOT, 'packages/ql3-cluster-admin/package.json'),
|
|
);
|
|
|
|
const HEALTH_SCRIPT = String.raw`
|
|
const fs = require('node:fs');
|
|
const https = require('node:https');
|
|
const [host, rawPort] = process.argv.slice(1);
|
|
const ca = fs.readFileSync('/var/run/qinglong3/ca/ca.crt');
|
|
function request(path) {
|
|
return new Promise((resolve, reject) => {
|
|
const outgoing = https.request({
|
|
host,
|
|
port: Number(rawPort),
|
|
path,
|
|
method: 'GET',
|
|
servername: host,
|
|
ca,
|
|
minVersion: 'TLSv1.3',
|
|
maxVersion: 'TLSv1.3',
|
|
rejectUnauthorized: true,
|
|
agent: false,
|
|
headers: { connection: 'close' }
|
|
}, (incoming) => {
|
|
const chunks = [];
|
|
incoming.on('data', (chunk) => chunks.push(chunk));
|
|
incoming.once('end', () => resolve({
|
|
path,
|
|
statusCode: incoming.statusCode,
|
|
protocol: incoming.socket.getProtocol(),
|
|
body: JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
}));
|
|
});
|
|
outgoing.setTimeout(10000, () => outgoing.destroy(new Error('timeout')));
|
|
outgoing.once('error', reject);
|
|
outgoing.end();
|
|
});
|
|
}
|
|
(async () => {
|
|
let lastError = 'unavailable';
|
|
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
|
try {
|
|
const responses = await Promise.all([request('/readyz'), request('/livez')]);
|
|
const output = JSON.stringify({ schemaVersion: 1, attempt, responses });
|
|
fs.writeFileSync('/dev/termination-log', output + '\n');
|
|
process.stdout.write(output + '\n');
|
|
return;
|
|
} catch (error) {
|
|
lastError = String(error.code || error.name || 'UNKNOWN').slice(0, 64);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
}
|
|
const output = JSON.stringify({ schemaVersion: 1, error: lastError });
|
|
fs.writeFileSync('/dev/termination-log', output + '\n');
|
|
process.stderr.write(output + '\n');
|
|
process.exitCode = 1;
|
|
})();
|
|
`;
|
|
|
|
const RAW_REQUEST_DIAGNOSTIC_SCRIPT = String.raw`
|
|
const fs = require('node:fs');
|
|
const https = require('node:https');
|
|
const [configFile, commandFile, assertionFile] = process.argv.slice(1);
|
|
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
const body = fs.readFileSync(commandFile);
|
|
const assertion = fs.readFileSync(assertionFile, 'ascii');
|
|
const target = new URL(config.endpoint);
|
|
const cert = config.clientCertificateFile
|
|
? fs.readFileSync(config.clientCertificateFile)
|
|
: undefined;
|
|
const key = config.clientPrivateKeyFile
|
|
? fs.readFileSync(config.clientPrivateKeyFile)
|
|
: undefined;
|
|
const outgoing = https.request({
|
|
protocol: target.protocol,
|
|
hostname: target.hostname,
|
|
port: Number(target.port),
|
|
path: target.pathname,
|
|
method: 'POST',
|
|
servername: config.servername,
|
|
ca: fs.readFileSync(config.caFile),
|
|
cert,
|
|
key,
|
|
minVersion: 'TLSv1.3',
|
|
maxVersion: 'TLSv1.3',
|
|
rejectUnauthorized: true,
|
|
agent: false,
|
|
headers: {
|
|
accept: 'application/json',
|
|
'accept-encoding': 'identity',
|
|
authorization: 'Bearer ' + assertion,
|
|
connection: 'close',
|
|
'content-type': 'application/json',
|
|
'content-length': String(body.length)
|
|
}
|
|
}, (incoming) => {
|
|
const chunks = [];
|
|
incoming.on('data', (chunk) => chunks.push(chunk));
|
|
incoming.once('end', () => {
|
|
const responseBody = Buffer.concat(chunks).toString('utf8');
|
|
const output = JSON.stringify({
|
|
event: 'raw_request_completed',
|
|
statusCode: incoming.statusCode,
|
|
protocol: incoming.socket.getProtocol(),
|
|
rawHeaders: incoming.rawHeaders,
|
|
body: JSON.parse(responseBody)
|
|
});
|
|
fs.writeFileSync('/dev/termination-log', output + '\n');
|
|
process.stdout.write(output + '\n');
|
|
});
|
|
});
|
|
outgoing.setTimeout(10000, () => outgoing.destroy(new Error('timeout')));
|
|
outgoing.once('error', (error) => {
|
|
const output = JSON.stringify({
|
|
event: 'raw_request_failed',
|
|
code: String(error.code || error.name || 'UNKNOWN').slice(0, 64),
|
|
message: String(error.message || '').slice(0, 256)
|
|
});
|
|
fs.writeFileSync('/dev/termination-log', output + '\n');
|
|
process.stdout.write(output + '\n');
|
|
});
|
|
outgoing.end(body);
|
|
`;
|
|
|
|
const CONNECTIVITY_SCRIPT = String.raw`
|
|
const fs = require('node:fs');
|
|
const https = require('node:https');
|
|
const [host, rawPort] = process.argv.slice(1);
|
|
const ca = fs.readFileSync('/var/run/qinglong3/ca/ca.crt');
|
|
function probe() {
|
|
return new Promise((resolve, reject) => {
|
|
const outgoing = https.request({
|
|
host,
|
|
port: Number(rawPort),
|
|
path: '/readyz',
|
|
method: 'GET',
|
|
servername: host,
|
|
ca,
|
|
minVersion: 'TLSv1.3',
|
|
maxVersion: 'TLSv1.3',
|
|
rejectUnauthorized: true,
|
|
agent: false,
|
|
headers: { connection: 'close' }
|
|
}, (incoming) => {
|
|
incoming.resume();
|
|
incoming.once('end', () => resolve({
|
|
statusCode: incoming.statusCode,
|
|
protocol: incoming.socket.getProtocol()
|
|
}));
|
|
});
|
|
outgoing.setTimeout(3000, () => outgoing.destroy(new Error('timeout')));
|
|
outgoing.once('error', reject);
|
|
outgoing.end();
|
|
});
|
|
}
|
|
(async () => {
|
|
let lastError = 'unavailable';
|
|
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
|
try {
|
|
const response = await probe();
|
|
if (response.statusCode === 200 && response.protocol === 'TLSv1.3') {
|
|
const output = JSON.stringify({
|
|
event: 'connectivity_ready',
|
|
attempts: attempt,
|
|
...response
|
|
});
|
|
fs.writeFileSync('/dev/termination-log', output + '\n');
|
|
process.stdout.write(output + '\n');
|
|
return;
|
|
}
|
|
lastError = 'HTTP_' + String(response.statusCode);
|
|
} catch (error) {
|
|
lastError = String(error.code || error.name || 'UNKNOWN').slice(0, 64);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
}
|
|
const output = JSON.stringify({ event: 'connectivity_failed', error: lastError });
|
|
fs.writeFileSync('/dev/termination-log', output + '\n');
|
|
process.stderr.write(output + '\n');
|
|
process.exitCode = 1;
|
|
})();
|
|
`;
|
|
|
|
function run(binary, args, options = {}) {
|
|
if (!options.quiet) {
|
|
process.stderr.write(`+ ${path.basename(binary)} ${args.join(' ')}\n`);
|
|
}
|
|
const result = spawnSync(binary, args, {
|
|
cwd: ROOT,
|
|
env: options.env ?? process.env,
|
|
input: options.input,
|
|
encoding: 'utf8',
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
stdio: options.capture
|
|
? ['pipe', 'pipe', 'pipe']
|
|
: [
|
|
options.input === undefined ? 'inherit' : 'pipe',
|
|
'inherit',
|
|
'inherit',
|
|
],
|
|
});
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0 && !options.allowFailure) {
|
|
throw new Error(
|
|
`${path.basename(binary)} failed with ${String(result.status)}: ` +
|
|
`${result.stderr || result.stdout || ''}`,
|
|
);
|
|
}
|
|
return Object.freeze({
|
|
status: result.status,
|
|
stdout: options.capture ? result.stdout.trim() : '',
|
|
stderr: options.capture ? result.stderr.trim() : '',
|
|
});
|
|
}
|
|
|
|
function sleep(milliseconds) {
|
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
}
|
|
|
|
async function waitFor(description, timeoutMs, inspect) {
|
|
const startedAt = Date.now();
|
|
let last = 'not observed';
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
try {
|
|
const value = await inspect();
|
|
if (value?.ready) return value.value;
|
|
if (value?.fact) last = value.fact;
|
|
} catch (error) {
|
|
last = error instanceof Error ? error.message : String(error);
|
|
}
|
|
await sleep(500);
|
|
}
|
|
throw new Error(`${description} timed out: ${last}`);
|
|
}
|
|
|
|
function reviewedKey(kid) {
|
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
return Object.freeze({
|
|
kid,
|
|
privateKey,
|
|
publicJwk: Object.freeze({
|
|
...publicKey.export({ format: 'jwk' }),
|
|
alg: 'EdDSA',
|
|
kid,
|
|
use: 'sig',
|
|
}),
|
|
});
|
|
}
|
|
|
|
function keyset(generation, keys, revokedKids = []) {
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
generation,
|
|
issuer: ISSUER,
|
|
audience: AUDIENCE,
|
|
keys: keys.map((key) => key.publicJwk),
|
|
revokedKids,
|
|
assuranceMappings: [
|
|
{
|
|
acr: 'urn:ql3:mfa',
|
|
assurance: 'multi_factor',
|
|
requiredAmr: ['pwd', 'otp'],
|
|
},
|
|
],
|
|
constraints: {
|
|
maxAssertionBytes: 8 * 1024,
|
|
maxLifetimeMs: 5 * 60 * 1000,
|
|
maxAuthenticationAgeMs: 5 * 60 * 1000,
|
|
clockSkewMs: 5 * 1000,
|
|
},
|
|
});
|
|
}
|
|
|
|
function assertion(key, subject = 'operator-a', suffix = randomUUID()) {
|
|
const now = Math.floor(Date.now() / 1_000);
|
|
const header = Buffer.from(
|
|
JSON.stringify({
|
|
alg: 'EdDSA',
|
|
kid: key.kid,
|
|
typ: 'ql3-worker-credential-management+jwt',
|
|
}),
|
|
).toString('base64url');
|
|
const payload = Buffer.from(
|
|
JSON.stringify({
|
|
acr: 'urn:ql3:mfa',
|
|
amr: ['pwd', 'otp'],
|
|
aud: AUDIENCE,
|
|
auth_time: now - 1,
|
|
exp: now + 240,
|
|
iat: now,
|
|
iss: ISSUER,
|
|
jti: `ql3-worker-manager-live-${suffix}`,
|
|
ql3_purpose: 'worker-credential-management',
|
|
sub: subject,
|
|
}),
|
|
).toString('base64url');
|
|
const signed = `${header}.${payload}`;
|
|
return `${signed}.${sign(
|
|
null,
|
|
Buffer.from(signed, 'ascii'),
|
|
key.privateKey,
|
|
).toString('base64url')}`;
|
|
}
|
|
|
|
function planCommand(index, nowMs = Date.now()) {
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
operation: 'worker-credential.plan',
|
|
request: Object.freeze({
|
|
action: 'issue',
|
|
actionRef: `worker-credential:manager-live:plan-${index}`,
|
|
authorityProjectId: 'cluster-authority',
|
|
credentialExpiresAtMs: nowMs + 60 * 60_000,
|
|
credentialId: `manager_live_credential_${index}`,
|
|
credentialNotBeforeAtMs: nowMs + 5 * 60_000,
|
|
deliveryId: randomUUID(),
|
|
deploymentGeneration: `generation-manager_live_${index}`,
|
|
deploymentTargetDigest: 'a'.repeat(64),
|
|
previousCredentialId: null,
|
|
workerId: `manager-live-worker-${index}`,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function inspectCommand(actionRef, suffix = randomUUID()) {
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
operation: 'worker-credential.inspect',
|
|
request: Object.freeze({
|
|
actionRef,
|
|
authorityProjectId: 'cluster-authority',
|
|
approvalRequestId: `approval-manager-live-${suffix}`,
|
|
inspectionId: `inspection-manager-live-${suffix}`,
|
|
}),
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const docker = process.env.QL3_DOCKER_BIN || 'docker';
|
|
const kubectlBinary = process.env.QL3_KUBECTL_BIN || 'kubectl';
|
|
const suffix = process.pid.toString(36);
|
|
const network = `ql3-wcm-live-${suffix}`;
|
|
const server = `ql3-wcm-server-${suffix}`;
|
|
const agents = [`ql3-wcm-agent-a-${suffix}`, `ql3-wcm-agent-b-${suffix}`];
|
|
const postgres = `ql3-wcm-postgres-${suffix}`;
|
|
const containers = [server, ...agents, postgres];
|
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-wcm-live-'));
|
|
const kubeconfig = path.join(temporary, 'kubeconfig');
|
|
const imageArchive = path.join(temporary, 'admin-image.tar');
|
|
const adminImage = `ql3-worker-credential-manager-live:${suffix}`;
|
|
const caKey = path.join(temporary, 'ca.key');
|
|
const caCertificate = path.join(temporary, 'ca.crt');
|
|
const serverKey = path.join(temporary, 'tls.key');
|
|
const serverRequest = path.join(temporary, 'tls.csr');
|
|
const serverCertificate = path.join(temporary, 'tls.crt');
|
|
const serverExtensions = path.join(temporary, 'server.ext');
|
|
const clientOldKey = path.join(temporary, 'client-old.key');
|
|
const clientOldRequest = path.join(temporary, 'client-old.csr');
|
|
const clientOldCertificate = path.join(temporary, 'client-old.crt');
|
|
const clientNewKey = path.join(temporary, 'client-new.key');
|
|
const clientNewRequest = path.join(temporary, 'client-new.csr');
|
|
const clientNewCertificate = path.join(temporary, 'client-new.crt');
|
|
const clientCertificateRevocationList = path.join(temporary, 'client.crl');
|
|
const caConfig = path.join(temporary, 'ca.cnf');
|
|
const caDatabase = path.join(temporary, 'ca.index');
|
|
const caSerial = path.join(temporary, 'ca.serial');
|
|
const caCrlNumber = path.join(temporary, 'ca.crlnumber');
|
|
const caNewCertificates = path.join(temporary, 'ca-new-certificates');
|
|
const clusterToken = randomBytes(32).toString('base64url');
|
|
let networkCreated = false;
|
|
const createdContainers = new Set();
|
|
let adminImageBuilt = false;
|
|
let migrationDatabase;
|
|
try {
|
|
run(docker, ['version'], { capture: true, quiet: true });
|
|
const k3sImage = JSON.parse(
|
|
run(docker, ['image', 'inspect', K3S_IMAGE], {
|
|
capture: true,
|
|
quiet: true,
|
|
}).stdout,
|
|
)[0];
|
|
assert.ok(k3sImage.RepoDigests.includes(`rancher/k3s@${K3S_DIGEST}`));
|
|
const postgresImage = JSON.parse(
|
|
run(docker, ['image', 'inspect', POSTGRES_IMAGE], {
|
|
capture: true,
|
|
quiet: true,
|
|
}).stdout,
|
|
)[0];
|
|
for (const name of containers) {
|
|
assert.equal(
|
|
run(docker, ['inspect', name], {
|
|
capture: true,
|
|
quiet: true,
|
|
allowFailure: true,
|
|
}).status,
|
|
1,
|
|
`refusing to reuse Docker container ${name}`,
|
|
);
|
|
}
|
|
assert.equal(
|
|
run(docker, ['network', 'inspect', network], {
|
|
capture: true,
|
|
quiet: true,
|
|
allowFailure: true,
|
|
}).status,
|
|
1,
|
|
'refusing to reuse Docker network',
|
|
);
|
|
run(docker, ['network', 'create', network], { capture: true, quiet: true });
|
|
networkCreated = true;
|
|
run(
|
|
docker,
|
|
[
|
|
'run',
|
|
'-d',
|
|
'--privileged',
|
|
'--network',
|
|
network,
|
|
'--name',
|
|
server,
|
|
'-p',
|
|
'127.0.0.1::6443',
|
|
K3S_IMAGE,
|
|
'server',
|
|
'--token',
|
|
clusterToken,
|
|
'--node-name',
|
|
server,
|
|
'--disable=traefik',
|
|
'--disable=servicelb',
|
|
'--write-kubeconfig-mode=600',
|
|
'--tls-san=127.0.0.1',
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
createdContainers.add(server);
|
|
await waitFor('K3s server readiness', 120_000, () => {
|
|
const result = run(
|
|
docker,
|
|
['exec', server, 'kubectl', 'get', '--raw=/readyz'],
|
|
{ capture: true, quiet: true, allowFailure: true },
|
|
);
|
|
return result.status === 0 && result.stdout === 'ok'
|
|
? { ready: true, value: true }
|
|
: { ready: false, fact: result.stderr || result.stdout };
|
|
});
|
|
for (const agent of agents) {
|
|
run(
|
|
docker,
|
|
[
|
|
'run',
|
|
'-d',
|
|
'--privileged',
|
|
'--network',
|
|
network,
|
|
'--name',
|
|
agent,
|
|
K3S_IMAGE,
|
|
'agent',
|
|
'--server',
|
|
`https://${server}:6443`,
|
|
'--token',
|
|
clusterToken,
|
|
'--node-name',
|
|
agent,
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
createdContainers.add(agent);
|
|
}
|
|
const port = run(docker, ['port', server, '6443/tcp'], {
|
|
capture: true,
|
|
quiet: true,
|
|
}).stdout;
|
|
assert.match(port, /^127\.0\.0\.1:\d+$/);
|
|
const config = run(
|
|
docker,
|
|
['exec', server, 'cat', '/etc/rancher/k3s/k3s.yaml'],
|
|
{ capture: true, quiet: true },
|
|
).stdout.replace('https://127.0.0.1:6443', `https://${port}`);
|
|
fs.writeFileSync(kubeconfig, `${config}\n`, { mode: 0o600, flag: 'wx' });
|
|
const kubectl = (args, options = {}) =>
|
|
run(kubectlBinary, ['--kubeconfig', kubeconfig, ...args], options);
|
|
const kubectlJson = (args) =>
|
|
JSON.parse(
|
|
kubectl([...args, '-o', 'json'], {
|
|
capture: true,
|
|
quiet: true,
|
|
}).stdout,
|
|
);
|
|
const apply = (manifest) =>
|
|
kubectl(['apply', '-f', '-'], {
|
|
input: `${JSON.stringify(manifest)}\n`,
|
|
capture: true,
|
|
quiet: true,
|
|
});
|
|
const create = (manifest) =>
|
|
kubectl(['create', '-f', '-'], {
|
|
input: `${JSON.stringify(manifest)}\n`,
|
|
capture: true,
|
|
quiet: true,
|
|
});
|
|
const loadOperationManifest = (name) =>
|
|
yaml.load(
|
|
fs.readFileSync(
|
|
path.join(
|
|
ROOT,
|
|
'deploy/kubernetes/ql3-cluster/operations/' +
|
|
'worker-credential-management-client/base',
|
|
name,
|
|
),
|
|
'utf8',
|
|
),
|
|
);
|
|
|
|
await waitFor('three K3s nodes', 180_000, () => {
|
|
const nodes = kubectlJson(['get', 'nodes']).items ?? [];
|
|
const ready = nodes.filter((node) =>
|
|
node.status.conditions?.some(
|
|
(condition) =>
|
|
condition.type === 'Ready' && condition.status === 'True',
|
|
),
|
|
);
|
|
return ready.length === 3
|
|
? { ready: true, value: ready }
|
|
: { ready: false, fact: `${ready.length}/3 Ready nodes` };
|
|
});
|
|
|
|
const sourceRevision = run('git', ['rev-parse', 'HEAD'], {
|
|
capture: true,
|
|
quiet: true,
|
|
}).stdout;
|
|
run(docker, [
|
|
'build',
|
|
'--file',
|
|
'deploy/containers/ql3-cluster-admin/Dockerfile',
|
|
'--tag',
|
|
adminImage,
|
|
'--build-arg',
|
|
`SOURCE_REVISION=${sourceRevision}`,
|
|
'.',
|
|
]);
|
|
adminImageBuilt = true;
|
|
run(docker, ['image', 'save', '--output', imageArchive, adminImage]);
|
|
for (const node of [server, ...agents]) {
|
|
run(docker, ['cp', imageArchive, `${node}:/tmp/ql3-admin.tar`], {
|
|
capture: true,
|
|
quiet: true,
|
|
});
|
|
run(
|
|
docker,
|
|
[
|
|
'exec',
|
|
node,
|
|
'ctr',
|
|
'--address',
|
|
'/run/k3s/containerd/containerd.sock',
|
|
'--namespace',
|
|
'k8s.io',
|
|
'images',
|
|
'import',
|
|
'/tmp/ql3-admin.tar',
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
run(docker, ['exec', node, 'rm', '-f', '/tmp/ql3-admin.tar'], {
|
|
capture: true,
|
|
quiet: true,
|
|
});
|
|
}
|
|
fs.unlinkSync(imageArchive);
|
|
|
|
fs.writeFileSync(
|
|
serverExtensions,
|
|
[
|
|
'basicConstraints=CA:FALSE',
|
|
'keyUsage=digitalSignature,keyEncipherment',
|
|
'extendedKeyUsage=serverAuth',
|
|
`subjectAltName=DNS:${SERVERNAME},DNS:${SERVERNAME}.cluster.local`,
|
|
'',
|
|
].join('\n'),
|
|
{ mode: 0o600, flag: 'wx' },
|
|
);
|
|
run(
|
|
'openssl',
|
|
[
|
|
'req',
|
|
'-x509',
|
|
'-newkey',
|
|
'rsa:2048',
|
|
'-nodes',
|
|
'-days',
|
|
'1',
|
|
'-subj',
|
|
'/CN=QL3 Worker Manager Live CA',
|
|
'-keyout',
|
|
caKey,
|
|
'-out',
|
|
caCertificate,
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
fs.mkdirSync(caNewCertificates, { mode: 0o700 });
|
|
fs.writeFileSync(caDatabase, '', { mode: 0o600, flag: 'wx' });
|
|
fs.writeFileSync(caSerial, '1000\n', { mode: 0o600, flag: 'wx' });
|
|
fs.writeFileSync(caCrlNumber, '1000\n', { mode: 0o600, flag: 'wx' });
|
|
fs.writeFileSync(
|
|
caConfig,
|
|
[
|
|
'[ca]',
|
|
'default_ca=client_ca',
|
|
'[client_ca]',
|
|
`database=${caDatabase}`,
|
|
`new_certs_dir=${caNewCertificates}`,
|
|
`certificate=${caCertificate}`,
|
|
`private_key=${caKey}`,
|
|
`serial=${caSerial}`,
|
|
`crlnumber=${caCrlNumber}`,
|
|
'default_md=sha256',
|
|
'default_days=1',
|
|
'default_crl_days=1',
|
|
'policy=client_policy',
|
|
'unique_subject=no',
|
|
'copy_extensions=none',
|
|
'[client_policy]',
|
|
'commonName=supplied',
|
|
'[client_certificate]',
|
|
'basicConstraints=critical,CA:FALSE',
|
|
'keyUsage=critical,digitalSignature,keyEncipherment',
|
|
'extendedKeyUsage=clientAuth',
|
|
'',
|
|
].join('\n'),
|
|
{ mode: 0o600, flag: 'wx' },
|
|
);
|
|
for (const [commonName, keyFile, requestFile, certificateFile] of [
|
|
[
|
|
'ql3-worker-credential-management-client-old',
|
|
clientOldKey,
|
|
clientOldRequest,
|
|
clientOldCertificate,
|
|
],
|
|
[
|
|
'ql3-worker-credential-management-client-new',
|
|
clientNewKey,
|
|
clientNewRequest,
|
|
clientNewCertificate,
|
|
],
|
|
]) {
|
|
run(
|
|
'openssl',
|
|
[
|
|
'req',
|
|
'-newkey',
|
|
'rsa:2048',
|
|
'-nodes',
|
|
'-subj',
|
|
`/CN=${commonName}`,
|
|
'-keyout',
|
|
keyFile,
|
|
'-out',
|
|
requestFile,
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
run(
|
|
'openssl',
|
|
[
|
|
'ca',
|
|
'-batch',
|
|
'-notext',
|
|
'-config',
|
|
caConfig,
|
|
'-extensions',
|
|
'client_certificate',
|
|
'-in',
|
|
requestFile,
|
|
'-out',
|
|
certificateFile,
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
}
|
|
run(
|
|
'openssl',
|
|
[
|
|
'ca',
|
|
'-gencrl',
|
|
'-config',
|
|
caConfig,
|
|
'-out',
|
|
clientCertificateRevocationList,
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
run(
|
|
'openssl',
|
|
[
|
|
'req',
|
|
'-newkey',
|
|
'rsa:2048',
|
|
'-nodes',
|
|
'-subj',
|
|
`/CN=${SERVERNAME}`,
|
|
'-keyout',
|
|
serverKey,
|
|
'-out',
|
|
serverRequest,
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
run(
|
|
'openssl',
|
|
[
|
|
'x509',
|
|
'-req',
|
|
'-days',
|
|
'1',
|
|
'-in',
|
|
serverRequest,
|
|
'-CA',
|
|
caCertificate,
|
|
'-CAkey',
|
|
caKey,
|
|
'-CAcreateserial',
|
|
'-extfile',
|
|
serverExtensions,
|
|
'-out',
|
|
serverCertificate,
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
|
|
run(
|
|
docker,
|
|
[
|
|
'run',
|
|
'-d',
|
|
'--network',
|
|
network,
|
|
'--name',
|
|
postgres,
|
|
'-p',
|
|
'127.0.0.1::5432',
|
|
'-e',
|
|
`POSTGRES_DB=${POSTGRES_DATABASE}`,
|
|
'-e',
|
|
'POSTGRES_USER=postgres',
|
|
'-e',
|
|
`POSTGRES_PASSWORD=${POSTGRES_SUPERUSER_PASSWORD}`,
|
|
POSTGRES_IMAGE,
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
createdContainers.add(postgres);
|
|
await waitFor('PostgreSQL final TCP readiness', 60_000, () => {
|
|
const result = run(
|
|
docker,
|
|
[
|
|
'exec',
|
|
postgres,
|
|
'pg_isready',
|
|
'-h',
|
|
'127.0.0.1',
|
|
'-U',
|
|
'postgres',
|
|
'-d',
|
|
POSTGRES_DATABASE,
|
|
],
|
|
{ capture: true, quiet: true, allowFailure: true },
|
|
);
|
|
return result.status === 0
|
|
? { ready: true, value: true }
|
|
: { ready: false, fact: result.stderr || result.stdout };
|
|
});
|
|
const postgresEndpoint = run(docker, ['port', postgres, '5432/tcp'], {
|
|
capture: true,
|
|
quiet: true,
|
|
}).stdout;
|
|
assert.match(postgresEndpoint, /^127\.0\.0\.1:\d+$/);
|
|
const postgresPort = Number(
|
|
postgresEndpoint.slice(postgresEndpoint.lastIndexOf(':') + 1),
|
|
);
|
|
const postgresAddress = run(
|
|
docker,
|
|
[
|
|
'inspect',
|
|
'--format',
|
|
'{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}',
|
|
postgres,
|
|
],
|
|
{ capture: true, quiet: true },
|
|
).stdout;
|
|
assert.match(postgresAddress, /^\d{1,3}(?:\.\d{1,3}){3}$/);
|
|
|
|
const { createPostgresDatabaseOpener } = clusterRequire(
|
|
'@qinglong/cluster-postgres/worker-credential-manager',
|
|
);
|
|
const { runPostgresMigrations } = clusterRequire(
|
|
'@qinglong/cluster-postgres/migration',
|
|
);
|
|
const databaseUrl = ({ user, password }) =>
|
|
`postgresql://${encodeURIComponent(user)}:${encodeURIComponent(
|
|
password,
|
|
)}` + `@127.0.0.1:${postgresPort}/${POSTGRES_DATABASE}`;
|
|
const openDatabase = (role, credential, applicationName) =>
|
|
createPostgresDatabaseOpener({
|
|
role,
|
|
connection: {
|
|
connectionString: databaseUrl(credential),
|
|
tls: { mode: 'disable' },
|
|
},
|
|
pool: {
|
|
applicationName,
|
|
maxConnections: 1,
|
|
connectionTimeoutMs: 2_000,
|
|
},
|
|
onPoolError() {},
|
|
});
|
|
const openMigrationDatabase = openDatabase(
|
|
'migration',
|
|
POSTGRES_ROLES.migration,
|
|
'ql3-worker-manager-live-migration',
|
|
);
|
|
const bootstrap = await openDatabase(
|
|
'migration',
|
|
{ user: 'postgres', password: POSTGRES_SUPERUSER_PASSWORD },
|
|
'ql3-worker-manager-live-bootstrap',
|
|
)();
|
|
try {
|
|
for (const credential of Object.values(POSTGRES_ROLES)) {
|
|
await bootstrap.pool.query(
|
|
`CREATE ROLE ${credential.user} LOGIN PASSWORD '${credential.password}'`,
|
|
);
|
|
}
|
|
await bootstrap.pool.query(
|
|
`ALTER DATABASE ${POSTGRES_DATABASE} OWNER TO ` +
|
|
POSTGRES_ROLES.migration.user,
|
|
);
|
|
} finally {
|
|
await bootstrap.close();
|
|
}
|
|
migrationDatabase = await openMigrationDatabase();
|
|
await runPostgresMigrations({ pool: migrationDatabase.pool });
|
|
const seededAtMs = Date.now();
|
|
await migrationDatabase.pool.query(
|
|
`INSERT INTO "ql3"."projects" (
|
|
id, name, slug, status, version, created_at_ms, updated_at_ms
|
|
) VALUES (
|
|
'cluster-authority', 'Cluster Authority', 'cluster-authority',
|
|
'active', 1, $1, $1
|
|
)`,
|
|
[seededAtMs],
|
|
);
|
|
await migrationDatabase.pool.query(
|
|
`INSERT INTO "ql3"."project_role_bindings" (
|
|
project_id, subject_type, subject_id, version, state, role,
|
|
mutation_id, changed_by_type, changed_by_id, created_at_ms
|
|
) VALUES (
|
|
'cluster-authority', 'user', 'operator-a', 1, 'active', 'admin',
|
|
'binding-operator-a-v1', 'user', 'owner-a', $1
|
|
)`,
|
|
[seededAtMs],
|
|
);
|
|
|
|
const oldKey = reviewedKey('worker-manager-key-1');
|
|
const newKey = reviewedKey('worker-manager-key-2');
|
|
const keyset1 = keyset(1, [oldKey]);
|
|
const keyset2 = keyset(2, [oldKey, newKey]);
|
|
const keyset3 = keyset(3, [oldKey, newKey], [oldKey.kid]);
|
|
const caText = fs.readFileSync(caCertificate, 'utf8');
|
|
const tlsCertificateText = fs.readFileSync(serverCertificate, 'utf8');
|
|
const tlsKeyText = fs.readFileSync(serverKey, 'utf8');
|
|
const clientOldCertificateText = fs.readFileSync(
|
|
clientOldCertificate,
|
|
'utf8',
|
|
);
|
|
const clientOldKeyText = fs.readFileSync(clientOldKey, 'utf8');
|
|
const clientNewCertificateText = fs.readFileSync(
|
|
clientNewCertificate,
|
|
'utf8',
|
|
);
|
|
const clientNewKeyText = fs.readFileSync(clientNewKey, 'utf8');
|
|
let activeClientCertificateText = clientOldCertificateText;
|
|
let activeClientKeyText = clientOldKeyText;
|
|
let clientCrlText = fs.readFileSync(
|
|
clientCertificateRevocationList,
|
|
'utf8',
|
|
);
|
|
const managerUrl =
|
|
`postgresql://${encodeURIComponent(
|
|
POSTGRES_ROLES.workerCredentialManager.user,
|
|
)}` +
|
|
`:${encodeURIComponent(
|
|
POSTGRES_ROLES.workerCredentialManager.password,
|
|
)}` +
|
|
`@${postgresAddress}:5432/${POSTGRES_DATABASE}`;
|
|
|
|
apply({
|
|
apiVersion: 'v1',
|
|
kind: 'Namespace',
|
|
metadata: { name: NAMESPACE },
|
|
});
|
|
apply({
|
|
apiVersion: 'v1',
|
|
kind: 'Secret',
|
|
metadata: { name: TLS_SECRET, namespace: NAMESPACE },
|
|
type: 'kubernetes.io/tls',
|
|
stringData: {
|
|
'tls.crt': tlsCertificateText,
|
|
'tls.key': tlsKeyText,
|
|
'ca.crt': caText,
|
|
'client.crl': clientCrlText,
|
|
},
|
|
});
|
|
const applyIdentity = (document) =>
|
|
apply({
|
|
apiVersion: 'v1',
|
|
kind: 'Secret',
|
|
metadata: { name: IDENTITY_SECRET, namespace: NAMESPACE },
|
|
type: 'Opaque',
|
|
stringData: { 'keyset.json': `${JSON.stringify(document)}\n` },
|
|
});
|
|
applyIdentity(keyset1);
|
|
apply({
|
|
apiVersion: 'v1',
|
|
kind: 'Secret',
|
|
metadata: { name: DATABASE_SECRET, namespace: NAMESPACE },
|
|
type: 'Opaque',
|
|
stringData: { 'postgres-worker-credential-manager-url': managerUrl },
|
|
});
|
|
apply({
|
|
apiVersion: 'v1',
|
|
kind: 'ServiceAccount',
|
|
metadata: { name: DEPLOYMENT, namespace: NAMESPACE },
|
|
automountServiceAccountToken: false,
|
|
});
|
|
apply({
|
|
apiVersion: 'v1',
|
|
kind: 'Service',
|
|
metadata: { name: SERVICE, namespace: NAMESPACE },
|
|
spec: {
|
|
type: 'ClusterIP',
|
|
selector: {
|
|
'app.kubernetes.io/name': DEPLOYMENT,
|
|
'app.kubernetes.io/component': 'worker-credential-management',
|
|
},
|
|
ports: [
|
|
{ name: 'https', port: 8444, targetPort: 'https', protocol: 'TCP' },
|
|
],
|
|
},
|
|
});
|
|
apply({
|
|
apiVersion: 'policy/v1',
|
|
kind: 'PodDisruptionBudget',
|
|
metadata: { name: DEPLOYMENT, namespace: NAMESPACE },
|
|
spec: {
|
|
minAvailable: 1,
|
|
selector: {
|
|
matchLabels: {
|
|
'app.kubernetes.io/name': DEPLOYMENT,
|
|
'app.kubernetes.io/component': 'worker-credential-management',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
apply({
|
|
apiVersion: 'networking.k8s.io/v1',
|
|
kind: 'NetworkPolicy',
|
|
metadata: { name: DEPLOYMENT, namespace: NAMESPACE },
|
|
spec: {
|
|
podSelector: {
|
|
matchLabels: {
|
|
'app.kubernetes.io/name': DEPLOYMENT,
|
|
'app.kubernetes.io/component': 'worker-credential-management',
|
|
},
|
|
},
|
|
policyTypes: ['Ingress', 'Egress'],
|
|
ingress: [
|
|
{
|
|
from: [
|
|
{
|
|
podSelector: {
|
|
matchLabels: {
|
|
'qinglong.io/worker-credential-management-client': 'true',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
ports: [{ protocol: 'TCP', port: 8444 }],
|
|
},
|
|
],
|
|
egress: [
|
|
{
|
|
to: [{ ipBlock: { cidr: `${postgresAddress}/32` } }],
|
|
ports: [{ protocol: 'TCP', port: 5432 }],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
apply({
|
|
apiVersion: 'apps/v1',
|
|
kind: 'Deployment',
|
|
metadata: { name: DEPLOYMENT, namespace: NAMESPACE },
|
|
spec: {
|
|
replicas: 2,
|
|
minReadySeconds: 2,
|
|
revisionHistoryLimit: 3,
|
|
progressDeadlineSeconds: 300,
|
|
strategy: {
|
|
type: 'RollingUpdate',
|
|
rollingUpdate: { maxUnavailable: 0, maxSurge: 1 },
|
|
},
|
|
selector: {
|
|
matchLabels: {
|
|
'app.kubernetes.io/name': DEPLOYMENT,
|
|
'app.kubernetes.io/component': 'worker-credential-management',
|
|
},
|
|
},
|
|
template: {
|
|
metadata: {
|
|
labels: {
|
|
'app.kubernetes.io/name': DEPLOYMENT,
|
|
'app.kubernetes.io/component': 'worker-credential-management',
|
|
'app.kubernetes.io/part-of': 'qinglong3',
|
|
},
|
|
annotations: { 'qinglong.io/identity-generation': '1' },
|
|
},
|
|
spec: {
|
|
serviceAccountName: DEPLOYMENT,
|
|
automountServiceAccountToken: false,
|
|
terminationGracePeriodSeconds: 10,
|
|
securityContext: {
|
|
runAsNonRoot: true,
|
|
runAsUser: 10001,
|
|
runAsGroup: 10001,
|
|
fsGroup: 10001,
|
|
seccompProfile: { type: 'RuntimeDefault' },
|
|
},
|
|
affinity: {
|
|
podAntiAffinity: {
|
|
requiredDuringSchedulingIgnoredDuringExecution: [
|
|
{
|
|
topologyKey: 'kubernetes.io/hostname',
|
|
labelSelector: {
|
|
matchLabels: {
|
|
'app.kubernetes.io/name': DEPLOYMENT,
|
|
'app.kubernetes.io/component':
|
|
'worker-credential-management',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
containers: [
|
|
{
|
|
name: 'management',
|
|
image: adminImage,
|
|
imagePullPolicy: 'Never',
|
|
command: [
|
|
'node',
|
|
'/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/' +
|
|
'worker-credential/management-server/workerCredentialManagementCli.js',
|
|
],
|
|
securityContext: {
|
|
allowPrivilegeEscalation: false,
|
|
readOnlyRootFilesystem: true,
|
|
capabilities: { drop: ['ALL'] },
|
|
},
|
|
env: [
|
|
{ name: 'QL3_PROFILE', value: 'cluster-admin' },
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_ENABLED',
|
|
value: 'true',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_HOST',
|
|
value: '0.0.0.0',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_PORT',
|
|
value: '8444',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_TLS_CERT_FILE',
|
|
value: '/var/run/secrets/qinglong3/management-tls/tls.crt',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_TLS_KEY_FILE',
|
|
value: '/var/run/secrets/qinglong3/management-tls/tls.key',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_CLIENT_CA_FILE',
|
|
value: '/var/run/secrets/qinglong3/management-tls/ca.crt',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_CLIENT_CRL_FILE',
|
|
value:
|
|
'/var/run/secrets/qinglong3/management-tls/client.crl',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_IDENTITY_KEYSET_FILE',
|
|
value: '/var/run/qinglong3/management-identity/keyset.json',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_QUOTA_WINDOW_MS',
|
|
value: '300000',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_PLAN_QUOTA',
|
|
value: '8',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_PROPOSE_QUOTA',
|
|
value: '32',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_DECIDE_QUOTA',
|
|
value: '32',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_INSPECT_QUOTA',
|
|
value: '128',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_MAX_CONNECTIONS',
|
|
value: '64',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_MAX_CONCURRENT_REQUESTS',
|
|
value: '32',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_RATE_WINDOW_MS',
|
|
value: '300000',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_PEER_REQUEST_LIMIT',
|
|
value: '128',
|
|
},
|
|
{
|
|
name: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_GLOBAL_REQUEST_LIMIT',
|
|
value: '10000',
|
|
},
|
|
{
|
|
name: 'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_URL',
|
|
valueFrom: {
|
|
secretKeyRef: {
|
|
name: DATABASE_SECRET,
|
|
key: 'postgres-worker-credential-manager-url',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_TLS_MODE',
|
|
value: 'disable',
|
|
},
|
|
{
|
|
name: 'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_ALLOW_INSECURE',
|
|
value: 'true',
|
|
},
|
|
{
|
|
name: 'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_MAX_CONNECTIONS',
|
|
value: '2',
|
|
},
|
|
{
|
|
name: 'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_APPLICATION_NAME',
|
|
value: 'ql3-worker-manager-live',
|
|
},
|
|
],
|
|
ports: [
|
|
{ name: 'https', containerPort: 8444, protocol: 'TCP' },
|
|
],
|
|
startupProbe: {
|
|
httpGet: { path: '/livez', port: 'https', scheme: 'HTTPS' },
|
|
periodSeconds: 2,
|
|
timeoutSeconds: 1,
|
|
failureThreshold: 45,
|
|
},
|
|
readinessProbe: {
|
|
httpGet: { path: '/readyz', port: 'https', scheme: 'HTTPS' },
|
|
periodSeconds: 2,
|
|
timeoutSeconds: 1,
|
|
failureThreshold: 2,
|
|
},
|
|
livenessProbe: {
|
|
httpGet: { path: '/livez', port: 'https', scheme: 'HTTPS' },
|
|
periodSeconds: 10,
|
|
timeoutSeconds: 2,
|
|
failureThreshold: 3,
|
|
},
|
|
resources: {
|
|
requests: { cpu: '50m', memory: '96Mi' },
|
|
limits: { cpu: '1', memory: '384Mi' },
|
|
},
|
|
volumeMounts: [
|
|
{ name: 'tmp', mountPath: '/tmp' },
|
|
{
|
|
name: 'management-tls',
|
|
mountPath: '/var/run/secrets/qinglong3/management-tls',
|
|
readOnly: true,
|
|
},
|
|
{
|
|
name: 'management-identity',
|
|
mountPath: '/var/run/qinglong3/management-identity',
|
|
readOnly: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
volumes: [
|
|
{
|
|
name: 'tmp',
|
|
emptyDir: { medium: 'Memory', sizeLimit: '16Mi' },
|
|
},
|
|
{
|
|
name: 'management-tls',
|
|
secret: {
|
|
secretName: TLS_SECRET,
|
|
defaultMode: 288,
|
|
items: [
|
|
{ key: 'tls.crt', path: 'tls.crt' },
|
|
{ key: 'tls.key', path: 'tls.key' },
|
|
{ key: 'ca.crt', path: 'ca.crt' },
|
|
{ key: 'client.crl', path: 'client.crl' },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: 'management-identity',
|
|
secret: {
|
|
secretName: IDENTITY_SECRET,
|
|
defaultMode: 292,
|
|
items: [{ key: 'keyset.json', path: 'keyset.json' }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const readyManagerPods = async (excludedUids = new Set()) =>
|
|
waitFor('two Ready manager Pods on distinct nodes', 180_000, () => {
|
|
const pods = kubectlJson([
|
|
'-n',
|
|
NAMESPACE,
|
|
'get',
|
|
'pods',
|
|
'-l',
|
|
`app.kubernetes.io/name=${DEPLOYMENT}`,
|
|
]).items.filter(
|
|
(pod) =>
|
|
pod.metadata.deletionTimestamp === undefined &&
|
|
!excludedUids.has(pod.metadata.uid) &&
|
|
pod.status.conditions?.some(
|
|
(condition) =>
|
|
condition.type === 'Ready' && condition.status === 'True',
|
|
),
|
|
);
|
|
const nodes = new Set(pods.map((pod) => pod.spec.nodeName));
|
|
return pods.length === 2 && nodes.size === 2
|
|
? {
|
|
ready: true,
|
|
value: pods.sort((a, b) =>
|
|
a.metadata.name.localeCompare(b.metadata.name),
|
|
),
|
|
}
|
|
: {
|
|
ready: false,
|
|
fact: `${pods.length} Ready Pods on ${nodes.size} nodes`,
|
|
};
|
|
});
|
|
const patchGeneration = (generation) =>
|
|
kubectl(
|
|
[
|
|
'-n',
|
|
NAMESPACE,
|
|
'patch',
|
|
'deployment',
|
|
DEPLOYMENT,
|
|
'--type=merge',
|
|
'-p',
|
|
JSON.stringify({
|
|
spec: {
|
|
template: {
|
|
metadata: {
|
|
annotations: {
|
|
'qinglong.io/identity-generation': generation,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
const waitRollout = () =>
|
|
kubectl(
|
|
[
|
|
'-n',
|
|
NAMESPACE,
|
|
'rollout',
|
|
'status',
|
|
`deployment/${DEPLOYMENT}`,
|
|
'--timeout=180s',
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
|
|
waitRollout();
|
|
const generation1Pods = await readyManagerPods();
|
|
assert.equal(
|
|
new Set(generation1Pods.map((pod) => pod.spec.nodeName)).size,
|
|
2,
|
|
);
|
|
for (const pod of generation1Pods) {
|
|
assert.equal(pod.spec.automountServiceAccountToken, false);
|
|
assert.equal(pod.spec.serviceAccountName, DEPLOYMENT);
|
|
assert.equal(
|
|
pod.spec.volumes.some((volume) =>
|
|
volume.projected?.sources?.some(
|
|
(source) => source.serviceAccountToken !== undefined,
|
|
),
|
|
),
|
|
false,
|
|
);
|
|
}
|
|
|
|
const clientShell = [
|
|
'umask 077',
|
|
'cp /var/run/qinglong3/client/client.json /tmp/client.json',
|
|
'cp /var/run/qinglong3/client/command.json /tmp/command.json',
|
|
'cp /var/run/qinglong3/client/assertion.jwt /tmp/assertion.jwt',
|
|
'cp /var/run/qinglong3/client/ca.crt /tmp/ca.crt',
|
|
'cp /var/run/qinglong3/client/client.crt /tmp/client.crt',
|
|
'cp /var/run/qinglong3/client/client.key /tmp/client.key',
|
|
'chmod 600 /tmp/client.json /tmp/command.json /tmp/assertion.jwt ' +
|
|
'/tmp/ca.crt /tmp/client.crt /tmp/client.key',
|
|
'set +e',
|
|
'attempt=0',
|
|
'while true; do',
|
|
' attempt=$((attempt + 1))',
|
|
' output="$(node /opt/qinglong/node_modules/@qinglong/cluster-admin/dist/' +
|
|
'worker-credential/workerCredentialManagementClientCli.js ' +
|
|
'--config=/tmp/client.json --command=/tmp/command.json ' +
|
|
'--assertion=/tmp/assertion.jwt 2>&1)"',
|
|
' status=$?',
|
|
' if [ "$status" -eq 0 ] || ! printf \'%s\' "$output" | ' +
|
|
'grep -q QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_REQUEST_FAILED || ' +
|
|
'[ "$attempt" -ge 12 ]; then',
|
|
' break',
|
|
' fi',
|
|
' sleep 1',
|
|
'done',
|
|
'if [ "$status" -ne 0 ] && printf \'%s\' "$output" | ' +
|
|
'grep -q QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_REQUEST_FAILED; then',
|
|
' diagnostic="$(node -e "$QL3_RAW_REQUEST_DIAGNOSTIC_SCRIPT" -- ' +
|
|
'/tmp/client.json /tmp/command.json /tmp/assertion.jwt 2>&1)"',
|
|
' output="{\\"client\\":$output,\\"diagnostic\\":$diagnostic}"',
|
|
'fi',
|
|
'printf \'{"transportAttempts":%s}\\n%s\\n\' ' +
|
|
'"$attempt" "$output" > /dev/termination-log',
|
|
'printf \'%s\\n\' "$output"',
|
|
'exit "$status"',
|
|
].join('\n');
|
|
const clientConfig = Object.freeze({
|
|
schemaVersion: 1,
|
|
endpoint: `https://${SERVERNAME}:8444${MANAGEMENT_PATH}`,
|
|
servername: SERVERNAME,
|
|
caFile: '/tmp/ca.crt',
|
|
clientCertificateFile: '/tmp/client.crt',
|
|
clientPrivateKeyFile: '/tmp/client.key',
|
|
requestTimeoutMs: 15_000,
|
|
});
|
|
const createClientJob = ({
|
|
name,
|
|
target,
|
|
command,
|
|
bearer,
|
|
clientCertificateText = activeClientCertificateText,
|
|
clientKeyText = activeClientKeyText,
|
|
}) => {
|
|
const inputSecret = `${name}-input`;
|
|
create({
|
|
apiVersion: 'v1',
|
|
kind: 'Secret',
|
|
metadata: { name: inputSecret, namespace: NAMESPACE },
|
|
type: 'Opaque',
|
|
stringData: {
|
|
'client.json': `${JSON.stringify(clientConfig)}\n`,
|
|
'command.json': `${JSON.stringify(command)}\n`,
|
|
'assertion.jwt': bearer,
|
|
'ca.crt': caText,
|
|
'client.crt': clientCertificateText,
|
|
'client.key': clientKeyText,
|
|
},
|
|
});
|
|
create({
|
|
apiVersion: 'batch/v1',
|
|
kind: 'Job',
|
|
metadata: { name, namespace: NAMESPACE },
|
|
spec: {
|
|
backoffLimit: 0,
|
|
activeDeadlineSeconds: 60,
|
|
ttlSecondsAfterFinished: 600,
|
|
template: {
|
|
metadata: {
|
|
labels: {
|
|
app: 'ql3-worker-manager-live-client',
|
|
'qinglong.io/worker-credential-management-client': 'true',
|
|
},
|
|
},
|
|
spec: {
|
|
automountServiceAccountToken: false,
|
|
restartPolicy: 'Never',
|
|
hostAliases: [
|
|
{ ip: target.status.podIP, hostnames: [SERVERNAME] },
|
|
],
|
|
securityContext: {
|
|
runAsNonRoot: true,
|
|
runAsUser: 10001,
|
|
runAsGroup: 10001,
|
|
fsGroup: 10001,
|
|
seccompProfile: { type: 'RuntimeDefault' },
|
|
},
|
|
containers: [
|
|
{
|
|
name: 'client',
|
|
image: adminImage,
|
|
imagePullPolicy: 'Never',
|
|
command: ['/bin/sh', '-c'],
|
|
args: [clientShell],
|
|
env: [
|
|
{
|
|
name: 'QL3_RAW_REQUEST_DIAGNOSTIC_SCRIPT',
|
|
value: RAW_REQUEST_DIAGNOSTIC_SCRIPT,
|
|
},
|
|
],
|
|
terminationMessagePolicy: 'File',
|
|
securityContext: {
|
|
allowPrivilegeEscalation: false,
|
|
readOnlyRootFilesystem: true,
|
|
capabilities: { drop: ['ALL'] },
|
|
},
|
|
resources: {
|
|
requests: { cpu: '10m', memory: '32Mi' },
|
|
limits: { cpu: '250m', memory: '128Mi' },
|
|
},
|
|
volumeMounts: [
|
|
{ name: 'tmp', mountPath: '/tmp' },
|
|
{
|
|
name: 'client-input',
|
|
mountPath: '/var/run/qinglong3/client',
|
|
readOnly: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
volumes: [
|
|
{
|
|
name: 'tmp',
|
|
emptyDir: { medium: 'Memory', sizeLimit: '4Mi' },
|
|
},
|
|
{
|
|
name: 'client-input',
|
|
secret: { secretName: inputSecret, defaultMode: 288 },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
return Object.freeze({ name, target, command, bearer });
|
|
};
|
|
const waitClientJob = async (job, expected) => {
|
|
const completion = await waitFor(
|
|
`${job.name} completion`,
|
|
120_000,
|
|
() => {
|
|
const current = kubectlJson([
|
|
'-n',
|
|
NAMESPACE,
|
|
'get',
|
|
'job',
|
|
job.name,
|
|
]);
|
|
const complete = current.status.conditions?.some(
|
|
(condition) =>
|
|
condition.type === 'Complete' && condition.status === 'True',
|
|
);
|
|
const failed = current.status.conditions?.some(
|
|
(condition) =>
|
|
condition.type === 'Failed' && condition.status === 'True',
|
|
);
|
|
return complete || failed
|
|
? { ready: true, value: { complete, failed } }
|
|
: { ready: false, fact: JSON.stringify(current.status ?? {}) };
|
|
},
|
|
);
|
|
const pods = kubectlJson([
|
|
'-n',
|
|
NAMESPACE,
|
|
'get',
|
|
'pods',
|
|
'-l',
|
|
`job-name=${job.name}`,
|
|
]).items;
|
|
assert.equal(pods.length, 1);
|
|
const terminated =
|
|
pods[0].status.containerStatuses?.[0]?.state?.terminated;
|
|
const outputText = terminated?.message ?? '';
|
|
assert.equal(outputText.includes(job.bearer), false);
|
|
assert.equal(
|
|
outputText.includes(POSTGRES_ROLES.workerCredentialManager.password),
|
|
false,
|
|
);
|
|
const outputLines = outputText.split('\n').filter(Boolean);
|
|
const attemptFact = JSON.parse(outputLines[0]);
|
|
assert.ok(Number.isSafeInteger(attemptFact.transportAttempts));
|
|
assert.ok(attemptFact.transportAttempts >= 1);
|
|
assert.ok(attemptFact.transportAttempts <= 12);
|
|
const output = JSON.parse(outputLines.at(-1));
|
|
const targetFailureEvidence = () => {
|
|
const state = kubectl(
|
|
[
|
|
'-n',
|
|
NAMESPACE,
|
|
'get',
|
|
'pod',
|
|
job.target.metadata.name,
|
|
'-o',
|
|
'json',
|
|
],
|
|
{ capture: true, quiet: true, allowFailure: true },
|
|
);
|
|
const currentLogs = kubectl(
|
|
[
|
|
'-n',
|
|
NAMESPACE,
|
|
'logs',
|
|
job.target.metadata.name,
|
|
'--container=management',
|
|
'--tail=80',
|
|
],
|
|
{ capture: true, quiet: true, allowFailure: true },
|
|
);
|
|
const previousLogs = kubectl(
|
|
[
|
|
'-n',
|
|
NAMESPACE,
|
|
'logs',
|
|
job.target.metadata.name,
|
|
'--container=management',
|
|
'--previous',
|
|
'--tail=80',
|
|
],
|
|
{ capture: true, quiet: true, allowFailure: true },
|
|
);
|
|
return Object.freeze({
|
|
targetPod: job.target.metadata.name,
|
|
expectedUid: job.target.metadata.uid,
|
|
state: state.status === 0 ? JSON.parse(state.stdout) : state.stderr,
|
|
currentLogs: currentLogs.stdout || currentLogs.stderr,
|
|
previousLogs: previousLogs.stdout || previousLogs.stderr,
|
|
});
|
|
};
|
|
if (expected.statusCode === 200) {
|
|
if (!completion.complete || terminated?.exitCode !== 0) {
|
|
throw new Error(
|
|
`${job.name} unexpectedly failed: ${JSON.stringify({
|
|
output,
|
|
target: targetFailureEvidence(),
|
|
})}`,
|
|
);
|
|
}
|
|
assert.equal(completion.complete, true);
|
|
assert.equal(terminated?.exitCode, 0);
|
|
assert.equal(output.event, 'command_completed');
|
|
assert.equal(output.result.operation, job.command.operation);
|
|
if (expected.resultStatus !== undefined) {
|
|
assert.equal(
|
|
Array.isArray(expected.resultStatus)
|
|
? expected.resultStatus.includes(output.result.status)
|
|
: output.result.status === expected.resultStatus,
|
|
true,
|
|
);
|
|
}
|
|
} else {
|
|
if (!completion.failed || terminated?.exitCode !== 1) {
|
|
throw new Error(
|
|
`${job.name} unexpectedly succeeded: ${JSON.stringify(output)}`,
|
|
);
|
|
}
|
|
assert.equal(completion.failed, true);
|
|
assert.equal(terminated?.exitCode, 1);
|
|
assert.equal(output.event, 'command_failed');
|
|
assert.equal(output.statusCode, expected.statusCode);
|
|
assert.equal(output.responseCode, expected.responseCode);
|
|
}
|
|
return Object.freeze({
|
|
name: job.name,
|
|
targetPod: job.target.metadata.name,
|
|
targetPodUid: job.target.metadata.uid,
|
|
targetNode: job.target.spec.nodeName,
|
|
statusCode: expected.statusCode,
|
|
transportAttempts: attemptFact.transportAttempts,
|
|
output,
|
|
});
|
|
};
|
|
const executeClient = async (definition, expected) => {
|
|
const job = createClientJob(definition);
|
|
return waitClientJob(job, expected);
|
|
};
|
|
const executeWave = async (definitions, expected) => {
|
|
const jobs = definitions.map(createClientJob);
|
|
return Promise.all(jobs.map((job) => waitClientJob(job, expected)));
|
|
};
|
|
const executeWithoutClientCertificate = async ({
|
|
target,
|
|
command,
|
|
bearer,
|
|
}) => {
|
|
const name = 'ql3-wcm-no-client-certificate';
|
|
const inputSecret = `${name}-input`;
|
|
const rawConfig = {
|
|
schemaVersion: 1,
|
|
endpoint: clientConfig.endpoint,
|
|
servername: clientConfig.servername,
|
|
caFile: '/var/run/qinglong3/client/ca.crt',
|
|
requestTimeoutMs: clientConfig.requestTimeoutMs,
|
|
};
|
|
create({
|
|
apiVersion: 'v1',
|
|
kind: 'Secret',
|
|
metadata: { name: inputSecret, namespace: NAMESPACE },
|
|
type: 'Opaque',
|
|
stringData: {
|
|
'client.json': `${JSON.stringify(rawConfig)}\n`,
|
|
'command.json': `${JSON.stringify(command)}\n`,
|
|
'assertion.jwt': bearer,
|
|
'ca.crt': caText,
|
|
},
|
|
});
|
|
create({
|
|
apiVersion: 'batch/v1',
|
|
kind: 'Job',
|
|
metadata: { name, namespace: NAMESPACE },
|
|
spec: {
|
|
backoffLimit: 0,
|
|
activeDeadlineSeconds: 60,
|
|
ttlSecondsAfterFinished: 600,
|
|
template: {
|
|
metadata: {
|
|
labels: {
|
|
app: 'ql3-worker-manager-live-no-client-certificate',
|
|
'qinglong.io/worker-credential-management-client': 'true',
|
|
},
|
|
},
|
|
spec: {
|
|
automountServiceAccountToken: false,
|
|
restartPolicy: 'Never',
|
|
hostAliases: [
|
|
{ ip: target.status.podIP, hostnames: [SERVERNAME] },
|
|
],
|
|
securityContext: {
|
|
runAsNonRoot: true,
|
|
runAsUser: 10001,
|
|
runAsGroup: 10001,
|
|
fsGroup: 10001,
|
|
seccompProfile: { type: 'RuntimeDefault' },
|
|
},
|
|
containers: [
|
|
{
|
|
name: 'client',
|
|
image: adminImage,
|
|
imagePullPolicy: 'Never',
|
|
command: [
|
|
'node',
|
|
'-e',
|
|
RAW_REQUEST_DIAGNOSTIC_SCRIPT,
|
|
'--',
|
|
'/var/run/qinglong3/client/client.json',
|
|
'/var/run/qinglong3/client/command.json',
|
|
'/var/run/qinglong3/client/assertion.jwt',
|
|
],
|
|
terminationMessagePolicy: 'File',
|
|
securityContext: {
|
|
allowPrivilegeEscalation: false,
|
|
readOnlyRootFilesystem: true,
|
|
capabilities: { drop: ['ALL'] },
|
|
},
|
|
resources: {
|
|
requests: { cpu: '5m', memory: '16Mi' },
|
|
limits: { cpu: '100m', memory: '64Mi' },
|
|
},
|
|
volumeMounts: [
|
|
{
|
|
name: 'client-input',
|
|
mountPath: '/var/run/qinglong3/client',
|
|
readOnly: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
volumes: [
|
|
{
|
|
name: 'client-input',
|
|
secret: { secretName: inputSecret, defaultMode: 288 },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
await waitFor(`${name} completion`, 90_000, () => {
|
|
const current = kubectlJson(['-n', NAMESPACE, 'get', 'job', name]);
|
|
const complete = current.status.conditions?.some(
|
|
(condition) =>
|
|
condition.type === 'Complete' && condition.status === 'True',
|
|
);
|
|
return complete
|
|
? { ready: true, value: current }
|
|
: { ready: false, fact: JSON.stringify(current.status ?? {}) };
|
|
});
|
|
const pods = kubectlJson([
|
|
'-n',
|
|
NAMESPACE,
|
|
'get',
|
|
'pods',
|
|
'-l',
|
|
`job-name=${name}`,
|
|
]).items;
|
|
assert.equal(pods.length, 1);
|
|
const terminated =
|
|
pods[0].status.containerStatuses?.[0]?.state?.terminated;
|
|
assert.equal(terminated?.exitCode, 0);
|
|
const output = JSON.parse(terminated?.message ?? '{}');
|
|
assert.equal(output.event, 'raw_request_completed');
|
|
assert.equal(output.statusCode, 401);
|
|
assert.equal(output.protocol, 'TLSv1.3');
|
|
assert.equal(output.body.error.code, 'client_certificate_required');
|
|
assert.equal(String(terminated?.message ?? '').includes(bearer), false);
|
|
return Object.freeze({
|
|
targetPod: target.metadata.name,
|
|
statusCode: output.statusCode,
|
|
responseCode: output.body.error.code,
|
|
});
|
|
};
|
|
|
|
const executeConnectivity = async (target, index) => {
|
|
const name = `ql3-wcm-initial-connectivity-${index + 1}`;
|
|
create({
|
|
apiVersion: 'batch/v1',
|
|
kind: 'Job',
|
|
metadata: { name, namespace: NAMESPACE },
|
|
spec: {
|
|
backoffLimit: 0,
|
|
activeDeadlineSeconds: 60,
|
|
ttlSecondsAfterFinished: 600,
|
|
template: {
|
|
metadata: {
|
|
labels: {
|
|
app: 'ql3-worker-manager-live-connectivity',
|
|
'qinglong.io/worker-credential-management-client': 'true',
|
|
},
|
|
},
|
|
spec: {
|
|
automountServiceAccountToken: false,
|
|
restartPolicy: 'Never',
|
|
hostAliases: [
|
|
{ ip: target.status.podIP, hostnames: [SERVERNAME] },
|
|
],
|
|
securityContext: {
|
|
runAsNonRoot: true,
|
|
runAsUser: 10001,
|
|
runAsGroup: 10001,
|
|
fsGroup: 10001,
|
|
seccompProfile: { type: 'RuntimeDefault' },
|
|
},
|
|
containers: [
|
|
{
|
|
name: 'connectivity',
|
|
image: adminImage,
|
|
imagePullPolicy: 'Never',
|
|
command: [
|
|
'node',
|
|
'-e',
|
|
CONNECTIVITY_SCRIPT,
|
|
'--',
|
|
SERVERNAME,
|
|
'8444',
|
|
],
|
|
terminationMessagePolicy: 'File',
|
|
securityContext: {
|
|
allowPrivilegeEscalation: false,
|
|
readOnlyRootFilesystem: true,
|
|
capabilities: { drop: ['ALL'] },
|
|
},
|
|
resources: {
|
|
requests: { cpu: '5m', memory: '16Mi' },
|
|
limits: { cpu: '100m', memory: '64Mi' },
|
|
},
|
|
volumeMounts: [
|
|
{
|
|
name: 'ca',
|
|
mountPath: '/var/run/qinglong3/ca',
|
|
readOnly: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
volumes: [
|
|
{
|
|
name: 'ca',
|
|
secret: {
|
|
secretName: TLS_SECRET,
|
|
defaultMode: 292,
|
|
items: [{ key: 'ca.crt', path: 'ca.crt' }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
await waitFor(`${name} completion`, 90_000, () => {
|
|
const current = kubectlJson(['-n', NAMESPACE, 'get', 'job', name]);
|
|
const complete = current.status.conditions?.some(
|
|
(condition) =>
|
|
condition.type === 'Complete' && condition.status === 'True',
|
|
);
|
|
const failed = current.status.conditions?.some(
|
|
(condition) =>
|
|
condition.type === 'Failed' && condition.status === 'True',
|
|
);
|
|
return complete || failed
|
|
? { ready: true, value: { complete, failed } }
|
|
: { ready: false, fact: JSON.stringify(current.status ?? {}) };
|
|
});
|
|
const pods = kubectlJson([
|
|
'-n',
|
|
NAMESPACE,
|
|
'get',
|
|
'pods',
|
|
'-l',
|
|
`job-name=${name}`,
|
|
]).items;
|
|
assert.equal(pods.length, 1);
|
|
const terminated =
|
|
pods[0].status.containerStatuses?.[0]?.state?.terminated;
|
|
const output = JSON.parse(terminated?.message ?? '{}');
|
|
assert.equal(terminated?.exitCode, 0);
|
|
assert.equal(output.event, 'connectivity_ready');
|
|
assert.equal(output.statusCode, 200);
|
|
assert.equal(output.protocol, 'TLSv1.3');
|
|
return Object.freeze({
|
|
name,
|
|
targetPod: target.metadata.name,
|
|
targetNode: target.spec.nodeName,
|
|
sourceNode: pods[0].spec.nodeName,
|
|
attempts: output.attempts,
|
|
});
|
|
};
|
|
const initialConnectivity = await Promise.all(
|
|
generation1Pods.map(executeConnectivity),
|
|
);
|
|
const firstCommands = Array.from({ length: 8 }, (_, index) =>
|
|
planCommand(index + 1),
|
|
);
|
|
const inspectExistingPlan = (suffix) =>
|
|
inspectCommand(firstCommands[0].request.actionRef, suffix);
|
|
const noClientCertificate = await executeWithoutClientCertificate({
|
|
target: generation1Pods[0],
|
|
command: inspectExistingPlan('no-client-certificate'),
|
|
bearer: assertion(oldKey, 'operator-a', 'no-client-certificate'),
|
|
});
|
|
const firstWave = await executeWave(
|
|
firstCommands.map((command, index) => ({
|
|
name: `ql3-wcm-plan-admit-${String(index + 1).padStart(2, '0')}`,
|
|
target: generation1Pods[index % 2],
|
|
command,
|
|
bearer: assertion(oldKey, 'operator-a', `admit-${index + 1}`),
|
|
})),
|
|
{ statusCode: 200, resultStatus: ['created', 'existing'] },
|
|
);
|
|
const committedOperationName = 'ql3-worker-credential-management-client';
|
|
const committedOperationCommand = inspectExistingPlan(
|
|
'committed-operation',
|
|
);
|
|
const committedOperationAssertion = assertion(
|
|
oldKey,
|
|
'operator-a',
|
|
'committed-operation',
|
|
);
|
|
create(loadOperationManifest('service-account.yaml'));
|
|
create(loadOperationManifest('network-policy.yaml'));
|
|
create({
|
|
apiVersion: 'v1',
|
|
kind: 'ConfigMap',
|
|
metadata: {
|
|
name: 'ql3-worker-credential-management-request',
|
|
namespace: NAMESPACE,
|
|
},
|
|
immutable: true,
|
|
data: {
|
|
'client.json': `${JSON.stringify(clientConfig)}\n`,
|
|
'command.json': `${JSON.stringify(committedOperationCommand)}\n`,
|
|
},
|
|
});
|
|
create({
|
|
apiVersion: 'v1',
|
|
kind: 'ConfigMap',
|
|
metadata: {
|
|
name: 'ql3-worker-credential-management-client-trust',
|
|
namespace: NAMESPACE,
|
|
},
|
|
immutable: true,
|
|
data: { 'ca.crt': caText },
|
|
});
|
|
create({
|
|
apiVersion: 'v1',
|
|
kind: 'Secret',
|
|
metadata: {
|
|
name: 'ql3-worker-credential-management-assertion',
|
|
namespace: NAMESPACE,
|
|
},
|
|
immutable: true,
|
|
type: 'Opaque',
|
|
stringData: { 'assertion.jwt': committedOperationAssertion },
|
|
});
|
|
create({
|
|
apiVersion: 'v1',
|
|
kind: 'Secret',
|
|
metadata: {
|
|
name: 'ql3-worker-credential-management-client-identity',
|
|
namespace: NAMESPACE,
|
|
},
|
|
immutable: true,
|
|
type: 'kubernetes.io/tls',
|
|
stringData: {
|
|
'tls.crt': activeClientCertificateText,
|
|
'tls.key': activeClientKeyText,
|
|
},
|
|
});
|
|
const committedOperationJob = loadOperationManifest('job.yaml');
|
|
for (const candidate of [
|
|
...committedOperationJob.spec.template.spec.initContainers,
|
|
...committedOperationJob.spec.template.spec.containers,
|
|
]) {
|
|
candidate.image = adminImage;
|
|
candidate.imagePullPolicy = 'Never';
|
|
}
|
|
create(committedOperationJob);
|
|
const committedOperationCompletion = await waitFor(
|
|
'committed management client operation completion',
|
|
150_000,
|
|
() => {
|
|
const current = kubectlJson([
|
|
'-n',
|
|
NAMESPACE,
|
|
'get',
|
|
'job',
|
|
committedOperationName,
|
|
]);
|
|
const complete = current.status.conditions?.some(
|
|
(condition) =>
|
|
condition.type === 'Complete' && condition.status === 'True',
|
|
);
|
|
const failed = current.status.conditions?.some(
|
|
(condition) =>
|
|
condition.type === 'Failed' && condition.status === 'True',
|
|
);
|
|
return complete || failed
|
|
? { ready: true, value: { complete, failed, current } }
|
|
: { ready: false, fact: JSON.stringify(current.status ?? {}) };
|
|
},
|
|
);
|
|
const committedOperationPods = kubectlJson([
|
|
'-n',
|
|
NAMESPACE,
|
|
'get',
|
|
'pods',
|
|
'-l',
|
|
`job-name=${committedOperationName}`,
|
|
]).items;
|
|
assert.equal(committedOperationPods.length, 1);
|
|
const committedOperationPod = committedOperationPods[0];
|
|
const committedOperationInit =
|
|
committedOperationPod.status.initContainerStatuses?.[0];
|
|
const committedOperationClient =
|
|
committedOperationPod.status.containerStatuses?.[0];
|
|
assert.equal(committedOperationCompletion.complete, true);
|
|
assert.equal(committedOperationCompletion.failed, false);
|
|
assert.equal(committedOperationCompletion.current.status.succeeded, 1);
|
|
assert.equal(committedOperationInit?.state?.terminated?.exitCode, 0);
|
|
assert.equal(committedOperationInit?.restartCount, 0);
|
|
assert.equal(committedOperationClient?.state?.terminated?.exitCode, 0);
|
|
assert.equal(committedOperationClient?.restartCount, 0);
|
|
assert.equal(
|
|
committedOperationPod.spec.automountServiceAccountToken,
|
|
false,
|
|
);
|
|
assert.equal(committedOperationPod.spec.enableServiceLinks, false);
|
|
assert.equal(
|
|
committedOperationPod.spec.serviceAccountName,
|
|
committedOperationName,
|
|
);
|
|
assert.equal(
|
|
committedOperationPod.spec.volumes.some((volume) =>
|
|
volume.projected?.sources?.some(
|
|
(source) => source.serviceAccountToken !== undefined,
|
|
),
|
|
),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
committedOperationPod.metadata.labels?.[
|
|
'qinglong.io/worker-credential-management-client'
|
|
],
|
|
'true',
|
|
);
|
|
assert.equal(
|
|
String(
|
|
committedOperationClient?.state?.terminated?.message ?? '',
|
|
).includes(committedOperationAssertion),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
String(
|
|
committedOperationClient?.state?.terminated?.message ?? '',
|
|
).includes(POSTGRES_ROLES.workerCredentialManager.password),
|
|
false,
|
|
);
|
|
for (const [kind, resourceName] of [
|
|
['configmap', 'ql3-worker-credential-management-request'],
|
|
['configmap', 'ql3-worker-credential-management-client-trust'],
|
|
['secret', 'ql3-worker-credential-management-assertion'],
|
|
['secret', 'ql3-worker-credential-management-client-identity'],
|
|
]) {
|
|
assert.equal(
|
|
kubectlJson(['-n', NAMESPACE, 'get', kind, resourceName]).immutable,
|
|
true,
|
|
);
|
|
}
|
|
const committedClientOperation = Object.freeze({
|
|
job: committedOperationName,
|
|
source: 'repository base manifests with test-only image substitution',
|
|
initExitCode: committedOperationInit.state.terminated.exitCode,
|
|
clientExitCode: committedOperationClient.state.terminated.exitCode,
|
|
clientRestarts: committedOperationClient.restartCount,
|
|
operation: committedOperationCommand.operation,
|
|
completed: committedOperationCompletion.complete,
|
|
assertionInTerminationMessage: false,
|
|
serviceAccountTokenMounted: false,
|
|
immutableInputs: true,
|
|
});
|
|
const limitedWave = await executeWave(
|
|
Array.from({ length: 8 }, (_, index) => ({
|
|
name: `ql3-wcm-plan-limit-${String(index + 1).padStart(2, '0')}`,
|
|
target: generation1Pods[index % 2],
|
|
command: planCommand(index + 9),
|
|
bearer: assertion(oldKey, 'operator-a', `limit-${index + 1}`),
|
|
})),
|
|
{ statusCode: 429, responseCode: 'quota_exceeded' },
|
|
);
|
|
const replay = await executeClient(
|
|
{
|
|
name: 'ql3-wcm-plan-replay-cross-pod',
|
|
target: generation1Pods[1],
|
|
command: firstCommands[0],
|
|
bearer: assertion(oldKey, 'operator-a', 'replay-cross-pod'),
|
|
},
|
|
{ statusCode: 200, resultStatus: 'existing' },
|
|
);
|
|
|
|
run(
|
|
'openssl',
|
|
['ca', '-batch', '-config', caConfig, '-revoke', clientOldCertificate],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
run(
|
|
'openssl',
|
|
[
|
|
'ca',
|
|
'-gencrl',
|
|
'-config',
|
|
caConfig,
|
|
'-out',
|
|
clientCertificateRevocationList,
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
clientCrlText = fs.readFileSync(clientCertificateRevocationList, 'utf8');
|
|
apply({
|
|
apiVersion: 'v1',
|
|
kind: 'Secret',
|
|
metadata: { name: TLS_SECRET, namespace: NAMESPACE },
|
|
type: 'kubernetes.io/tls',
|
|
stringData: {
|
|
'tls.crt': tlsCertificateText,
|
|
'tls.key': tlsKeyText,
|
|
'ca.crt': caText,
|
|
'client.crl': clientCrlText,
|
|
},
|
|
});
|
|
const preRevocationUids = new Set(
|
|
generation1Pods.map((pod) => pod.metadata.uid),
|
|
);
|
|
patchGeneration('client-crl-2');
|
|
waitRollout();
|
|
const clientRevocationPods = await readyManagerPods(preRevocationUids);
|
|
const revokedClientCertificate = await executeClient(
|
|
{
|
|
name: 'ql3-wcm-revoked-client-certificate',
|
|
target: clientRevocationPods[0],
|
|
command: inspectExistingPlan('revoked-client-certificate'),
|
|
bearer: assertion(oldKey, 'operator-a', 'revoked-client-certificate'),
|
|
clientCertificateText: clientOldCertificateText,
|
|
clientKeyText: clientOldKeyText,
|
|
},
|
|
{ statusCode: 401, responseCode: 'client_certificate_required' },
|
|
);
|
|
activeClientCertificateText = clientNewCertificateText;
|
|
activeClientKeyText = clientNewKeyText;
|
|
const activeClientCertificate = await executeClient(
|
|
{
|
|
name: 'ql3-wcm-active-client-certificate',
|
|
target: clientRevocationPods[1],
|
|
command: inspectExistingPlan('active-client-certificate'),
|
|
bearer: assertion(oldKey, 'operator-a', 'active-client-certificate'),
|
|
},
|
|
{ statusCode: 200 },
|
|
);
|
|
|
|
const generation1Uids = new Set(
|
|
clientRevocationPods.map((pod) => pod.metadata.uid),
|
|
);
|
|
applyIdentity(keyset2);
|
|
patchGeneration('2');
|
|
waitRollout();
|
|
const generation2Pods = await readyManagerPods(generation1Uids);
|
|
const overlapOld = await executeClient(
|
|
{
|
|
name: 'ql3-wcm-overlap-old-key',
|
|
target: generation2Pods[0],
|
|
command: inspectExistingPlan('overlap-old'),
|
|
bearer: assertion(oldKey, 'operator-a', 'overlap-old'),
|
|
},
|
|
{ statusCode: 200 },
|
|
);
|
|
const overlapNew = await executeClient(
|
|
{
|
|
name: 'ql3-wcm-overlap-new-key',
|
|
target: generation2Pods[1],
|
|
command: inspectExistingPlan('overlap-new'),
|
|
bearer: assertion(newKey, 'operator-a', 'overlap-new'),
|
|
},
|
|
{ statusCode: 200 },
|
|
);
|
|
|
|
const generation2Uids = new Set(
|
|
generation2Pods.map((pod) => pod.metadata.uid),
|
|
);
|
|
applyIdentity(keyset3);
|
|
patchGeneration('3');
|
|
waitRollout();
|
|
const generation3Pods = await readyManagerPods(generation2Uids);
|
|
const revokedOld = await executeClient(
|
|
{
|
|
name: 'ql3-wcm-revoked-old-key',
|
|
target: generation3Pods[0],
|
|
command: inspectExistingPlan('revoked-old'),
|
|
bearer: assertion(oldKey, 'operator-a', 'revoked-old'),
|
|
},
|
|
{ statusCode: 401, responseCode: 'authentication_required' },
|
|
);
|
|
const activeNew = await executeClient(
|
|
{
|
|
name: 'ql3-wcm-active-new-key',
|
|
target: generation3Pods[1],
|
|
command: inspectExistingPlan('active-new'),
|
|
bearer: assertion(newKey, 'operator-a', 'active-new'),
|
|
},
|
|
{ statusCode: 200 },
|
|
);
|
|
|
|
applyIdentity(keyset2);
|
|
patchGeneration('rollback-2');
|
|
const rollbackPod = await waitFor(
|
|
'rollback surge Pod fail-closed',
|
|
120_000,
|
|
() => {
|
|
const deployment = kubectlJson([
|
|
'-n',
|
|
NAMESPACE,
|
|
'get',
|
|
'deployment',
|
|
DEPLOYMENT,
|
|
]);
|
|
const pods = kubectlJson([
|
|
'-n',
|
|
NAMESPACE,
|
|
'get',
|
|
'pods',
|
|
'-l',
|
|
`app.kubernetes.io/name=${DEPLOYMENT}`,
|
|
]).items.filter((pod) => pod.metadata.deletionTimestamp === undefined);
|
|
const ready = pods.filter((pod) =>
|
|
pod.status.conditions?.some(
|
|
(condition) =>
|
|
condition.type === 'Ready' && condition.status === 'True',
|
|
),
|
|
);
|
|
const candidate = pods.find(
|
|
(pod) =>
|
|
!generation3Pods.some(
|
|
(current) => current.metadata.uid === pod.metadata.uid,
|
|
) &&
|
|
pod.status.containerStatuses?.[0] &&
|
|
!pod.status.containerStatuses[0].ready &&
|
|
(pod.status.containerStatuses[0].restartCount > 0 ||
|
|
pod.status.containerStatuses[0].state?.waiting?.reason ===
|
|
'CrashLoopBackOff'),
|
|
);
|
|
return ready.length === 2 &&
|
|
deployment.status.readyReplicas === 2 &&
|
|
candidate
|
|
? { ready: true, value: candidate }
|
|
: {
|
|
ready: false,
|
|
fact:
|
|
`ready=${ready.length} replicas=${
|
|
deployment.status.readyReplicas ?? 0
|
|
} ` + `pods=${pods.length}`,
|
|
};
|
|
},
|
|
);
|
|
assert.equal(
|
|
rollbackPod.status.containerStatuses[0].lastState?.terminated?.exitCode ??
|
|
rollbackPod.status.containerStatuses[0].state?.terminated?.exitCode,
|
|
1,
|
|
);
|
|
applyIdentity(keyset3);
|
|
patchGeneration('3-recovered');
|
|
kubectl(
|
|
[
|
|
'-n',
|
|
NAMESPACE,
|
|
'delete',
|
|
'pod',
|
|
rollbackPod.metadata.name,
|
|
'--grace-period=0',
|
|
'--force',
|
|
'--wait=true',
|
|
],
|
|
{ capture: true, quiet: true },
|
|
);
|
|
waitRollout();
|
|
const recoveredIdentityPods = await readyManagerPods();
|
|
const recoveredIdentity = await Promise.all(
|
|
recoveredIdentityPods.map((pod, index) =>
|
|
executeClient(
|
|
{
|
|
name: `ql3-wcm-identity-recovered-${index + 1}`,
|
|
target: pod,
|
|
command: inspectExistingPlan(`identity-recovered-${index + 1}`),
|
|
bearer: assertion(
|
|
newKey,
|
|
'operator-a',
|
|
`identity-recovered-${index + 1}`,
|
|
),
|
|
},
|
|
{ statusCode: 200 },
|
|
),
|
|
),
|
|
);
|
|
|
|
const beforeFailureFacts = (
|
|
await migrationDatabase.pool.query(
|
|
`SELECT
|
|
(SELECT count(*)::integer
|
|
FROM "ql3"."worker_credential_management_plans") AS "plans",
|
|
(SELECT consumed_count::integer
|
|
FROM "ql3"."worker_credential_management_quota_buckets"
|
|
WHERE project_id = 'cluster-authority'
|
|
AND subject_type = 'user'
|
|
AND subject_id = 'operator-a'
|
|
AND operation = 'worker-credential.plan') AS "consumedCount",
|
|
(SELECT jsonb_array_length(receipt_ids)::integer
|
|
FROM "ql3"."worker_credential_management_quota_buckets"
|
|
WHERE project_id = 'cluster-authority'
|
|
AND subject_type = 'user'
|
|
AND subject_id = 'operator-a'
|
|
AND operation = 'worker-credential.plan') AS "receiptCount",
|
|
(SELECT generation::integer
|
|
FROM "ql3"."plugin_package_identity_keyset_ledger"
|
|
WHERE authority = 'worker-credential-management') AS "identityGeneration"`,
|
|
)
|
|
).rows[0];
|
|
assert.deepEqual(beforeFailureFacts, {
|
|
plans: 8,
|
|
consumedCount: 8,
|
|
receiptCount: 8,
|
|
identityGeneration: 3,
|
|
});
|
|
await migrationDatabase.close();
|
|
migrationDatabase = undefined;
|
|
|
|
const createHealthJob = ({ name, target }) => {
|
|
create({
|
|
apiVersion: 'batch/v1',
|
|
kind: 'Job',
|
|
metadata: { name, namespace: NAMESPACE },
|
|
spec: {
|
|
backoffLimit: 0,
|
|
activeDeadlineSeconds: 60,
|
|
ttlSecondsAfterFinished: 600,
|
|
template: {
|
|
metadata: {
|
|
labels: {
|
|
app: 'ql3-worker-manager-live-health',
|
|
'qinglong.io/worker-credential-management-client': 'true',
|
|
},
|
|
},
|
|
spec: {
|
|
automountServiceAccountToken: false,
|
|
restartPolicy: 'Never',
|
|
hostAliases: [
|
|
{ ip: target.status.podIP, hostnames: [SERVERNAME] },
|
|
],
|
|
securityContext: {
|
|
runAsNonRoot: true,
|
|
runAsUser: 10001,
|
|
runAsGroup: 10001,
|
|
fsGroup: 10001,
|
|
seccompProfile: { type: 'RuntimeDefault' },
|
|
},
|
|
containers: [
|
|
{
|
|
name: 'health',
|
|
image: adminImage,
|
|
imagePullPolicy: 'Never',
|
|
command: [
|
|
'node',
|
|
'-e',
|
|
HEALTH_SCRIPT,
|
|
'--',
|
|
SERVERNAME,
|
|
'8444',
|
|
],
|
|
terminationMessagePolicy: 'File',
|
|
securityContext: {
|
|
allowPrivilegeEscalation: false,
|
|
readOnlyRootFilesystem: true,
|
|
capabilities: { drop: ['ALL'] },
|
|
},
|
|
resources: {
|
|
requests: { cpu: '5m', memory: '16Mi' },
|
|
limits: { cpu: '100m', memory: '64Mi' },
|
|
},
|
|
volumeMounts: [
|
|
{
|
|
name: 'ca',
|
|
mountPath: '/var/run/qinglong3/ca',
|
|
readOnly: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
volumes: [
|
|
{
|
|
name: 'ca',
|
|
secret: {
|
|
secretName: TLS_SECRET,
|
|
defaultMode: 292,
|
|
items: [{ key: 'ca.crt', path: 'ca.crt' }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
return Object.freeze({ name, target });
|
|
};
|
|
const waitHealthJob = async (job) => {
|
|
await waitFor(`${job.name} completion`, 90_000, () => {
|
|
const current = kubectlJson(['-n', NAMESPACE, 'get', 'job', job.name]);
|
|
const complete = current.status.conditions?.some(
|
|
(condition) =>
|
|
condition.type === 'Complete' && condition.status === 'True',
|
|
);
|
|
const failed = current.status.conditions?.some(
|
|
(condition) =>
|
|
condition.type === 'Failed' && condition.status === 'True',
|
|
);
|
|
return complete || failed
|
|
? { ready: true, value: current }
|
|
: { ready: false, fact: JSON.stringify(current.status ?? {}) };
|
|
});
|
|
const pods = kubectlJson([
|
|
'-n',
|
|
NAMESPACE,
|
|
'get',
|
|
'pods',
|
|
'-l',
|
|
`job-name=${job.name}`,
|
|
]).items;
|
|
assert.equal(pods.length, 1);
|
|
const terminated =
|
|
pods[0].status.containerStatuses?.[0]?.state?.terminated;
|
|
assert.equal(terminated?.exitCode, 0);
|
|
const output = JSON.parse(
|
|
(terminated.message ?? '').split('\n').filter(Boolean).at(-1),
|
|
);
|
|
assert.deepEqual(
|
|
output.responses.map((entry) => entry.protocol),
|
|
['TLSv1.3', 'TLSv1.3'],
|
|
);
|
|
return Object.freeze({
|
|
targetPod: job.target.metadata.name,
|
|
readyStatus: output.responses.find((entry) => entry.path === '/readyz')
|
|
.statusCode,
|
|
liveStatus: output.responses.find((entry) => entry.path === '/livez')
|
|
.statusCode,
|
|
});
|
|
};
|
|
const healthWave = async (label, pods) => {
|
|
const jobs = pods.map((pod, index) =>
|
|
createHealthJob({
|
|
name: `ql3-wcm-health-${label}-${index + 1}`,
|
|
target: pod,
|
|
}),
|
|
);
|
|
return Promise.all(jobs.map(waitHealthJob));
|
|
};
|
|
|
|
run(docker, ['stop', '--time', '1', postgres], {
|
|
capture: true,
|
|
quiet: true,
|
|
});
|
|
const unavailableRequests = await Promise.all(
|
|
recoveredIdentityPods.map((pod, index) =>
|
|
executeClient(
|
|
{
|
|
name: `ql3-wcm-postgres-down-${index + 1}`,
|
|
target: pod,
|
|
command: inspectExistingPlan(`postgres-down-${index + 1}`),
|
|
bearer: assertion(
|
|
newKey,
|
|
'operator-a',
|
|
`postgres-down-${index + 1}`,
|
|
),
|
|
},
|
|
{ statusCode: 503, responseCode: 'unavailable' },
|
|
),
|
|
),
|
|
);
|
|
const withdrawnHealth = await healthWave(
|
|
'withdrawn',
|
|
recoveredIdentityPods,
|
|
);
|
|
assert.deepEqual(
|
|
withdrawnHealth.map((entry) => entry.readyStatus),
|
|
[503, 503],
|
|
);
|
|
assert.deepEqual(
|
|
withdrawnHealth.map((entry) => entry.liveStatus),
|
|
[200, 200],
|
|
);
|
|
await waitFor(
|
|
'manager readiness withdrawn from both replicas',
|
|
30_000,
|
|
() => {
|
|
const current = kubectlJson([
|
|
'-n',
|
|
NAMESPACE,
|
|
'get',
|
|
'deployment',
|
|
DEPLOYMENT,
|
|
]);
|
|
return (current.status.readyReplicas ?? 0) === 0
|
|
? { ready: true, value: current }
|
|
: {
|
|
ready: false,
|
|
fact: `${
|
|
current.status.readyReplicas ?? 0
|
|
} replicas remain Ready`,
|
|
};
|
|
},
|
|
);
|
|
|
|
run(docker, ['start', postgres], { capture: true, quiet: true });
|
|
await waitFor('restarted PostgreSQL readiness', 60_000, () => {
|
|
const result = run(
|
|
docker,
|
|
[
|
|
'exec',
|
|
postgres,
|
|
'pg_isready',
|
|
'-h',
|
|
'127.0.0.1',
|
|
'-U',
|
|
'postgres',
|
|
'-d',
|
|
POSTGRES_DATABASE,
|
|
],
|
|
{ capture: true, quiet: true, allowFailure: true },
|
|
);
|
|
return result.status === 0
|
|
? { ready: true, value: true }
|
|
: { ready: false, fact: result.stderr || result.stdout };
|
|
});
|
|
const stillWithdrawnHealth = await healthWave(
|
|
'still-withdrawn',
|
|
recoveredIdentityPods,
|
|
);
|
|
assert.deepEqual(
|
|
stillWithdrawnHealth.map((entry) => entry.readyStatus),
|
|
[503, 503],
|
|
);
|
|
const withdrawnUids = new Set(
|
|
recoveredIdentityPods.map((pod) => pod.metadata.uid),
|
|
);
|
|
patchGeneration('3-fresh-postgres');
|
|
waitRollout();
|
|
const freshPods = await readyManagerPods(withdrawnUids);
|
|
const freshRequests = await Promise.all(
|
|
freshPods.map((pod, index) =>
|
|
executeClient(
|
|
{
|
|
name: `ql3-wcm-postgres-fresh-${index + 1}`,
|
|
target: pod,
|
|
command: inspectExistingPlan(`postgres-fresh-${index + 1}`),
|
|
bearer: assertion(
|
|
newKey,
|
|
'operator-a',
|
|
`postgres-fresh-${index + 1}`,
|
|
),
|
|
},
|
|
{ statusCode: 200 },
|
|
),
|
|
),
|
|
);
|
|
|
|
const finalFacts = JSON.parse(
|
|
run(
|
|
docker,
|
|
[
|
|
'exec',
|
|
'-e',
|
|
`PGPASSWORD=${POSTGRES_ROLES.workerCredentialManager.password}`,
|
|
postgres,
|
|
'psql',
|
|
'-U',
|
|
POSTGRES_ROLES.workerCredentialManager.user,
|
|
'-d',
|
|
POSTGRES_DATABASE,
|
|
'--tuples-only',
|
|
'--no-align',
|
|
'--command',
|
|
`SELECT json_build_object(
|
|
'plans', (SELECT count(*)::integer
|
|
FROM "ql3"."worker_credential_management_plans"),
|
|
'consumedCount', (SELECT consumed_count::integer
|
|
FROM "ql3"."worker_credential_management_quota_buckets"
|
|
WHERE project_id = 'cluster-authority'
|
|
AND subject_type = 'user'
|
|
AND subject_id = 'operator-a'
|
|
AND operation = 'worker-credential.plan'),
|
|
'receiptCount', (SELECT jsonb_array_length(receipt_ids)::integer
|
|
FROM "ql3"."worker_credential_management_quota_buckets"
|
|
WHERE project_id = 'cluster-authority'
|
|
AND subject_type = 'user'
|
|
AND subject_id = 'operator-a'
|
|
AND operation = 'worker-credential.plan'),
|
|
'identityGeneration', (SELECT generation::integer
|
|
FROM "ql3"."plugin_package_identity_keyset_ledger"
|
|
WHERE authority = 'worker-credential-management'),
|
|
'activeKeyIds', (SELECT active_key_ids
|
|
FROM "ql3"."plugin_package_identity_keyset_ledger"
|
|
WHERE authority = 'worker-credential-management'),
|
|
'revokedKeyIds', (SELECT revoked_key_ids
|
|
FROM "ql3"."plugin_package_identity_keyset_ledger"
|
|
WHERE authority = 'worker-credential-management'))`,
|
|
],
|
|
{ capture: true, quiet: true },
|
|
).stdout,
|
|
);
|
|
assert.deepEqual(finalFacts, {
|
|
plans: 8,
|
|
consumedCount: 8,
|
|
receiptCount: 8,
|
|
identityGeneration: 3,
|
|
activeKeyIds: [newKey.kid],
|
|
revokedKeyIds: [oldKey.kid],
|
|
});
|
|
|
|
const report = {
|
|
schemaVersion: 1,
|
|
fixture:
|
|
'qinglong/worker-credential-management-kubernetes-live-contract@v1',
|
|
kubernetes: {
|
|
distribution: 'k3s',
|
|
image: K3S_IMAGE,
|
|
imageDigest: K3S_DIGEST,
|
|
architecture: k3sImage.Architecture,
|
|
serverVersion: kubectlJson(['version']).serverVersion.gitVersion,
|
|
nodes: kubectlJson(['get', 'nodes']).items.map((node) => ({
|
|
name: node.metadata.name,
|
|
uid: node.metadata.uid,
|
|
})),
|
|
},
|
|
postgresql: {
|
|
image: POSTGRES_IMAGE,
|
|
imageId: postgresImage.Id,
|
|
architecture: postgresImage.Architecture,
|
|
database: POSTGRES_DATABASE,
|
|
managerRole: POSTGRES_ROLES.workerCredentialManager.user,
|
|
maxConnectionsPerPod: 2,
|
|
},
|
|
deployment: {
|
|
replicas: 2,
|
|
requiredPodAntiAffinity: true,
|
|
initialConnectivityChecks: initialConnectivity.length,
|
|
generation1Pods: generation1Pods.map((pod) => ({
|
|
name: pod.metadata.name,
|
|
uid: pod.metadata.uid,
|
|
node: pod.spec.nodeName,
|
|
})),
|
|
generation2Pods: generation2Pods.map((pod) => ({
|
|
name: pod.metadata.name,
|
|
uid: pod.metadata.uid,
|
|
node: pod.spec.nodeName,
|
|
})),
|
|
generation3Pods: generation3Pods.map((pod) => ({
|
|
name: pod.metadata.name,
|
|
uid: pod.metadata.uid,
|
|
node: pod.spec.nodeName,
|
|
})),
|
|
freshPostgresPods: freshPods.map((pod) => ({
|
|
name: pod.metadata.name,
|
|
uid: pod.metadata.uid,
|
|
node: pod.spec.nodeName,
|
|
})),
|
|
serviceAccountAutomount: false,
|
|
projectedServiceAccountToken: false,
|
|
exactPostgresEgressCidr: `${postgresAddress}/32`,
|
|
},
|
|
managementClientOperation: committedClientOperation,
|
|
clientCertificateAuthentication: {
|
|
healthWithoutClientCertificate: initialConnectivity.length === 2,
|
|
businessRouteWithoutClientCertificateStatus:
|
|
noClientCertificate.statusCode,
|
|
businessRouteWithoutClientCertificateCode:
|
|
noClientCertificate.responseCode,
|
|
revokedCertificateStatus: revokedClientCertificate.statusCode,
|
|
activeCertificateStatus: activeClientCertificate.statusCode,
|
|
rotationRequiresServerRollout: true,
|
|
},
|
|
quota: {
|
|
competingPods: 2,
|
|
admitted: firstWave.length,
|
|
limited: limitedWave.length,
|
|
requestsPerPodPerWave: 4,
|
|
crossPodReplayStatus: replay.output.result.status,
|
|
durablePlans: finalFacts.plans,
|
|
durableConsumedCount: finalFacts.consumedCount,
|
|
durableReceiptCount: finalFacts.receiptCount,
|
|
},
|
|
identity: {
|
|
generations: [1, 2, 3],
|
|
overlapOldAccepted: overlapOld.statusCode === 200,
|
|
overlapNewAccepted: overlapNew.statusCode === 200,
|
|
revokedOldRejected: revokedOld.statusCode === 401,
|
|
activeNewAccepted: activeNew.statusCode === 200,
|
|
rollbackPod: {
|
|
name: rollbackPod.metadata.name,
|
|
uid: rollbackPod.metadata.uid,
|
|
exitCode: 1,
|
|
},
|
|
readyReplicasDuringRollback: 2,
|
|
recoveredReplicas: recoveredIdentity.length,
|
|
durableGeneration: finalFacts.identityGeneration,
|
|
activeKeyIds: finalFacts.activeKeyIds,
|
|
revokedKeyIds: finalFacts.revokedKeyIds,
|
|
},
|
|
availability: {
|
|
unavailableResponses: unavailableRequests.map(
|
|
(entry) => entry.statusCode,
|
|
),
|
|
withdrawnReadyStatuses: withdrawnHealth.map(
|
|
(entry) => entry.readyStatus,
|
|
),
|
|
withdrawnLiveStatuses: withdrawnHealth.map((entry) => entry.liveStatus),
|
|
stillWithdrawnAfterDatabaseRestart: stillWithdrawnHealth.every(
|
|
(entry) => entry.readyStatus === 503,
|
|
),
|
|
freshActivationResponses: freshRequests.map(
|
|
(entry) => entry.statusCode,
|
|
),
|
|
},
|
|
gates: {
|
|
realThreeNodeKubernetes: true,
|
|
twoManagerPodsOnDistinctNodes: true,
|
|
tls13ProductionClientAcrossBothPods: true,
|
|
committedOneShotClientOperation: true,
|
|
clientCertificateAndOidcRequiredForBusinessRoutes: true,
|
|
revokedClientCertificateRejectedAfterRollout: true,
|
|
durableQuotaConvergedAcrossPods: true,
|
|
crossPodReplayConsumedNoAdditionalQuota: true,
|
|
identityProjectionOverlapAndRevocation: true,
|
|
rollbackSurgeFailedClosedWithoutAvailabilityLoss: true,
|
|
databaseFailureWithdrewReadinessButNotLiveness: true,
|
|
availabilityFenceDidNotRecoverInPlace: true,
|
|
freshActivationRecoveredBothReplicas: true,
|
|
passed: true,
|
|
},
|
|
limitations: [
|
|
'three privileged K3s Docker nodes are not production infrastructure or control-plane HA evidence',
|
|
'PostgreSQL transport is deliberately plaintext only inside the isolated disposable live fixture',
|
|
'database failure uses container stop rather than a raw-wire or CNI partition',
|
|
'identity assertions use deterministic local strong-User ceremony rather than an external IdP',
|
|
],
|
|
};
|
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
} finally {
|
|
if (migrationDatabase) {
|
|
await migrationDatabase.close().catch(() => undefined);
|
|
}
|
|
for (const name of [...createdContainers].reverse()) {
|
|
run(docker, ['rm', '-f', '-v', name], {
|
|
capture: true,
|
|
quiet: true,
|
|
allowFailure: true,
|
|
});
|
|
}
|
|
if (networkCreated) {
|
|
run(docker, ['network', 'rm', network], {
|
|
capture: true,
|
|
quiet: true,
|
|
allowFailure: true,
|
|
});
|
|
}
|
|
if (adminImageBuilt) {
|
|
run(docker, ['image', 'rm', '-f', adminImage], {
|
|
capture: true,
|
|
quiet: true,
|
|
allowFailure: true,
|
|
});
|
|
}
|
|
fs.rmSync(temporary, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stderr.write(
|
|
`ql3 Worker credential manager Kubernetes live contract failed: ` +
|
|
`${error.stack || error}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
});
|