@telorun/kernel 0.42.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundle/files-integrity.d.ts +25 -0
- package/dist/bundle/files-integrity.d.ts.map +1 -0
- package/dist/bundle/files-integrity.js +40 -0
- package/dist/bundle/files-integrity.js.map +1 -0
- package/dist/bundle/module-manifest.d.ts +22 -0
- package/dist/bundle/module-manifest.d.ts.map +1 -0
- package/dist/bundle/module-manifest.js +33 -0
- package/dist/bundle/module-manifest.js.map +1 -0
- package/dist/bundle/tar.d.ts +20 -0
- package/dist/bundle/tar.d.ts.map +1 -0
- package/dist/bundle/tar.js +63 -0
- package/dist/bundle/tar.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +8 -2
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.d.ts +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.js +14 -93
- package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
- package/dist/transports/oci/docker-credentials.d.ts +10 -0
- package/dist/transports/oci/docker-credentials.d.ts.map +1 -0
- package/dist/transports/oci/docker-credentials.js +75 -0
- package/dist/transports/oci/docker-credentials.js.map +1 -0
- package/dist/transports/oci/oci-client.d.ts +51 -0
- package/dist/transports/oci/oci-client.d.ts.map +1 -0
- package/dist/transports/oci/oci-client.js +170 -0
- package/dist/transports/oci/oci-client.js.map +1 -0
- package/dist/transports/oci/oci-ref.d.ts +21 -0
- package/dist/transports/oci/oci-ref.d.ts.map +1 -0
- package/dist/transports/oci/oci-ref.js +36 -0
- package/dist/transports/oci/oci-ref.js.map +1 -0
- package/dist/transports/oci/oci-transport.d.ts +29 -0
- package/dist/transports/oci/oci-transport.d.ts.map +1 -0
- package/dist/transports/oci/oci-transport.js +142 -0
- package/dist/transports/oci/oci-transport.js.map +1 -0
- package/dist/transports/registry-transport.d.ts +23 -0
- package/dist/transports/registry-transport.d.ts.map +1 -0
- package/dist/transports/registry-transport.js +225 -0
- package/dist/transports/registry-transport.js.map +1 -0
- package/dist/transports/transport-registry.d.ts +40 -0
- package/dist/transports/transport-registry.d.ts.map +1 -0
- package/dist/transports/transport-registry.js +70 -0
- package/dist/transports/transport-registry.js.map +1 -0
- package/dist/transports/transport.d.ts +92 -0
- package/dist/transports/transport.d.ts.map +1 -0
- package/dist/transports/transport.js +2 -0
- package/dist/transports/transport.js.map +1 -0
- package/package.json +6 -3
- package/src/bundle/files-integrity.ts +46 -0
- package/src/bundle/module-manifest.ts +49 -0
- package/src/bundle/tar.ts +78 -0
- package/src/index.ts +22 -0
- package/src/kernel.ts +7 -2
- package/src/manifest-sources/local-manifest-cache-source.ts +14 -99
- package/src/transports/oci/docker-credentials.ts +85 -0
- package/src/transports/oci/oci-client.ts +237 -0
- package/src/transports/oci/oci-ref.ts +53 -0
- package/src/transports/oci/oci-transport.ts +192 -0
- package/src/transports/registry-transport.ts +281 -0
- package/src/transports/transport-registry.ts +91 -0
- package/src/transports/transport.ts +112 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../src/transports/transport.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAExD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAEhE;;;sDAGsD;AACtD,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,WAAW,EAAE,CAAC;CACtB;AAED;;;;iEAIiE;AACjE,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,WAAW,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,gDAAgD;IAChD,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;kFAEkF;AAClF,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,qDAAqD;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;6CACyC;IACzC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE;QACf,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,OAAO,EAAE,MAAM,CAAC;KACjB,KAAK,IAAI,CAAC;CACZ;AAED;;;;;;;;;;;;yDAYyD;AACzD,MAAM,WAAW,SAAS;IACxB,4EAA4E;IAC5E,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAE/B;;gFAE4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAEhC;;6EAEyE;IACzE,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAAC;IAE5C;;;6BAGyB;IACzB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC;IAEpD;;;kFAG8E;IAC9E,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;IAErD;;qEAEiE;IACjE,OAAO,CACL,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,aAAa,EACrB,IAAI,CAAC,EAAE,cAAc,GACpB,OAAO,CAAC,aAAa,CAAC,CAAC;IAE1B;;;;;2BAKuB;IACvB,sBAAsB,CACpB,WAAW,EAAE,MAAM,EACnB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,eAAe,GACvB,MAAM,CAAC;CACX"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport.js","sourceRoot":"","sources":["../../src/transports/transport.ts"],"names":[],"mappings":""}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/kernel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "Telo Runtime - A lightweight, polyglot execution host.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -47,16 +47,18 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@marcbachmann/cel-js": "^7.6.1",
|
|
49
49
|
"@sinclair/typebox": "^0.34.48",
|
|
50
|
-
"@telorun/analyzer": "0.
|
|
50
|
+
"@telorun/analyzer": "0.33.0",
|
|
51
51
|
"@telorun/glob": "0.2.0",
|
|
52
|
-
"@telorun/templating": "0.10.
|
|
52
|
+
"@telorun/templating": "0.10.1",
|
|
53
53
|
"ajv": "^8.17.1",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
55
55
|
"packageurl-js": "^2.0.1",
|
|
56
|
+
"tar-stream": "^3.1.7",
|
|
56
57
|
"yaml": "^2.8.3"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
59
60
|
"@types/node": "^20.0.0",
|
|
61
|
+
"@types/tar-stream": "^3.1.3",
|
|
60
62
|
"typescript": "^5.0.0",
|
|
61
63
|
"vitest": "^2.1.8",
|
|
62
64
|
"@telorun/sdk": "0.41.0"
|
|
@@ -76,6 +78,7 @@
|
|
|
76
78
|
"ajv": "$ajv",
|
|
77
79
|
"ajv-formats": "$ajv-formats",
|
|
78
80
|
"packageurl-js": "$packageurl-js",
|
|
81
|
+
"tar-stream": "$tar-stream",
|
|
79
82
|
"yaml": "$yaml"
|
|
80
83
|
},
|
|
81
84
|
"scripts": {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { DEFAULT_MANIFEST_FILENAME, sha256Base64Url } from "@telorun/analyzer";
|
|
2
|
+
|
|
3
|
+
import { findOwnerDoc, parseManifestDocs } from "./module-manifest.js";
|
|
4
|
+
|
|
5
|
+
export interface PayloadFile {
|
|
6
|
+
/** POSIX-relative path inside the bundle. */
|
|
7
|
+
name: string;
|
|
8
|
+
content: Buffer | Uint8Array;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Canonical per-file content digest of a module's `files:` payload — the value
|
|
13
|
+
* of `filesIntegrity` in a bundle's `telo.yaml`. SHA-256 over the sorted
|
|
14
|
+
* `<path>\0<sha256(content)>` lines of every payload file, `telo.yaml`
|
|
15
|
+
* excluded (the importer's `#sha256-...` hash already covers the manifest, and
|
|
16
|
+
* excluding it breaks the self-reference — the manifest embeds this value).
|
|
17
|
+
*
|
|
18
|
+
* Hashing file *contents* rather than the tar/gzip bytes makes the digest
|
|
19
|
+
* independent of archive framing, so publisher and client compute the same
|
|
20
|
+
* value from the same file set, and it can be re-derived from the extracted
|
|
21
|
+
* files on disk. Returns `sha256-<base64url>`.
|
|
22
|
+
*/
|
|
23
|
+
export async function computeFilesIntegrity(files: PayloadFile[]): Promise<string> {
|
|
24
|
+
const lines: string[] = [];
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
if (file.name === DEFAULT_MANIFEST_FILENAME) continue;
|
|
27
|
+
const bytes = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content);
|
|
28
|
+
lines.push(`${file.name}\0${await sha256Base64Url(bytes)}`);
|
|
29
|
+
}
|
|
30
|
+
lines.sort();
|
|
31
|
+
const canonical = new TextEncoder().encode(lines.join("\n"));
|
|
32
|
+
return `sha256-${await sha256Base64Url(canonical)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Write `filesIntegrity` onto the manifest's owner doc so the published
|
|
36
|
+
* `telo.yaml` pins its payload — transitively covered by importers'
|
|
37
|
+
* `#sha256-...` hash. The digest excludes `telo.yaml`, so injecting it does
|
|
38
|
+
* not change the digest. Returns the manifest unchanged when it has no owner
|
|
39
|
+
* doc. */
|
|
40
|
+
export function injectFilesIntegrity(manifest: string, hash: string): string {
|
|
41
|
+
const docs = parseManifestDocs(manifest);
|
|
42
|
+
const owner = findOwnerDoc(docs);
|
|
43
|
+
if (!owner) return manifest;
|
|
44
|
+
owner.set("filesIntegrity", hash);
|
|
45
|
+
return docs.map((d) => d.toString()).join("---\n");
|
|
46
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { defaultCustomTags } from "@telorun/templating";
|
|
2
|
+
import { parseAllDocuments, type Document } from "yaml";
|
|
3
|
+
|
|
4
|
+
const OWNER_KINDS = new Set(["Telo.Application", "Telo.Library"]);
|
|
5
|
+
|
|
6
|
+
/** Parse a manifest's YAML documents, tolerating its `!cel` / `!ref` tags. */
|
|
7
|
+
export function parseManifestDocs(text: string): Document[] {
|
|
8
|
+
return parseAllDocuments(text, { customTags: defaultCustomTags() });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The owner document of a manifest — the single `Telo.Application` /
|
|
12
|
+
* `Telo.Library` doc that carries its identity, `files:`, and `filesIntegrity`.
|
|
13
|
+
* The one place that selects it, shared by every reader/writer. */
|
|
14
|
+
export function findOwnerDoc(docs: Document[]): Document | undefined {
|
|
15
|
+
return docs.find((d) => {
|
|
16
|
+
const kind = (d.toJSON() as { kind?: string } | null)?.kind;
|
|
17
|
+
return kind !== undefined && OWNER_KINDS.has(kind);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The owner-doc fields the transports read: identity for the publish location,
|
|
22
|
+
* and payload markers for artifact fetch. */
|
|
23
|
+
export interface OwnerManifest {
|
|
24
|
+
namespace?: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
version?: string;
|
|
27
|
+
filesIntegrity?: string;
|
|
28
|
+
/** True when the owner doc declares a non-empty `files:` list. */
|
|
29
|
+
declaresFiles: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read the owner doc's identity + payload fields from a manifest, parsing once
|
|
33
|
+
* (never regex-scraping). The single source both transports call for
|
|
34
|
+
* `<namespace>/<name>/<version>`, `filesIntegrity`, and payload detection. */
|
|
35
|
+
export function readOwnerManifest(text: string): OwnerManifest {
|
|
36
|
+
const owner = findOwnerDoc(parseManifestDocs(text));
|
|
37
|
+
const parsed = owner?.toJSON() as
|
|
38
|
+
| { metadata?: Record<string, unknown>; files?: unknown; filesIntegrity?: unknown }
|
|
39
|
+
| undefined;
|
|
40
|
+
const md = parsed?.metadata ?? {};
|
|
41
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
42
|
+
return {
|
|
43
|
+
namespace: str(md.namespace),
|
|
44
|
+
name: str(md.name),
|
|
45
|
+
version: str(md.version),
|
|
46
|
+
filesIntegrity: str(parsed?.filesIntegrity),
|
|
47
|
+
declaresFiles: Array.isArray(parsed?.files) && parsed.files.length > 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { extract as tarExtract, pack as tarPack } from "tar-stream";
|
|
2
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
|
|
5
|
+
import type { PayloadFile } from "./files-integrity.js";
|
|
6
|
+
|
|
7
|
+
export interface BundleEntry {
|
|
8
|
+
/** POSIX-relative path inside the archive (e.g. `telo.yaml`, `public/app.js`). */
|
|
9
|
+
name: string;
|
|
10
|
+
content: Buffer | string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Normalize decoded tar entries to `PayloadFile`s (Buffer-backed content) —
|
|
14
|
+
* the fixed step both transports run after `readTarGz`. */
|
|
15
|
+
export function toPayloadFiles(entries: BundleEntry[]): PayloadFile[] {
|
|
16
|
+
return entries.map((e) => ({
|
|
17
|
+
name: e.name,
|
|
18
|
+
content: typeof e.content === "string" ? Buffer.from(e.content) : e.content,
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pack `entries` into a gzipped tar (`module.tar.gz`) — the module-artifact
|
|
24
|
+
* writer shared by `telo publish` and the transports. Artifacts are small (a
|
|
25
|
+
* manifest plus a built frontend), so buffering the whole archive before gzip
|
|
26
|
+
* is fine. (Distinct from `apps/k8s-runner/src/tar.ts`, which is coupled to
|
|
27
|
+
* `@telorun/runner-core`'s `RunBundle`.)
|
|
28
|
+
*/
|
|
29
|
+
export async function makeTarGz(entries: BundleEntry[]): Promise<Buffer> {
|
|
30
|
+
const pack = tarPack();
|
|
31
|
+
const chunks: Buffer[] = [];
|
|
32
|
+
pack.on("data", (c: Buffer) => chunks.push(c));
|
|
33
|
+
|
|
34
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
35
|
+
pack.on("end", resolve);
|
|
36
|
+
pack.on("error", reject);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
const buf = typeof entry.content === "string" ? Buffer.from(entry.content, "utf-8") : entry.content;
|
|
41
|
+
await new Promise<void>((resolve, reject) => {
|
|
42
|
+
pack.entry({ name: entry.name }, buf, (err) => (err ? reject(err) : resolve()));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
pack.finalize();
|
|
46
|
+
await done;
|
|
47
|
+
|
|
48
|
+
return gzipSync(Buffer.concat(chunks));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Decompress + untar a `module.tar.gz` buffer into its file entries. */
|
|
52
|
+
export async function readTarGz(buf: Buffer): Promise<BundleEntry[]> {
|
|
53
|
+
const tar = gunzipSync(buf);
|
|
54
|
+
const ex = tarExtract();
|
|
55
|
+
const entries: BundleEntry[] = [];
|
|
56
|
+
|
|
57
|
+
await new Promise<void>((resolve, reject) => {
|
|
58
|
+
ex.on("entry", (header, stream, next) => {
|
|
59
|
+
if (header.type !== "file") {
|
|
60
|
+
stream.on("end", next);
|
|
61
|
+
stream.resume();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const chunks: Buffer[] = [];
|
|
65
|
+
stream.on("data", (c: Buffer) => chunks.push(c));
|
|
66
|
+
stream.on("end", () => {
|
|
67
|
+
entries.push({ name: header.name, content: Buffer.concat(chunks) });
|
|
68
|
+
next();
|
|
69
|
+
});
|
|
70
|
+
stream.on("error", reject);
|
|
71
|
+
});
|
|
72
|
+
ex.on("finish", resolve);
|
|
73
|
+
ex.on("error", reject);
|
|
74
|
+
Readable.from(tar).pipe(ex);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return entries;
|
|
78
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,28 @@ export {
|
|
|
10
10
|
writeManifestCache,
|
|
11
11
|
} from "./manifest-sources/local-manifest-cache-source.js";
|
|
12
12
|
export { MemorySource } from "./manifest-sources/memory-source.js";
|
|
13
|
+
export type { Transport } from "./transports/transport.js";
|
|
14
|
+
export { RegistryTransport } from "./transports/registry-transport.js";
|
|
15
|
+
export { OciTransport } from "./transports/oci/oci-transport.js";
|
|
16
|
+
export { OciClient } from "./transports/oci/oci-client.js";
|
|
17
|
+
export { isOciRef, parseOciRef, type ParsedOciRef } from "./transports/oci/oci-ref.js";
|
|
18
|
+
export {
|
|
19
|
+
TransportRegistry,
|
|
20
|
+
defaultTransports,
|
|
21
|
+
defaultTransportRegistry,
|
|
22
|
+
} from "./transports/transport-registry.js";
|
|
23
|
+
export { makeTarGz, readTarGz, type BundleEntry } from "./bundle/tar.js";
|
|
24
|
+
export {
|
|
25
|
+
computeFilesIntegrity,
|
|
26
|
+
injectFilesIntegrity,
|
|
27
|
+
type PayloadFile,
|
|
28
|
+
} from "./bundle/files-integrity.js";
|
|
29
|
+
export type {
|
|
30
|
+
FetchedArtifact,
|
|
31
|
+
PublishBundle,
|
|
32
|
+
PublishResult,
|
|
33
|
+
PublishOptions,
|
|
34
|
+
} from "./transports/transport.js";
|
|
13
35
|
export { ExecutionContext } from "./execution-context.js";
|
|
14
36
|
export { Kernel, type KernelOptions } from "./kernel.js";
|
|
15
37
|
export { nodeCelHandlers } from "./cel-handlers.js";
|
package/src/kernel.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AnalysisRegistry,
|
|
3
3
|
buildEvalPaths,
|
|
4
|
-
defaultSources,
|
|
5
4
|
flattenForAnalyzer,
|
|
6
5
|
flattenLoadedModule,
|
|
7
6
|
isModuleKind,
|
|
@@ -48,6 +47,7 @@ import {
|
|
|
48
47
|
import {
|
|
49
48
|
resolveCacheRoot,
|
|
50
49
|
} from "./manifest-sources/local-manifest-cache-source.js";
|
|
50
|
+
import { defaultTransportRegistry } from "./transports/transport-registry.js";
|
|
51
51
|
import {
|
|
52
52
|
collectDeclaredEnvKeys,
|
|
53
53
|
precompileApplicationEnvSchemas,
|
|
@@ -156,7 +156,12 @@ export class Kernel implements IKernel {
|
|
|
156
156
|
this.env = options.env ?? hostEnv();
|
|
157
157
|
this.argv = options.argv ?? [];
|
|
158
158
|
this.registryUrl = options.registryUrl;
|
|
159
|
-
|
|
159
|
+
// Resolution sources come from the transport registry, so a scheme-owning
|
|
160
|
+
// transport (OCI, later S3) joins the loader's dispatch chain by being
|
|
161
|
+
// registered — no source-chain edits here.
|
|
162
|
+
this.loader = new Loader(defaultTransportRegistry(this.registryUrl).sources(), {
|
|
163
|
+
celHandlers: nodeCelHandlers,
|
|
164
|
+
});
|
|
160
165
|
for (const source of options.sources) {
|
|
161
166
|
this.loader.register(source);
|
|
162
167
|
}
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import { splitIntegrity, verifyIntegrity, type LoadedGraph, type ManifestSource } from "@telorun/analyzer";
|
|
2
|
-
import { createHash } from "crypto";
|
|
3
2
|
import { statSync } from "fs";
|
|
4
3
|
import * as fs from "fs/promises";
|
|
5
4
|
import * as path from "path";
|
|
6
5
|
import { fileURLToPath, pathToFileURL } from "url";
|
|
7
6
|
|
|
8
7
|
import { hostEnv } from "../host-env.js";
|
|
8
|
+
import { TransportRegistry, defaultTransportRegistry } from "../transports/transport-registry.js";
|
|
9
9
|
|
|
10
10
|
const CACHE_SUBDIR = ".telo/manifests";
|
|
11
|
-
const HTTP_NAMESPACE = "__http";
|
|
12
|
-
const DEFAULT_MANIFEST_FILENAME = "telo.yaml";
|
|
13
11
|
const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
|
|
14
|
-
const QUERY_HASH_LENGTH = 12;
|
|
15
12
|
|
|
16
13
|
/** Verify that `candidate` resolves to a path under `root`. Returns the
|
|
17
14
|
* candidate path on success, `null` when any segment escapes the root.
|
|
@@ -27,104 +24,22 @@ function joinUnder(root: string, ...segments: string[]): string | null {
|
|
|
27
24
|
return candidate;
|
|
28
25
|
}
|
|
29
26
|
|
|
30
|
-
/** Mirror `HttpSource.read`'s `fetchUrl` derivation: when the URL does not
|
|
31
|
-
* already point at a YAML file, append `/telo.yaml`. Used by both the
|
|
32
|
-
* reader and writer so a raw import URL and the canonical source it
|
|
33
|
-
* resolves to map to the same cache path. */
|
|
34
|
-
function normalizePathname(url: string, parsed: URL): string {
|
|
35
|
-
let pathname = parsed.pathname;
|
|
36
|
-
if (!url.includes(".yaml")) {
|
|
37
|
-
pathname = pathname.endsWith("/")
|
|
38
|
-
? `${pathname}${DEFAULT_MANIFEST_FILENAME}`
|
|
39
|
-
: `${pathname}/${DEFAULT_MANIFEST_FILENAME}`;
|
|
40
|
-
}
|
|
41
|
-
return pathname;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Compute a short hash of `search + hash` so two URLs that differ only in
|
|
45
|
-
* query / fragment do not collide at the same cache path. Inserted before
|
|
46
|
-
* the final extension so the filename stays readable. */
|
|
47
|
-
function disambiguatePath(pathname: string, search: string, hash: string): string {
|
|
48
|
-
if (!search && !hash) return pathname;
|
|
49
|
-
const digest = createHash("sha256")
|
|
50
|
-
.update(search + hash)
|
|
51
|
-
.digest("hex")
|
|
52
|
-
.slice(0, QUERY_HASH_LENGTH);
|
|
53
|
-
const ext = path.extname(pathname);
|
|
54
|
-
const base = pathname.slice(0, pathname.length - ext.length);
|
|
55
|
-
return `${base}.${digest}${ext}`;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
27
|
/** Single source of truth for URL → cache path. Used identically by the
|
|
59
28
|
* reader (cache lookup) and writer (install-time persistence). For any
|
|
60
|
-
* given import
|
|
61
|
-
*
|
|
29
|
+
* given import ref — registry ref, direct registry URL, arbitrary HTTP, or
|
|
30
|
+
* (once registered) `oci://` — both sides land on the same file, because the
|
|
31
|
+
* owning transport decides the layout via {@link Transport.cacheLocation}.
|
|
62
32
|
*
|
|
63
|
-
* Returns `null` for unsupported
|
|
64
|
-
*
|
|
33
|
+
* Returns `null` for unsupported refs (file://, memory://, relative paths) or
|
|
34
|
+
* for path-traversal attempts that would escape `cacheRoot`. */
|
|
65
35
|
function cachePathForUrl(
|
|
66
36
|
rawUrl: string,
|
|
67
37
|
cacheRoot: string,
|
|
68
|
-
|
|
38
|
+
transports: TransportRegistry,
|
|
69
39
|
): string | null {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const trimmedRegistry = registryUrl.replace(/\/+$/, "");
|
|
74
|
-
|
|
75
|
-
// 1. Registry ref form: namespace/name@version
|
|
76
|
-
if (
|
|
77
|
-
!url.startsWith("http://") &&
|
|
78
|
-
!url.startsWith("https://") &&
|
|
79
|
-
!url.startsWith("/") &&
|
|
80
|
-
!url.startsWith(".") &&
|
|
81
|
-
!url.startsWith("file://") &&
|
|
82
|
-
!url.startsWith("memory://") &&
|
|
83
|
-
url.includes("@") &&
|
|
84
|
-
url.includes("/")
|
|
85
|
-
) {
|
|
86
|
-
const atIdx = url.lastIndexOf("@");
|
|
87
|
-
if (atIdx <= 0 || atIdx === url.length - 1) return null;
|
|
88
|
-
const modulePath = url.slice(0, atIdx);
|
|
89
|
-
if (!modulePath.includes("/")) return null;
|
|
90
|
-
const version = url.slice(atIdx + 1).replace(/^v/, "");
|
|
91
|
-
if (!version) return null;
|
|
92
|
-
return joinUnder(cacheRoot, modulePath, version, DEFAULT_MANIFEST_FILENAME);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// 2. HTTP(S) URL — could be a direct registry URL or arbitrary external.
|
|
96
|
-
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
97
|
-
let parsed: URL;
|
|
98
|
-
try {
|
|
99
|
-
parsed = new URL(url);
|
|
100
|
-
} catch {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
const pathname = normalizePathname(url, parsed);
|
|
104
|
-
|
|
105
|
-
// 2a. URL is on the configured registry, with no query/fragment:
|
|
106
|
-
// fold into the registry layout so the writer that received a
|
|
107
|
-
// registry-ref canonical and the reader that sees a direct URL
|
|
108
|
-
// both resolve to the same file.
|
|
109
|
-
const normalizedUrl = `${parsed.protocol}//${parsed.host}${pathname}`;
|
|
110
|
-
if (
|
|
111
|
-
!parsed.search &&
|
|
112
|
-
!parsed.hash &&
|
|
113
|
-
(normalizedUrl === trimmedRegistry || normalizedUrl.startsWith(`${trimmedRegistry}/`))
|
|
114
|
-
) {
|
|
115
|
-
const rel = normalizedUrl.slice(trimmedRegistry.length + 1);
|
|
116
|
-
if (!rel) return null;
|
|
117
|
-
return joinUnder(cacheRoot, ...rel.split("/"));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// 2b. Arbitrary HTTP(S) URL → __http subtree, with a short query-hash
|
|
121
|
-
// suffix when query / fragment are present to prevent collisions.
|
|
122
|
-
const cleanPath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
|
123
|
-
const disambiguated = disambiguatePath(cleanPath, parsed.search, parsed.hash);
|
|
124
|
-
return joinUnder(cacheRoot, HTTP_NAMESPACE, parsed.host, ...disambiguated.split("/"));
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return null;
|
|
40
|
+
const segments = transports.cacheLocation(rawUrl);
|
|
41
|
+
if (!segments) return null;
|
|
42
|
+
return joinUnder(cacheRoot, ...segments);
|
|
128
43
|
}
|
|
129
44
|
|
|
130
45
|
/**
|
|
@@ -136,7 +51,7 @@ function cachePathForUrl(
|
|
|
136
51
|
*/
|
|
137
52
|
export class LocalManifestCacheSource implements ManifestSource {
|
|
138
53
|
private readonly cacheRoot: string;
|
|
139
|
-
private readonly
|
|
54
|
+
private readonly transports: TransportRegistry;
|
|
140
55
|
|
|
141
56
|
constructor(
|
|
142
57
|
entryDir: string,
|
|
@@ -147,7 +62,7 @@ export class LocalManifestCacheSource implements ManifestSource {
|
|
|
147
62
|
// single `resolveCacheRoot` (honours `TELO_CACHE_DIR`); when absent we fall
|
|
148
63
|
// back to the entry-anchored default so library/test callers are unchanged.
|
|
149
64
|
this.cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
|
|
150
|
-
this.
|
|
65
|
+
this.transports = defaultTransportRegistry(registryUrl);
|
|
151
66
|
}
|
|
152
67
|
|
|
153
68
|
supports(url: string): boolean {
|
|
@@ -184,7 +99,7 @@ export class LocalManifestCacheSource implements ManifestSource {
|
|
|
184
99
|
}
|
|
185
100
|
|
|
186
101
|
private tryMap(url: string): string | null {
|
|
187
|
-
const candidate = cachePathForUrl(url, this.cacheRoot, this.
|
|
102
|
+
const candidate = cachePathForUrl(url, this.cacheRoot, this.transports);
|
|
188
103
|
if (!candidate) return null;
|
|
189
104
|
// Require a regular file. A directory, dangling symlink, or stat failure
|
|
190
105
|
// (ENOENT, EACCES, EISDIR-on-component) all fall through as a cache miss
|
|
@@ -213,7 +128,7 @@ export function cachePathForCanonical(
|
|
|
213
128
|
manifestsDir?: string,
|
|
214
129
|
): string | null {
|
|
215
130
|
const cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
|
|
216
|
-
return cachePathForUrl(canonicalSource, cacheRoot, registryUrl);
|
|
131
|
+
return cachePathForUrl(canonicalSource, cacheRoot, defaultTransportRegistry(registryUrl));
|
|
217
132
|
}
|
|
218
133
|
|
|
219
134
|
/**
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { hostEnv } from "../../host-env.js";
|
|
7
|
+
|
|
8
|
+
export interface DockerCredential {
|
|
9
|
+
username: string;
|
|
10
|
+
password: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface DockerConfig {
|
|
14
|
+
auths?: Record<string, { auth?: string; username?: string; password?: string }>;
|
|
15
|
+
credHelpers?: Record<string, string>;
|
|
16
|
+
credsStore?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Path to `config.json`, honouring `DOCKER_CONFIG`. */
|
|
20
|
+
function dockerConfigPath(): string {
|
|
21
|
+
const override = hostEnv().DOCKER_CONFIG;
|
|
22
|
+
const dir = override && override.trim() ? override.trim() : path.join(homedir(), ".docker");
|
|
23
|
+
return path.join(dir, "config.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readDockerConfig(): Promise<DockerConfig | null> {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(await readFile(dockerConfigPath(), "utf-8")) as DockerConfig;
|
|
29
|
+
} catch {
|
|
30
|
+
return null; // no config, unreadable, or malformed → anonymous
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Run `docker-credential-<helper> get` with `host` on stdin, per the Docker
|
|
35
|
+
* credential-helper protocol, returning `{Username, Secret}` or null. */
|
|
36
|
+
function runCredentialHelper(helper: string, host: string): Promise<DockerCredential | null> {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const proc = spawn(`docker-credential-${helper}`, ["get"], {
|
|
39
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
40
|
+
});
|
|
41
|
+
let out = "";
|
|
42
|
+
proc.stdout.on("data", (c) => (out += c));
|
|
43
|
+
proc.on("error", () => resolve(null)); // helper binary not installed
|
|
44
|
+
proc.on("close", (code) => {
|
|
45
|
+
if (code !== 0) return resolve(null);
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(out) as { Username?: string; Secret?: string };
|
|
48
|
+
if (parsed.Username && parsed.Secret) {
|
|
49
|
+
resolve({ username: parsed.Username, password: parsed.Secret });
|
|
50
|
+
} else {
|
|
51
|
+
resolve(null);
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
resolve(null);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
proc.stdin.end(host);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve credentials for `host` from the ambient Docker credential chain:
|
|
62
|
+
* a per-registry credential helper, the global credential store, then the
|
|
63
|
+
* inline `auths` entry (base64 `user:password` or explicit fields). Returns
|
|
64
|
+
* null for anonymous access — the caller falls back to an anonymous token. */
|
|
65
|
+
export async function resolveDockerCredential(host: string): Promise<DockerCredential | null> {
|
|
66
|
+
const config = await readDockerConfig();
|
|
67
|
+
if (!config) return null;
|
|
68
|
+
|
|
69
|
+
const helper = config.credHelpers?.[host];
|
|
70
|
+
if (helper) return runCredentialHelper(helper, host);
|
|
71
|
+
if (config.credsStore) return runCredentialHelper(config.credsStore, host);
|
|
72
|
+
|
|
73
|
+
const entry = config.auths?.[host];
|
|
74
|
+
if (entry?.auth) {
|
|
75
|
+
const decoded = Buffer.from(entry.auth, "base64").toString("utf-8");
|
|
76
|
+
const colon = decoded.indexOf(":");
|
|
77
|
+
if (colon > 0) {
|
|
78
|
+
return { username: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (entry?.username && entry?.password) {
|
|
82
|
+
return { username: entry.username, password: entry.password };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|