mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
feat(cli): self-provision pinned uv and defer MCP Python runtime install (#297)
Fixes a production crash-loop (PostHog issue 019eb68e): ktx mcp start --foreground on a uv-less container eagerly installed the managed Python runtime at boot, failed, and was restarted by its supervisor every ~62s (122 exceptions from one install). - MCP server factory now wires a lazy semantic-layer compute port that defers the runtime install to the first call, mirroring the already-lazy SQL-analysis port; the server boots and serves non-Python tools without the runtime. - ktx no longer requires uv on PATH: it downloads its own pinned, sha256-verified uv build under the runtime root (KTX_RUNTIME_ROOT aware), always musl-static on Linux. PATH uv is never consulted. - uv is acquired before the version dir is wiped, so a failed download cannot destroy an existing runtime. - Acquisition failures (offline, intercepted download, unsupported platform) throw KtxExpectedError and stay out of Error Tracking; a missing binary inside a checksum-verified archive remains a plain Error. - scripts/refresh-uv-manifest.mjs regenerates the pinned manifest (packages/cli/src/managed-uv-release.ts) on uv bumps. - Setup consent prompt now discloses the uv download; docs updated.
This commit is contained in:
parent
663eaff940
commit
feb0818444
11 changed files with 731 additions and 72 deletions
95
scripts/refresh-uv-manifest.mjs
Normal file
95
scripts/refresh-uv-manifest.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
#!/usr/bin/env node
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const manifestModulePath = path.join(scriptDir, '..', 'packages', 'cli', 'src', 'managed-uv-release.ts');
|
||||
|
||||
// Linux always uses the musl-static build: it runs on both glibc and musl
|
||||
// distributions, so the CLI never has to detect libc at runtime.
|
||||
const PLATFORM_ARTIFACTS = {
|
||||
'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz',
|
||||
'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz',
|
||||
'linux-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz',
|
||||
'linux-x64': 'uv-x86_64-unknown-linux-musl.tar.gz',
|
||||
'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip',
|
||||
'win32-x64': 'uv-x86_64-pc-windows-msvc.zip',
|
||||
};
|
||||
|
||||
function parseSha256File(contents, file) {
|
||||
const hash = contents.trim().split(/\s+/)[0]?.replace(/^\*/, '');
|
||||
if (!/^[a-f0-9]{64}$/.test(hash ?? '')) {
|
||||
throw new Error(`Unexpected sha256 file contents for ${file}: ${contents.slice(0, 120)}`);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function renderUvReleaseModule(version, artifacts) {
|
||||
const keys = Object.keys(PLATFORM_ARTIFACTS);
|
||||
for (const key of keys) {
|
||||
if (!artifacts[key]?.file || !artifacts[key]?.sha256) {
|
||||
throw new Error(`Missing artifact entry for ${key}`);
|
||||
}
|
||||
}
|
||||
const entries = keys
|
||||
.map(
|
||||
(key) =>
|
||||
` '${key}': { file: '${artifacts[key].file}', sha256: '${artifacts[key].sha256}' }, // pragma: allowlist secret`,
|
||||
)
|
||||
.join('\n');
|
||||
return `// Generated by scripts/refresh-uv-manifest.mjs. Do not edit by hand.
|
||||
// Regenerate with: node scripts/refresh-uv-manifest.mjs [<uv-version>]
|
||||
|
||||
export type ManagedUvPlatformKey =
|
||||
${keys.map((key) => ` | '${key}'`).join('\n')};
|
||||
|
||||
export interface ManagedUvArtifact {
|
||||
file: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export const MANAGED_UV_VERSION = '${version}';
|
||||
|
||||
export const MANAGED_UV_ARTIFACTS: Record<ManagedUvPlatformKey, ManagedUvArtifact> = {
|
||||
${entries}
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
export async function refreshUvManifest(options = {}) {
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const writeFile = options.writeFile ?? writeFileSync;
|
||||
const log = options.log ?? console.log;
|
||||
const outputPath = options.outputPath ?? manifestModulePath;
|
||||
|
||||
let version = options.version;
|
||||
if (!version) {
|
||||
const response = await fetchImpl('https://api.github.com/repos/astral-sh/uv/releases/latest');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resolve latest uv release: HTTP ${response.status}`);
|
||||
}
|
||||
version = (await response.json()).tag_name;
|
||||
}
|
||||
|
||||
const artifacts = {};
|
||||
for (const [key, file] of Object.entries(PLATFORM_ARTIFACTS)) {
|
||||
const url = `https://github.com/astral-sh/uv/releases/download/${version}/${file}.sha256`;
|
||||
const response = await fetchImpl(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
|
||||
}
|
||||
artifacts[key] = { file, sha256: parseSha256File(await response.text(), file) };
|
||||
}
|
||||
|
||||
writeFile(outputPath, renderUvReleaseModule(version, artifacts));
|
||||
log(`Pinned uv ${version} into ${outputPath}`);
|
||||
return { version, artifacts };
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
refreshUvManifest({ version: process.argv[2] }).catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
69
scripts/refresh-uv-manifest.test.mjs
Normal file
69
scripts/refresh-uv-manifest.test.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { refreshUvManifest, renderUvReleaseModule } from './refresh-uv-manifest.mjs';
|
||||
|
||||
const ALL_KEYS = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-arm64', 'win32-x64'];
|
||||
|
||||
function artifactsFixture() {
|
||||
return Object.fromEntries(
|
||||
ALL_KEYS.map((key, index) => [key, { file: `uv-${key}.tar.gz`, sha256: String(index).repeat(64).slice(0, 64) }]),
|
||||
);
|
||||
}
|
||||
|
||||
describe('renderUvReleaseModule', () => {
|
||||
it('renders a typed module with every platform key and the pinned version', () => {
|
||||
const module = renderUvReleaseModule('1.2.3', artifactsFixture());
|
||||
|
||||
assert.match(module, /MANAGED_UV_VERSION = '1\.2\.3'/);
|
||||
assert.match(module, /Generated by scripts\/refresh-uv-manifest\.mjs/);
|
||||
for (const key of ALL_KEYS) {
|
||||
assert.match(module, new RegExp(`'${key}': \\{ file: 'uv-${key}\\.tar\\.gz', sha256: '[a-f0-9]{64}' \\}`));
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an incomplete platform map', () => {
|
||||
const artifacts = artifactsFixture();
|
||||
delete artifacts['win32-arm64'];
|
||||
assert.throws(() => renderUvReleaseModule('1.2.3', artifacts), /Missing artifact entry for win32-arm64/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshUvManifest', () => {
|
||||
it('fetches per-artifact sha256 files and writes the module', async () => {
|
||||
const written = [];
|
||||
const fetched = [];
|
||||
const fetchStub = async (url) => {
|
||||
fetched.push(url);
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => `${'a'.repeat(64)} *${url.split('/').at(-1).replace('.sha256', '')}\n`,
|
||||
};
|
||||
};
|
||||
|
||||
const result = await refreshUvManifest({
|
||||
version: '1.2.3',
|
||||
fetch: fetchStub,
|
||||
writeFile: (path, contents) => written.push({ path, contents }),
|
||||
log: () => {},
|
||||
outputPath: '/tmp/managed-uv-release.ts',
|
||||
});
|
||||
|
||||
assert.equal(result.version, '1.2.3');
|
||||
assert.equal(fetched.length, ALL_KEYS.length);
|
||||
assert.ok(fetched.every((url) => url.endsWith('.sha256') && url.includes('/download/1.2.3/')));
|
||||
assert.equal(written.length, 1);
|
||||
assert.match(written[0].contents, /MANAGED_UV_VERSION = '1\.2\.3'/);
|
||||
});
|
||||
|
||||
it('rejects malformed sha256 file contents', async () => {
|
||||
await assert.rejects(
|
||||
refreshUvManifest({
|
||||
version: '1.2.3',
|
||||
fetch: async () => ({ ok: true, text: async () => '<html>proxy login</html>' }),
|
||||
writeFile: () => {},
|
||||
log: () => {},
|
||||
}),
|
||||
/Unexpected sha256 file contents/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue