@telorun/kernel 0.58.0 → 0.60.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 +21 -12
- package/dist/bundle/files-integrity.d.ts.map +1 -1
- package/dist/bundle/files-integrity.js +27 -14
- package/dist/bundle/files-integrity.js.map +1 -1
- package/dist/bundle/module-artifact.d.ts +134 -0
- package/dist/bundle/module-artifact.d.ts.map +1 -0
- package/dist/bundle/module-artifact.js +300 -0
- package/dist/bundle/module-artifact.js.map +1 -0
- package/dist/bundle/module-manifest.d.ts +12 -4
- package/dist/bundle/module-manifest.d.ts.map +1 -1
- package/dist/bundle/module-manifest.js +8 -4
- package/dist/bundle/module-manifest.js.map +1 -1
- package/dist/controller-loader.d.ts +3 -2
- package/dist/controller-loader.d.ts.map +1 -1
- package/dist/controller-loader.js +8 -8
- package/dist/controller-loader.js.map +1 -1
- package/dist/controller-loaders/bundle-loader.d.ts +8 -6
- package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
- package/dist/controller-loaders/bundle-loader.js +52 -13
- package/dist/controller-loaders/bundle-loader.js.map +1 -1
- package/dist/controller-loaders/npm-loader.d.ts +0 -18
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +3 -190
- package/dist/controller-loaders/npm-loader.js.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +6 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/directory-lock.d.ts +27 -0
- package/dist/directory-lock.d.ts.map +1 -0
- package/dist/directory-lock.js +205 -0
- package/dist/directory-lock.js.map +1 -0
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +20 -6
- package/dist/evaluation-context.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/kernel.d.ts +32 -0
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +55 -1
- 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 +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
- package/dist/resource-context.d.ts +18 -0
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +72 -1
- package/dist/resource-context.js.map +1 -1
- package/dist/transports/oci/oci-client.d.ts +28 -1
- package/dist/transports/oci/oci-client.d.ts.map +1 -1
- package/dist/transports/oci/oci-client.js +24 -1
- package/dist/transports/oci/oci-client.js.map +1 -1
- package/dist/transports/oci/oci-transport.d.ts +20 -14
- package/dist/transports/oci/oci-transport.d.ts.map +1 -1
- package/dist/transports/oci/oci-transport.js +105 -46
- package/dist/transports/oci/oci-transport.js.map +1 -1
- package/dist/transports/registry-transport.d.ts +7 -2
- package/dist/transports/registry-transport.d.ts.map +1 -1
- package/dist/transports/registry-transport.js +8 -33
- package/dist/transports/registry-transport.js.map +1 -1
- package/dist/transports/transport-registry.d.ts +5 -4
- package/dist/transports/transport-registry.d.ts.map +1 -1
- package/dist/transports/transport-registry.js +4 -4
- package/dist/transports/transport-registry.js.map +1 -1
- package/dist/transports/transport.d.ts +27 -19
- package/dist/transports/transport.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/bundle/files-integrity.ts +34 -14
- package/src/bundle/module-artifact.ts +375 -0
- package/src/bundle/module-manifest.ts +22 -6
- package/src/controller-loader.ts +9 -4
- package/src/controller-loaders/bundle-loader.ts +57 -11
- package/src/controller-loaders/npm-loader.ts +3 -209
- package/src/controllers/resource-definition/resource-definition-controller.ts +19 -0
- package/src/directory-lock.ts +225 -0
- package/src/evaluation-context.ts +17 -7
- package/src/index.ts +10 -2
- package/src/kernel.ts +66 -0
- package/src/manifest-sources/local-manifest-cache-source.ts +1 -1
- package/src/resource-context.ts +78 -1
- package/src/transports/oci/oci-client.ts +32 -1
- package/src/transports/oci/oci-transport.ts +122 -51
- package/src/transports/registry-transport.ts +10 -38
- package/src/transports/transport-registry.ts +5 -5
- package/src/transports/transport.ts +32 -19
package/src/index.ts
CHANGED
|
@@ -23,11 +23,19 @@ export {
|
|
|
23
23
|
export { makeTarGz, readTarGz, type BundleEntry } from "./bundle/tar.js";
|
|
24
24
|
export {
|
|
25
25
|
computeFilesIntegrity,
|
|
26
|
-
|
|
26
|
+
injectLayerIndex,
|
|
27
27
|
type PayloadFile,
|
|
28
28
|
} from "./bundle/files-integrity.js";
|
|
29
|
+
export {
|
|
30
|
+
ModuleArtifact,
|
|
31
|
+
moduleArtifactFor,
|
|
32
|
+
moduleDirectoryFor,
|
|
33
|
+
hostPlatformTarget,
|
|
34
|
+
type MaterializedLayer,
|
|
35
|
+
} from "./bundle/module-artifact.js";
|
|
36
|
+
export { readOwnerManifest, type OwnerManifest } from "./bundle/module-manifest.js";
|
|
29
37
|
export type {
|
|
30
|
-
|
|
38
|
+
PayloadLayer,
|
|
31
39
|
PublishBundle,
|
|
32
40
|
PublishResult,
|
|
33
41
|
PublishOptions,
|
package/src/kernel.ts
CHANGED
|
@@ -49,8 +49,16 @@ import {
|
|
|
49
49
|
writeAnalysisStamp,
|
|
50
50
|
} from "./manifest-sources/analysis-stamp.js";
|
|
51
51
|
import {
|
|
52
|
+
cachePathForCanonical,
|
|
52
53
|
resolveCacheRoot,
|
|
54
|
+
resolveEntryDir,
|
|
53
55
|
} from "./manifest-sources/local-manifest-cache-source.js";
|
|
56
|
+
import { readOwnerManifest } from "./bundle/module-manifest.js";
|
|
57
|
+
import {
|
|
58
|
+
moduleArtifactFor,
|
|
59
|
+
moduleDirectoryFor,
|
|
60
|
+
type ModuleArtifact,
|
|
61
|
+
} from "./bundle/module-artifact.js";
|
|
54
62
|
import { defaultTransportRegistry } from "./transports/transport-registry.js";
|
|
55
63
|
import {
|
|
56
64
|
collectDeclaredEnvKeys,
|
|
@@ -131,6 +139,9 @@ export class Kernel implements IKernel {
|
|
|
131
139
|
/** The `.telo` cache root for this load, resolved once in `load()` and
|
|
132
140
|
* threaded to the validator, analysis stamp, and npm install root. */
|
|
133
141
|
private _cacheRoot?: string | null;
|
|
142
|
+
/** Per-module artifact handles, keyed by canonical manifest source. Rebuilt on
|
|
143
|
+
* every `load()`; empty for a graph of purely local / manifest-only modules. */
|
|
144
|
+
private readonly moduleArtifacts = new Map<string, ModuleArtifact>();
|
|
134
145
|
private _loadedGraph?: LoadedGraph;
|
|
135
146
|
// Lifecycle state — guards boot/runTargets/teardown/invoke transitions.
|
|
136
147
|
// teardown() is the only idempotent method; everything else throws on misuse.
|
|
@@ -425,6 +436,7 @@ export class Kernel implements IKernel {
|
|
|
425
436
|
);
|
|
426
437
|
}
|
|
427
438
|
this._loadedGraph = analysisGraph;
|
|
439
|
+
this.buildModuleArtifacts(analysisGraph, manifestsDir);
|
|
428
440
|
// Version reconciliation: an incompatible major mismatch is fatal (the
|
|
429
441
|
// hoist override would silently run the wrong major); a same-major hoist is
|
|
430
442
|
// advisory — the override already redirects every importer to the winner.
|
|
@@ -918,6 +930,60 @@ export class Kernel implements IKernel {
|
|
|
918
930
|
return this._cacheRoot ? `${this._cacheRoot}/npm` : undefined;
|
|
919
931
|
}
|
|
920
932
|
|
|
933
|
+
/**
|
|
934
|
+
* Build one {@link ModuleArtifact} per loaded module that ships a payload.
|
|
935
|
+
*
|
|
936
|
+
* Here, and not inside a controller loader, because this is the only point
|
|
937
|
+
* where both halves are in hand: the **pinned** ref the importer wrote
|
|
938
|
+
* (`requestedUrl`, integrity fragment included) and the manifest text that has
|
|
939
|
+
* already been verified against it. A loader sees only the canonical base URI,
|
|
940
|
+
* which carries no `#sha256-`, so a loader that fetched for itself would have
|
|
941
|
+
* to re-read the index off the cache directory — verification downgraded from
|
|
942
|
+
* "anchored at the importer's pin" to "trust whatever is on disk".
|
|
943
|
+
*
|
|
944
|
+
* Keyed by canonical `source`, which is what a definition's `metadata.source`
|
|
945
|
+
* carries, so a controller resolution can find its module's artifact.
|
|
946
|
+
*
|
|
947
|
+
* The trigger is "this manifest carries a `layers:` index", never "its source
|
|
948
|
+
* maps to a cache path". Those differ the moment the cache is warm:
|
|
949
|
+
* `LocalManifestCacheSource` serves a hit as a `file://` URL into
|
|
950
|
+
* `.telo/manifests/`, which no transport claims, so deriving the directory from
|
|
951
|
+
* `source` yields nothing and every OCI module would silently lose its artifact
|
|
952
|
+
* on the second and every later run — taking lazy asset materialization with it.
|
|
953
|
+
* The **pinned** `requestedUrl` survives a cache hit, so placement is derived
|
|
954
|
+
* from that, with a local `source` resolving to its own directory.
|
|
955
|
+
*/
|
|
956
|
+
private buildModuleArtifacts(graph: LoadedGraph, manifestsDir: string | undefined): void {
|
|
957
|
+
this.moduleArtifacts.clear();
|
|
958
|
+
const transports = defaultTransportRegistry(this.registryUrl);
|
|
959
|
+
const entryDir = this._entryUrl ? resolveEntryDir(this._entryUrl) ?? "" : "";
|
|
960
|
+
for (const [, module] of graph.modules) {
|
|
961
|
+
const file = module.owner;
|
|
962
|
+
if (this.moduleArtifacts.has(file.source)) continue;
|
|
963
|
+
const artifact = moduleArtifactFor({
|
|
964
|
+
pinnedRef: file.requestedUrl,
|
|
965
|
+
layers: readOwnerManifest(file.text).layers,
|
|
966
|
+
moduleDir: moduleDirectoryFor(
|
|
967
|
+
file.requestedUrl,
|
|
968
|
+
file.source,
|
|
969
|
+
entryDir,
|
|
970
|
+
this.registryUrl,
|
|
971
|
+
manifestsDir,
|
|
972
|
+
),
|
|
973
|
+
transports,
|
|
974
|
+
log: this.logging.kernelLogger(),
|
|
975
|
+
});
|
|
976
|
+
if (artifact) this.moduleArtifacts.set(file.source, artifact);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** The artifact of the module whose manifest resolved from `source`, or
|
|
981
|
+
* `undefined` for a module with no fetchable payload (already on disk,
|
|
982
|
+
* manifest-only, or not cacheable). */
|
|
983
|
+
getModuleArtifact(source: string | undefined): ModuleArtifact | undefined {
|
|
984
|
+
return source ? this.moduleArtifacts.get(source) : undefined;
|
|
985
|
+
}
|
|
986
|
+
|
|
921
987
|
/** Authored `kind` of a declared resource by name, from the static manifest
|
|
922
988
|
* set. Init-order-independent (unlike `resourceInstances`), so a controller
|
|
923
989
|
* resolving a `!ref <name>` sentinel before the target initializes can still
|
|
@@ -133,7 +133,7 @@ export class LocalManifestCacheSource implements ManifestSource {
|
|
|
133
133
|
export function cachePathForCanonical(
|
|
134
134
|
canonicalSource: string,
|
|
135
135
|
entryDir: string,
|
|
136
|
-
registryUrl: string,
|
|
136
|
+
registryUrl: string | undefined = DEFAULT_REGISTRY_URL,
|
|
137
137
|
manifestsDir?: string,
|
|
138
138
|
): string | null {
|
|
139
139
|
const cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
|
package/src/resource-context.ts
CHANGED
|
@@ -20,6 +20,9 @@ import {
|
|
|
20
20
|
type TypeRule,
|
|
21
21
|
} from "@telorun/sdk";
|
|
22
22
|
import { isRefSentinel } from "@telorun/templating";
|
|
23
|
+
import * as path from "path";
|
|
24
|
+
import { pathToFileURL } from "url";
|
|
25
|
+
import type { ModuleArtifact } from "./bundle/module-artifact.js";
|
|
23
26
|
import { hostEnv } from "./host-env.js";
|
|
24
27
|
import type { LoggingHost } from "./logging/logging-host.js";
|
|
25
28
|
import type { ScopeConfig } from "./logging/scope-config.js";
|
|
@@ -326,7 +329,27 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
326
329
|
describe: () => string,
|
|
327
330
|
expects?: string,
|
|
328
331
|
): T {
|
|
329
|
-
|
|
332
|
+
// Two things the raw resolver cannot do from a `{ moduleContext }` slice:
|
|
333
|
+
//
|
|
334
|
+
// - A `!ref` can reach a controller as the raw SENTINEL. Phase-5 injection is
|
|
335
|
+
// field-map-driven, and the field map does not descend into the inline
|
|
336
|
+
// declarations inside an `x-telo-scope` array, so a ref slot on a scoped
|
|
337
|
+
// resource is not an injection site. (Phase 2.5 does rewrite such a
|
|
338
|
+
// sentinel to `{kind, name}` when it can name a target, so the shape that
|
|
339
|
+
// arrives varies — both are accepted.) `ensureKindRef` is the same rescue
|
|
340
|
+
// the sentinel path already performs for hidden slots.
|
|
341
|
+
// - A scope-local name lives in the OWNING context, not the module, so a
|
|
342
|
+
// `with:`-scoped resource referencing a scoped sibling has to resolve in
|
|
343
|
+
// the same order `contextForName` and `ScopeContext.getInstance` use —
|
|
344
|
+
// scope-local first, module as the fallback — or CEL and `!ref` disagree
|
|
345
|
+
// about what a name means inside a scope.
|
|
346
|
+
const normalized = isRefSentinel(value) ? this.ensureKindRef(value) : value;
|
|
347
|
+
return resolveRefInstance(normalized, this, guard, describe, expects);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Name lookup with scope-local precedence, for {@link resolveRefInstance}. */
|
|
351
|
+
resolveLocalInstance(name: string): ResourceInstance | undefined {
|
|
352
|
+
return this.contextForName(name).resourceInstances.get(name)?.instance;
|
|
330
353
|
}
|
|
331
354
|
|
|
332
355
|
async run(name: string) {
|
|
@@ -553,6 +576,60 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
553
576
|
return this.kernel.getInstallRoot();
|
|
554
577
|
}
|
|
555
578
|
|
|
579
|
+
/** The artifact of the module whose manifest resolved from `source`. Kernel-only
|
|
580
|
+
* (it hands back a kernel class), reached by the resource-definition controller
|
|
581
|
+
* through {@link ModuleArtifactHost} rather than the SDK surface. */
|
|
582
|
+
getModuleArtifact(source: string | undefined): ModuleArtifact | undefined {
|
|
583
|
+
return this.kernel.getModuleArtifact(source);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Resolve a module-relative reference against the declaring module's own
|
|
588
|
+
* directory, materializing the layers that could carry it on first use.
|
|
589
|
+
*
|
|
590
|
+
* A URI, not a filesystem path: the SDK is cross-runtime, and a path is only
|
|
591
|
+
* what *this* kernel happens to return for a module whose files are local.
|
|
592
|
+
* An already-absolute URI (one with a scheme) passes through untouched; a bare
|
|
593
|
+
* absolute filesystem path is returned as a `file://` URI rather than being
|
|
594
|
+
* rebased onto the module directory.
|
|
595
|
+
*/
|
|
596
|
+
async resolveModuleFile(relative: string): Promise<string> {
|
|
597
|
+
// An absolute URI names its own location; a bare absolute path is already
|
|
598
|
+
// resolved and must not be rebased onto the module directory.
|
|
599
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(relative)) return relative;
|
|
600
|
+
if (path.isAbsolute(relative)) return pathToFileURL(relative).href;
|
|
601
|
+
|
|
602
|
+
const source = this.moduleContext.source;
|
|
603
|
+
const artifact = this.kernel.getModuleArtifact(source);
|
|
604
|
+
if (artifact) {
|
|
605
|
+
// Both the `assets` layer and `common` — the sink rule puts a file the
|
|
606
|
+
// author did not claim via `assets:` into `common`, and a module that ships
|
|
607
|
+
// static files with no bundled controller has no other route to its payload.
|
|
608
|
+
// Fetching only assets would leave such a module resolving into an empty
|
|
609
|
+
// directory.
|
|
610
|
+
await artifact.materializeModuleFiles();
|
|
611
|
+
return new URL(relative, pathToFileURL(path.join(artifact.directory, "/")).href).href;
|
|
612
|
+
}
|
|
613
|
+
// No artifact means no payload to fetch. That is normal for a module already
|
|
614
|
+
// on disk (development) or one that ships no files — but for a module reached
|
|
615
|
+
// over a non-local scheme it means the artifact carries no layer index, i.e. it
|
|
616
|
+
// predates layers. Raise the actionable error here rather than leaving each
|
|
617
|
+
// caller to invent its own message from a URI it cannot open.
|
|
618
|
+
if (!source.startsWith("file://") && !path.isAbsolute(source)) {
|
|
619
|
+
throw new RuntimeError(
|
|
620
|
+
"ERR_MODULE_FILES_UNAVAILABLE",
|
|
621
|
+
`Cannot resolve '${relative}' against module '${source}': the module's artifact ` +
|
|
622
|
+
`carries no layer index, so its files cannot be located. It was published by an ` +
|
|
623
|
+
`older Telo that wrote a single-blob artifact — republish the module, or import it ` +
|
|
624
|
+
`from a local path during development.`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
// Local module: resolve against the manifest URL, the same rule `include:`
|
|
628
|
+
// and sibling imports follow.
|
|
629
|
+
const base = source.startsWith("file://") ? source : pathToFileURL(source).href;
|
|
630
|
+
return new URL(relative, base).href;
|
|
631
|
+
}
|
|
632
|
+
|
|
556
633
|
on(event: string, handler: (payload?: any) => void | Promise<void>): void {
|
|
557
634
|
this.kernel.on(event, handler);
|
|
558
635
|
}
|
|
@@ -4,7 +4,34 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { assertPublicEgress } from "../egress-guard.js";
|
|
5
5
|
import { resolveDockerCredential } from "./docker-credentials.js";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/** The **manifest layer**: the blob carrying `telo.yaml`. The only layer located
|
|
8
|
+
* through the OCI manifest — its bytes are then verified against the import
|
|
9
|
+
* pin, and every other layer is addressed by a digest the pinned `layers:` index
|
|
10
|
+
* supplies. */
|
|
11
|
+
export const TELO_MANIFEST_LAYER_MEDIA_TYPE = "application/vnd.telo.module.manifest.v1+tar";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The pre-layers single-blob artifact: `telo.yaml` and the whole `files:` payload
|
|
15
|
+
* in one layer.
|
|
16
|
+
*
|
|
17
|
+
* Still read, because every module published so far carries it and the ones with
|
|
18
|
+
* no payload — the npm-backed majority — are perfectly usable from it: the read
|
|
19
|
+
* path only ever wanted `telo.yaml`, and this blob contains it. What such an
|
|
20
|
+
* artifact cannot supply is a `layers:` index, so a module that *does* ship a
|
|
21
|
+
* payload resolves its manifest and then fails at the controller with an
|
|
22
|
+
* actionable "republish" error. That is the intended, narrow break.
|
|
23
|
+
*/
|
|
24
|
+
export const TELO_LEGACY_LAYER_MEDIA_TYPE = "application/vnd.telo.module.v1+tar";
|
|
25
|
+
|
|
26
|
+
/** A **payload layer**: one controller selector's files, the `assets` layer, or
|
|
27
|
+
* the `common` layer. Which is which is read from the pinned index, never from
|
|
28
|
+
* the descriptor — the annotations below are for `docker manifest inspect`
|
|
29
|
+
* legibility only and are never read back. */
|
|
30
|
+
export const TELO_PAYLOAD_LAYER_MEDIA_TYPE = "application/vnd.telo.module.layer.v1+tar";
|
|
31
|
+
|
|
32
|
+
export const TELO_LAYER_ROLE_ANNOTATION = "run.telo.layer.role";
|
|
33
|
+
export const TELO_LAYER_SELECTOR_ANNOTATION = "run.telo.layer.selector";
|
|
34
|
+
|
|
8
35
|
export const OCI_MANIFEST_MEDIA_TYPE = "application/vnd.oci.image.manifest.v1+json";
|
|
9
36
|
export const OCI_EMPTY_CONFIG_MEDIA_TYPE = "application/vnd.oci.empty.v1+json";
|
|
10
37
|
|
|
@@ -25,6 +52,10 @@ export interface OciDescriptor {
|
|
|
25
52
|
size: number;
|
|
26
53
|
data?: string;
|
|
27
54
|
artifactType?: string;
|
|
55
|
+
/** Descriptive per-layer metadata (`run.telo.layer.*`). Written so native
|
|
56
|
+
* tooling can read the artifact's shape; never consumed by Telo, which takes
|
|
57
|
+
* role and selector from the pinned index instead. */
|
|
58
|
+
annotations?: Record<string, string>;
|
|
28
59
|
}
|
|
29
60
|
|
|
30
61
|
export interface OciManifest {
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DEFAULT_MANIFEST_FILENAME,
|
|
3
3
|
IntegrityError,
|
|
4
|
+
selectorKey,
|
|
4
5
|
sha256Base64Url,
|
|
5
6
|
verifyIntegrity,
|
|
7
|
+
type ArtifactLayer,
|
|
6
8
|
type ManifestCacheCoords,
|
|
7
9
|
type ManifestSource,
|
|
8
10
|
} from "@telorun/analyzer";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
9
12
|
|
|
10
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
computeFilesIntegrity,
|
|
15
|
+
injectLayerIndex,
|
|
16
|
+
type PayloadFile,
|
|
17
|
+
} from "../../bundle/files-integrity.js";
|
|
11
18
|
import { readOwnerManifest, type OwnerManifest } from "../../bundle/module-manifest.js";
|
|
12
19
|
import { makeTarGz, readTarGz, toPayloadFiles } from "../../bundle/tar.js";
|
|
13
20
|
import type {
|
|
14
|
-
FetchedArtifact,
|
|
15
21
|
PublishBundle,
|
|
16
22
|
PublishOptions,
|
|
17
23
|
PublishResult,
|
|
@@ -20,7 +26,12 @@ import type {
|
|
|
20
26
|
import {
|
|
21
27
|
OciClient,
|
|
22
28
|
OCI_MANIFEST_MEDIA_TYPE,
|
|
23
|
-
|
|
29
|
+
TELO_LAYER_ROLE_ANNOTATION,
|
|
30
|
+
TELO_LEGACY_LAYER_MEDIA_TYPE,
|
|
31
|
+
TELO_LAYER_SELECTOR_ANNOTATION,
|
|
32
|
+
TELO_MANIFEST_LAYER_MEDIA_TYPE,
|
|
33
|
+
TELO_PAYLOAD_LAYER_MEDIA_TYPE,
|
|
34
|
+
type OciDescriptor,
|
|
24
35
|
type OciManifest,
|
|
25
36
|
} from "./oci-client.js";
|
|
26
37
|
import {
|
|
@@ -31,23 +42,44 @@ import {
|
|
|
31
42
|
withRefVersion,
|
|
32
43
|
} from "./oci-ref.js";
|
|
33
44
|
|
|
34
|
-
/**
|
|
35
|
-
*
|
|
36
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Pull only the **manifest layer** and return its verified `telo.yaml` text.
|
|
47
|
+
*
|
|
48
|
+
* This is the one place the OCI manifest is load-bearing: it is fetched by a
|
|
49
|
+
* reference that is usually a mutable tag and Telo never hashes it, so it is used
|
|
50
|
+
* solely to locate the blob carrying `telo.yaml`. Those bytes are then checked
|
|
51
|
+
* against the import's inline pin, which is what makes the rest of the artifact
|
|
52
|
+
* safe to address from the `layers:` index inside them — tampering with the OCI
|
|
53
|
+
* manifest can only change *which* blob is offered as the manifest, and a
|
|
54
|
+
* substituted one fails the pin here.
|
|
55
|
+
*
|
|
56
|
+
* Payload layers are never pulled on this path, so reading a manifest no longer
|
|
57
|
+
* downloads a payload it discards.
|
|
58
|
+
*
|
|
59
|
+
* A pre-layers single-blob artifact is still read here: it contains `telo.yaml`
|
|
60
|
+
* too, which is all this path wants, so every already-published module keeps
|
|
61
|
+
* resolving. What it cannot offer is a `layers:` index — so a module that ships a
|
|
62
|
+
* payload gets a clear "republish" failure at the controller instead, while the
|
|
63
|
+
* npm-backed majority, which ships none, is unaffected.
|
|
64
|
+
*/
|
|
65
|
+
async function pullManifestLayer(ref: string): Promise<string> {
|
|
37
66
|
const { host, repo, reference, integrity } = parseOciRef(ref);
|
|
38
67
|
const client = new OciClient(host, repo);
|
|
39
68
|
const manifest = await client.pullManifest(reference);
|
|
40
69
|
const layer =
|
|
41
|
-
manifest.layers.find((l) => l.mediaType ===
|
|
70
|
+
manifest.layers.find((l) => l.mediaType === TELO_MANIFEST_LAYER_MEDIA_TYPE) ??
|
|
71
|
+
manifest.layers.find((l) => l.mediaType === TELO_LEGACY_LAYER_MEDIA_TYPE) ??
|
|
72
|
+
manifest.layers[0];
|
|
42
73
|
if (!layer) {
|
|
43
74
|
throw new Error(`OCI artifact ${ref} has no layers`);
|
|
44
75
|
}
|
|
45
|
-
const
|
|
46
|
-
const entries = await readTarGz(tar);
|
|
76
|
+
const entries = await readTarGz(await client.pullBlob(layer.digest));
|
|
47
77
|
|
|
48
78
|
const teloEntry = entries.find((e) => e.name === DEFAULT_MANIFEST_FILENAME);
|
|
49
79
|
if (!teloEntry) {
|
|
50
|
-
throw new Error(
|
|
80
|
+
throw new Error(
|
|
81
|
+
`OCI artifact ${ref} manifest layer does not contain ${DEFAULT_MANIFEST_FILENAME}`,
|
|
82
|
+
);
|
|
51
83
|
}
|
|
52
84
|
const manifestText =
|
|
53
85
|
typeof teloEntry.content === "string" ? teloEntry.content : teloEntry.content.toString("utf-8");
|
|
@@ -56,28 +88,18 @@ async function pullVerified(ref: string): Promise<FetchedArtifact> {
|
|
|
56
88
|
if (integrity) {
|
|
57
89
|
await verifyIntegrity(new TextEncoder().encode(manifestText), integrity, ref);
|
|
58
90
|
}
|
|
59
|
-
|
|
60
|
-
const files = toPayloadFiles(entries);
|
|
61
|
-
|
|
62
|
-
const { filesIntegrity } = readOwnerManifest(manifestText);
|
|
63
|
-
if (filesIntegrity) {
|
|
64
|
-
const actual = await computeFilesIntegrity(files);
|
|
65
|
-
if (actual !== filesIntegrity) {
|
|
66
|
-
throw new IntegrityError(
|
|
67
|
-
`Integrity check failed for ${ref}: filesIntegrity expected ${filesIntegrity}, ` +
|
|
68
|
-
`got ${actual}. The payload does not match the recorded hash.`,
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return { manifest: manifestText, files };
|
|
91
|
+
return manifestText;
|
|
74
92
|
}
|
|
75
93
|
|
|
76
94
|
/**
|
|
77
95
|
* OCI transport: `oci://host/repo@reference` modules on any OCI distribution
|
|
78
96
|
* registry (GHCR / ECR / Docker Hub / Harbor), over a hand-rolled minimal
|
|
79
|
-
* client. A module is
|
|
80
|
-
*
|
|
97
|
+
* client. A module is one OCI artifact whose layers are the module's layers —
|
|
98
|
+
* `telo.yaml` in its own blob, then one blob per controller selector, plus the
|
|
99
|
+
* `assets` and `common` blobs — so a client fetches only what it needs. A flat
|
|
100
|
+
* layer list, not an image index: the manifest and asset layers are
|
|
101
|
+
* platform-neutral, so a manifest list would duplicate them per platform entry
|
|
102
|
+
* and add a round trip for a selection made from the pinned index anyway.
|
|
81
103
|
*
|
|
82
104
|
* Not browser-reachable (token handshake, Docker credentials, tar extraction),
|
|
83
105
|
* so its resolution `source` is Node-only; the editor resolves `oci://` imports
|
|
@@ -90,7 +112,7 @@ export class OciTransport implements Transport {
|
|
|
90
112
|
this.source = {
|
|
91
113
|
supports: (url) => this.supports(url),
|
|
92
114
|
read: async (url) => {
|
|
93
|
-
const
|
|
115
|
+
const manifest = await pullManifestLayer(url);
|
|
94
116
|
const { host, repo, reference } = parseOciRef(url);
|
|
95
117
|
return { text: manifest, source: `${OCI_SCHEME}${host}/${repo}@${reference}` };
|
|
96
118
|
},
|
|
@@ -159,24 +181,38 @@ export class OciTransport implements Transport {
|
|
|
159
181
|
return new OciClient(host, repo).headManifest(reference);
|
|
160
182
|
}
|
|
161
183
|
|
|
162
|
-
|
|
163
|
-
|
|
184
|
+
/** Pull one payload layer by the `blob` digest the pinned index supplies. The
|
|
185
|
+
* OCI layer list is not consulted — a digest addresses a blob directly, so a
|
|
186
|
+
* republish that reorders layers is simply invisible here rather than a
|
|
187
|
+
* failure. Content verification is the artifact handle's, which holds the
|
|
188
|
+
* expected `integrity`. */
|
|
189
|
+
async fetchLayer(ref: string, blobDigest: string): Promise<PayloadFile[]> {
|
|
190
|
+
const { host, repo } = parseOciRef(ref);
|
|
191
|
+
const tar = await new OciClient(host, repo).pullBlob(blobDigest);
|
|
192
|
+
// Verify the transfer against the digest that addressed it. A registry is
|
|
193
|
+
// not trusted to return the blob that was asked for, and this is the only
|
|
194
|
+
// place the pushed bytes exist — the content digest checked after extraction
|
|
195
|
+
// covers the file set, not the archive that carried it.
|
|
196
|
+
const actual = `sha256:${createHash("sha256").update(tar).digest("hex")}`;
|
|
197
|
+
if (actual !== blobDigest) {
|
|
198
|
+
throw new IntegrityError(
|
|
199
|
+
`Blob digest mismatch fetching a layer of ${ref}: requested ${blobDigest}, ` +
|
|
200
|
+
`received ${actual}. The registry returned different bytes than were addressed.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return toPayloadFiles(await readTarGz(tar));
|
|
164
204
|
}
|
|
165
205
|
|
|
166
206
|
/** Hashes the **UTF-8 encoding of the extracted `telo.yaml`**, which is what
|
|
167
|
-
* `
|
|
207
|
+
* `pullManifestLayer` checks an inline `#sha256-…` pin against on the read
|
|
208
|
+
* path.
|
|
168
209
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* costs N full artifact pulls, and `pullVerified` also re-checks the
|
|
174
|
-
* dependency's `filesIntegrity`, so a corrupt *payload* upstream surfaces
|
|
175
|
-
* here as a pinning failure rather than a payload error. Both are acceptable
|
|
176
|
-
* for publish-time pinning, where correctness beats latency and refusing to
|
|
177
|
-
* pin against a corrupt dependency is the right outcome. */
|
|
210
|
+
* Deliberately uncached — a pin must hash what is published *now*, not a
|
|
211
|
+
* cached copy — but since `telo.yaml` is its own layer this costs one small
|
|
212
|
+
* blob per import rather than a full artifact pull, and a corrupt payload
|
|
213
|
+
* upstream no longer surfaces here as a pinning failure. */
|
|
178
214
|
async manifestHash(ref: string): Promise<string> {
|
|
179
|
-
const
|
|
215
|
+
const manifest = await pullManifestLayer(ref);
|
|
180
216
|
return `sha256-${await sha256Base64Url(new TextEncoder().encode(manifest))}`;
|
|
181
217
|
}
|
|
182
218
|
|
|
@@ -221,27 +257,62 @@ export class OciTransport implements Transport {
|
|
|
221
257
|
);
|
|
222
258
|
}
|
|
223
259
|
const tag = identity.version;
|
|
260
|
+
const client = new OciClient(host, repo);
|
|
224
261
|
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
262
|
+
// Push every payload layer first, collecting the digests that address them.
|
|
263
|
+
// This ordering is what keeps the index non-circular: the manifest layer is
|
|
264
|
+
// pushed last and names only the layers pushed before it, never itself.
|
|
265
|
+
const payloadDescriptors: OciDescriptor[] = [];
|
|
266
|
+
const index: ArtifactLayer[] = [];
|
|
267
|
+
for (const layer of bundle.layers) {
|
|
268
|
+
if (layer.files.length === 0) continue;
|
|
269
|
+
const tar = await makeTarGz(
|
|
270
|
+
layer.files.map((f) => ({ name: f.name, content: Buffer.from(f.content) })),
|
|
271
|
+
);
|
|
272
|
+
const blob = await client.pushBlob(tar);
|
|
273
|
+
payloadDescriptors.push({
|
|
274
|
+
mediaType: TELO_PAYLOAD_LAYER_MEDIA_TYPE,
|
|
275
|
+
digest: blob,
|
|
276
|
+
size: tar.length,
|
|
277
|
+
annotations: {
|
|
278
|
+
[TELO_LAYER_ROLE_ANNOTATION]: layer.role,
|
|
279
|
+
...(layer.selector
|
|
280
|
+
? { [TELO_LAYER_SELECTOR_ANNOTATION]: selectorKey(layer.selector) }
|
|
281
|
+
: {}),
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
index.push({
|
|
285
|
+
role: layer.role,
|
|
286
|
+
...(layer.selector ? { selector: layer.selector } : {}),
|
|
287
|
+
blob,
|
|
288
|
+
integrity: await computeFilesIntegrity(layer.files),
|
|
289
|
+
});
|
|
229
290
|
}
|
|
230
|
-
|
|
291
|
+
|
|
292
|
+
// Inject the index, then push telo.yaml as its own layer so a manifest read
|
|
293
|
+
// never has to pull a payload.
|
|
294
|
+
const manifestText =
|
|
295
|
+
index.length > 0 ? injectLayerIndex(bundle.manifest, index) : bundle.manifest;
|
|
296
|
+
const manifestTar = await makeTarGz([
|
|
231
297
|
{ name: DEFAULT_MANIFEST_FILENAME, content: manifestText },
|
|
232
|
-
...bundle.files.map((f) => ({ name: f.name, content: Buffer.from(f.content) })),
|
|
233
298
|
]);
|
|
299
|
+
const manifestBlob = await client.pushBlob(manifestTar);
|
|
234
300
|
|
|
235
|
-
const client = new OciClient(host, repo);
|
|
236
|
-
const layerDigest = await client.pushBlob(tar);
|
|
237
301
|
const config = await client.pushEmptyConfig();
|
|
238
302
|
const annotations = OciTransport.annotationsFor(identity);
|
|
239
303
|
const manifest: OciManifest = {
|
|
240
304
|
schemaVersion: 2,
|
|
241
305
|
mediaType: OCI_MANIFEST_MEDIA_TYPE,
|
|
242
|
-
artifactType:
|
|
306
|
+
artifactType: TELO_MANIFEST_LAYER_MEDIA_TYPE,
|
|
243
307
|
config,
|
|
244
|
-
layers: [
|
|
308
|
+
layers: [
|
|
309
|
+
{
|
|
310
|
+
mediaType: TELO_MANIFEST_LAYER_MEDIA_TYPE,
|
|
311
|
+
digest: manifestBlob,
|
|
312
|
+
size: manifestTar.length,
|
|
313
|
+
},
|
|
314
|
+
...payloadDescriptors,
|
|
315
|
+
],
|
|
245
316
|
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
|
|
246
317
|
};
|
|
247
318
|
await client.pushManifest(tag, manifest);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DEFAULT_MANIFEST_FILENAME,
|
|
3
3
|
HttpSource,
|
|
4
|
-
IntegrityError,
|
|
5
4
|
RegistrySource,
|
|
6
5
|
isRegistryRef,
|
|
7
6
|
parseModuleRef,
|
|
@@ -15,12 +14,9 @@ import {
|
|
|
15
14
|
import { fetchOrThrow } from "@telorun/sdk";
|
|
16
15
|
import { createHash } from "crypto";
|
|
17
16
|
|
|
18
|
-
import {
|
|
19
|
-
import { readOwnerManifest } from "../bundle/module-manifest.js";
|
|
20
|
-
import { readTarGz, toPayloadFiles } from "../bundle/tar.js";
|
|
17
|
+
import type { PayloadFile } from "../bundle/files-integrity.js";
|
|
21
18
|
import { assertPublicEgress } from "./egress-guard.js";
|
|
22
19
|
import type {
|
|
23
|
-
FetchedArtifact,
|
|
24
20
|
PublishBundle,
|
|
25
21
|
PublishOptions,
|
|
26
22
|
PublishResult,
|
|
@@ -272,39 +268,15 @@ export class RegistryTransport implements Transport {
|
|
|
272
268
|
return `sha256-${await sha256Base64Url(bytes)}`;
|
|
273
269
|
}
|
|
274
270
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const res = await fetchOrThrow(tarUrl, undefined, {
|
|
285
|
-
operation: "Module payload download",
|
|
286
|
-
setting: "--registry / TELO_REGISTRY",
|
|
287
|
-
});
|
|
288
|
-
if (!res.ok) {
|
|
289
|
-
throw new Error(`could not fetch bundle ${tarUrl}: ${res.status} ${res.statusText}`);
|
|
290
|
-
}
|
|
291
|
-
const files = toPayloadFiles(await readTarGz(Buffer.from(await res.arrayBuffer())));
|
|
292
|
-
|
|
293
|
-
// Verify the payload against the manifest's `filesIntegrity` before handing
|
|
294
|
-
// it back — a mismatch is terminal (a tampered bundle must never be used).
|
|
295
|
-
// The manifest that carries the hash is itself pinned by the inline hash.
|
|
296
|
-
if (meta.filesIntegrity) {
|
|
297
|
-
const actual = await computeFilesIntegrity(files);
|
|
298
|
-
if (actual !== meta.filesIntegrity) {
|
|
299
|
-
throw new IntegrityError(
|
|
300
|
-
`Integrity check failed for bundle ${tarUrl}: filesIntegrity expected ` +
|
|
301
|
-
`${meta.filesIntegrity}, got ${actual}. The payload does not match the recorded ` +
|
|
302
|
-
`hash — the module may have been tampered with or republished.`,
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
return { manifest, files };
|
|
271
|
+
/** Layered artifacts are an OCI concept, and the registry origin is read-only
|
|
272
|
+
* — nothing has ever published a payload here, so there is no layer to pull.
|
|
273
|
+
* A module reached over the registry is manifest-only, and its controllers
|
|
274
|
+
* come from npm. */
|
|
275
|
+
async fetchLayer(ref: string, blobDigest: string): Promise<PayloadFile[]> {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`Cannot fetch layer ${blobDigest} of ${ref}: the Telo registry serves manifests only. ` +
|
|
278
|
+
`A module with a bundled payload is published as an OCI artifact (oci://host/repo).`,
|
|
279
|
+
);
|
|
308
280
|
}
|
|
309
281
|
|
|
310
282
|
async publish(
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ManifestCacheCoords, ManifestSource } from "@telorun/analyzer";
|
|
2
2
|
|
|
3
|
+
import type { PayloadFile } from "../bundle/files-integrity.js";
|
|
3
4
|
import { OciTransport } from "./oci/oci-transport.js";
|
|
4
5
|
import { RegistryTransport } from "./registry-transport.js";
|
|
5
6
|
import type {
|
|
6
|
-
FetchedArtifact,
|
|
7
7
|
PublishBundle,
|
|
8
8
|
PublishOptions,
|
|
9
9
|
PublishResult,
|
|
@@ -41,10 +41,10 @@ export class TransportRegistry {
|
|
|
41
41
|
return this.require(ref).listVersions(ref);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
45
|
-
* owns the ref. */
|
|
46
|
-
|
|
47
|
-
return this.require(ref).
|
|
44
|
+
/** One payload layer of `ref`, addressed by the `blob` digest its pinned index
|
|
45
|
+
* entry carries. Throws when no transport owns the ref. */
|
|
46
|
+
fetchLayer(ref: string, blobDigest: string): Promise<PayloadFile[]> {
|
|
47
|
+
return this.require(ref).fetchLayer(ref, blobDigest);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** Cheap content-identity digest for `ref` via its owning transport; `null`
|