@telorun/kernel 0.42.0 → 0.44.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/controllers/resource-definition/resource-definition-controller.d.ts +2 -0
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +46 -0
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/controllers/resource-definition/resource-inherited-controller.d.ts +21 -0
- package/dist/controllers/resource-definition/resource-inherited-controller.d.ts.map +1 -0
- package/dist/controllers/resource-definition/resource-inherited-controller.js +130 -0
- package/dist/controllers/resource-definition/resource-inherited-controller.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 +11 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +21 -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/resource-context.d.ts +8 -0
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +10 -0
- package/dist/resource-context.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 +7 -4
- 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/controllers/resource-definition/resource-definition-controller.ts +65 -0
- package/src/controllers/resource-definition/resource-inherited-controller.ts +167 -0
- package/src/index.ts +22 -0
- package/src/kernel.ts +24 -2
- package/src/manifest-sources/local-manifest-cache-source.ts +14 -99
- package/src/resource-context.ts +14 -0
- 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,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
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { resolveDockerCredential } from "./docker-credentials.js";
|
|
4
|
+
|
|
5
|
+
export const TELO_LAYER_MEDIA_TYPE = "application/vnd.telo.module.v1+tar";
|
|
6
|
+
export const OCI_MANIFEST_MEDIA_TYPE = "application/vnd.oci.image.manifest.v1+json";
|
|
7
|
+
export const OCI_EMPTY_CONFIG_MEDIA_TYPE = "application/vnd.oci.empty.v1+json";
|
|
8
|
+
|
|
9
|
+
/** The standard OCI empty descriptor — config `{}` (2 bytes). */
|
|
10
|
+
const EMPTY_CONFIG = Buffer.from("{}", "utf-8");
|
|
11
|
+
const EMPTY_CONFIG_DIGEST = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a";
|
|
12
|
+
|
|
13
|
+
const MANIFEST_ACCEPT = [
|
|
14
|
+
OCI_MANIFEST_MEDIA_TYPE,
|
|
15
|
+
"application/vnd.oci.image.index.v1+json",
|
|
16
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
17
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
18
|
+
].join(", ");
|
|
19
|
+
|
|
20
|
+
export interface OciDescriptor {
|
|
21
|
+
mediaType: string;
|
|
22
|
+
digest: string;
|
|
23
|
+
size: number;
|
|
24
|
+
data?: string;
|
|
25
|
+
artifactType?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface OciManifest {
|
|
29
|
+
schemaVersion: number;
|
|
30
|
+
mediaType?: string;
|
|
31
|
+
artifactType?: string;
|
|
32
|
+
config: OciDescriptor;
|
|
33
|
+
layers: OciDescriptor[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sha256Hex(bytes: Uint8Array): string {
|
|
37
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Parse a `WWW-Authenticate: Bearer realm="...",service="...",scope="..."` header. */
|
|
41
|
+
function parseBearerChallenge(header: string): Record<string, string> {
|
|
42
|
+
const out: Record<string, string> = {};
|
|
43
|
+
const params = header.replace(/^Bearer\s+/i, "");
|
|
44
|
+
for (const m of params.matchAll(/([a-z]+)="([^"]*)"/gi)) {
|
|
45
|
+
out[m[1].toLowerCase()] = m[2];
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A minimal OCI distribution (registry v2) client — exactly the surface Telo
|
|
52
|
+
* needs: pull manifest / pull blob / push blob / push manifest / list tags,
|
|
53
|
+
* with the `WWW-Authenticate` bearer-token handshake and the ambient Docker
|
|
54
|
+
* credential chain. Hand-rolled rather than depending on a stale/Deno-only
|
|
55
|
+
* client (see plan Decisions). One instance per `(host, repo)`; tokens are
|
|
56
|
+
* cached per scope for the instance's lifetime.
|
|
57
|
+
*/
|
|
58
|
+
export class OciClient {
|
|
59
|
+
private readonly tokenByScope = new Map<string, string>();
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
private readonly host: string,
|
|
63
|
+
private readonly repo: string,
|
|
64
|
+
) {}
|
|
65
|
+
|
|
66
|
+
private base(): string {
|
|
67
|
+
return `https://${this.host}/v2/${this.repo}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private pullScope(): string {
|
|
71
|
+
return `repository:${this.repo}:pull`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private pushScope(): string {
|
|
75
|
+
return `repository:${this.repo}:pull,push`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Fetch with the bearer-token dance: try (cached token if any), and on 401
|
|
79
|
+
* resolve a token from the `WWW-Authenticate` challenge and retry once. */
|
|
80
|
+
private async authedFetch(
|
|
81
|
+
url: string,
|
|
82
|
+
init: RequestInit,
|
|
83
|
+
scope: string,
|
|
84
|
+
): Promise<Response> {
|
|
85
|
+
const withToken = (token?: string): RequestInit => {
|
|
86
|
+
const headers = new Headers(init.headers);
|
|
87
|
+
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
88
|
+
return { ...init, headers };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const cached = this.tokenByScope.get(scope);
|
|
92
|
+
let res = await fetch(url, withToken(cached));
|
|
93
|
+
if (res.status !== 401) return res;
|
|
94
|
+
|
|
95
|
+
const challenge = res.headers.get("www-authenticate");
|
|
96
|
+
if (!challenge) return res;
|
|
97
|
+
const token = await this.fetchToken(challenge, scope);
|
|
98
|
+
if (!token) return res;
|
|
99
|
+
this.tokenByScope.set(scope, token);
|
|
100
|
+
// Drain the 401 body so the connection can be reused.
|
|
101
|
+
await res.text().catch(() => {});
|
|
102
|
+
return fetch(url, withToken(token));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Exchange a bearer challenge for a token, authenticating to the token
|
|
106
|
+
* service with Docker credentials when available (else anonymous). */
|
|
107
|
+
private async fetchToken(challenge: string, scope: string): Promise<string | null> {
|
|
108
|
+
const params = parseBearerChallenge(challenge);
|
|
109
|
+
if (!params.realm) return null;
|
|
110
|
+
const tokenUrl = new URL(params.realm);
|
|
111
|
+
if (params.service) tokenUrl.searchParams.set("service", params.service);
|
|
112
|
+
tokenUrl.searchParams.set("scope", params.scope || scope);
|
|
113
|
+
|
|
114
|
+
const headers = new Headers();
|
|
115
|
+
const cred = await resolveDockerCredential(this.host);
|
|
116
|
+
if (cred) {
|
|
117
|
+
const basic = Buffer.from(`${cred.username}:${cred.password}`).toString("base64");
|
|
118
|
+
headers.set("authorization", `Basic ${basic}`);
|
|
119
|
+
}
|
|
120
|
+
const res = await fetch(tokenUrl.href, { headers });
|
|
121
|
+
if (!res.ok) return null;
|
|
122
|
+
const body = (await res.json()) as { token?: string; access_token?: string };
|
|
123
|
+
return body.token ?? body.access_token ?? null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async pullManifest(reference: string): Promise<OciManifest> {
|
|
127
|
+
const res = await this.authedFetch(
|
|
128
|
+
`${this.base()}/manifests/${reference}`,
|
|
129
|
+
{ headers: { accept: MANIFEST_ACCEPT } },
|
|
130
|
+
this.pullScope(),
|
|
131
|
+
);
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`OCI pull manifest ${this.repo}:${reference} on ${this.host} failed: ${res.status} ${res.statusText}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return (await res.json()) as OciManifest;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async pullBlob(digest: string): Promise<Buffer> {
|
|
141
|
+
const res = await this.authedFetch(`${this.base()}/blobs/${digest}`, {}, this.pullScope());
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`OCI pull blob ${digest} from ${this.repo} on ${this.host} failed: ${res.status} ${res.statusText}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return Buffer.from(await res.arrayBuffer());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async listTags(): Promise<string[]> {
|
|
151
|
+
const res = await this.authedFetch(`${this.base()}/tags/list`, {}, this.pullScope());
|
|
152
|
+
if (res.status === 404) return [];
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`OCI list tags for ${this.repo} on ${this.host} failed: ${res.status} ${res.statusText}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const body = (await res.json()) as { tags?: string[] | null };
|
|
159
|
+
return Array.isArray(body.tags) ? body.tags : [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Upload `bytes` as a blob (skipped when already present), returning its
|
|
163
|
+
* `sha256:<hex>` digest. Two-step upload: obtain a session, then PUT with
|
|
164
|
+
* the digest. */
|
|
165
|
+
async pushBlob(bytes: Uint8Array): Promise<string> {
|
|
166
|
+
const digest = `sha256:${sha256Hex(bytes)}`;
|
|
167
|
+
|
|
168
|
+
const head = await this.authedFetch(
|
|
169
|
+
`${this.base()}/blobs/${digest}`,
|
|
170
|
+
{ method: "HEAD" },
|
|
171
|
+
this.pushScope(),
|
|
172
|
+
);
|
|
173
|
+
if (head.ok) return digest; // already present
|
|
174
|
+
await head.text().catch(() => {});
|
|
175
|
+
|
|
176
|
+
const start = await this.authedFetch(
|
|
177
|
+
`${this.base()}/blobs/uploads/`,
|
|
178
|
+
{ method: "POST" },
|
|
179
|
+
this.pushScope(),
|
|
180
|
+
);
|
|
181
|
+
if (start.status !== 202) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`OCI blob upload start for ${this.repo} on ${this.host} failed: ${start.status} ${start.statusText}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const location = start.headers.get("location");
|
|
187
|
+
await start.text().catch(() => {});
|
|
188
|
+
if (!location) {
|
|
189
|
+
throw new Error(`OCI blob upload for ${this.repo} returned no Location header`);
|
|
190
|
+
}
|
|
191
|
+
const uploadUrl = new URL(location, `https://${this.host}`);
|
|
192
|
+
uploadUrl.searchParams.set("digest", digest);
|
|
193
|
+
|
|
194
|
+
const put = await this.authedFetch(
|
|
195
|
+
uploadUrl.href,
|
|
196
|
+
{
|
|
197
|
+
method: "PUT",
|
|
198
|
+
headers: { "content-type": "application/octet-stream" },
|
|
199
|
+
body: bytes,
|
|
200
|
+
},
|
|
201
|
+
this.pushScope(),
|
|
202
|
+
);
|
|
203
|
+
if (put.status !== 201) {
|
|
204
|
+
const detail = await put.text().catch(() => "");
|
|
205
|
+
throw new Error(
|
|
206
|
+
`OCI blob PUT for ${this.repo} on ${this.host} failed: ${put.status} ${put.statusText} ${detail}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return digest;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async pushManifest(reference: string, manifest: OciManifest): Promise<void> {
|
|
213
|
+
const body = Buffer.from(JSON.stringify(manifest), "utf-8");
|
|
214
|
+
const res = await this.authedFetch(
|
|
215
|
+
`${this.base()}/manifests/${reference}`,
|
|
216
|
+
{ method: "PUT", headers: { "content-type": OCI_MANIFEST_MEDIA_TYPE }, body },
|
|
217
|
+
this.pushScope(),
|
|
218
|
+
);
|
|
219
|
+
if (res.status !== 201) {
|
|
220
|
+
const detail = await res.text().catch(() => "");
|
|
221
|
+
throw new Error(
|
|
222
|
+
`OCI push manifest ${this.repo}:${reference} on ${this.host} failed: ${res.status} ${res.statusText} ${detail}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Push the standard empty config blob and return its descriptor. */
|
|
228
|
+
async pushEmptyConfig(): Promise<OciDescriptor> {
|
|
229
|
+
await this.pushBlob(EMPTY_CONFIG);
|
|
230
|
+
return {
|
|
231
|
+
mediaType: OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
|
232
|
+
digest: EMPTY_CONFIG_DIGEST,
|
|
233
|
+
size: EMPTY_CONFIG.length,
|
|
234
|
+
data: EMPTY_CONFIG.toString("base64"),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { splitIntegrity } from "@telorun/analyzer";
|
|
2
|
+
|
|
3
|
+
export const OCI_SCHEME = "oci://";
|
|
4
|
+
|
|
5
|
+
/** A parsed `oci://host/repo@reference` module ref.
|
|
6
|
+
*
|
|
7
|
+
* - `host` — the registry host (`ghcr.io`, `123.dkr.ecr.us-east-1.amazonaws.com`).
|
|
8
|
+
* - `repo` — the repository path (`aws/telo-s3`), possibly multi-segment.
|
|
9
|
+
* - `reference` — a tag (`1.2.0`) or a digest (`sha256:...`); the OCI address.
|
|
10
|
+
* - `integrity` — Telo's inline `sha256-<base64url>` hash when the ref is pinned
|
|
11
|
+
* (authoritative across transports; the OCI digest is only corroborating). */
|
|
12
|
+
export interface ParsedOciRef {
|
|
13
|
+
host: string;
|
|
14
|
+
repo: string;
|
|
15
|
+
reference: string;
|
|
16
|
+
integrity?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** True when `ref` uses the `oci://` scheme (integrity fragment tolerated). */
|
|
20
|
+
export function isOciRef(ref: string): boolean {
|
|
21
|
+
return splitIntegrity(ref).base.startsWith(OCI_SCHEME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Parse `oci://host/repo@reference[#sha256-...]`. Throws on a malformed ref.
|
|
25
|
+
* A `reference` may be a tag or a `sha256:` digest; when absent (`@` omitted)
|
|
26
|
+
* it defaults to `latest`, matching OCI tooling. */
|
|
27
|
+
export function parseOciRef(ref: string): ParsedOciRef {
|
|
28
|
+
const { base, integrity } = splitIntegrity(ref);
|
|
29
|
+
if (!base.startsWith(OCI_SCHEME)) {
|
|
30
|
+
throw new Error(`Invalid OCI reference '${ref}', expected oci://host/repo@reference`);
|
|
31
|
+
}
|
|
32
|
+
const rest = base.slice(OCI_SCHEME.length);
|
|
33
|
+
const slash = rest.indexOf("/");
|
|
34
|
+
if (slash <= 0) {
|
|
35
|
+
throw new Error(`Invalid OCI reference '${ref}', missing repository path after host`);
|
|
36
|
+
}
|
|
37
|
+
const host = rest.slice(0, slash);
|
|
38
|
+
let repoAndRef = rest.slice(slash + 1);
|
|
39
|
+
|
|
40
|
+
let reference = "latest";
|
|
41
|
+
const at = repoAndRef.lastIndexOf("@");
|
|
42
|
+
// A digest reference is `repo@sha256:...` — the `@` before `sha256:` is the
|
|
43
|
+
// separator, not part of the repo. A tag reference has no `@`.
|
|
44
|
+
if (at > 0) {
|
|
45
|
+
reference = repoAndRef.slice(at + 1);
|
|
46
|
+
repoAndRef = repoAndRef.slice(0, at);
|
|
47
|
+
}
|
|
48
|
+
const repo = repoAndRef;
|
|
49
|
+
if (!host || !repo || !reference) {
|
|
50
|
+
throw new Error(`Invalid OCI reference '${ref}', expected oci://host/repo@reference`);
|
|
51
|
+
}
|
|
52
|
+
return { host, repo, reference, integrity };
|
|
53
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MANIFEST_FILENAME,
|
|
3
|
+
IntegrityError,
|
|
4
|
+
verifyIntegrity,
|
|
5
|
+
type ManifestSource,
|
|
6
|
+
} from "@telorun/analyzer";
|
|
7
|
+
|
|
8
|
+
import { computeFilesIntegrity, injectFilesIntegrity } from "../../bundle/files-integrity.js";
|
|
9
|
+
import { readOwnerManifest } from "../../bundle/module-manifest.js";
|
|
10
|
+
import { makeTarGz, readTarGz, toPayloadFiles } from "../../bundle/tar.js";
|
|
11
|
+
import type {
|
|
12
|
+
FetchedArtifact,
|
|
13
|
+
PublishBundle,
|
|
14
|
+
PublishOptions,
|
|
15
|
+
PublishResult,
|
|
16
|
+
SiblingIdentity,
|
|
17
|
+
Transport,
|
|
18
|
+
} from "../transport.js";
|
|
19
|
+
import {
|
|
20
|
+
OciClient,
|
|
21
|
+
OCI_MANIFEST_MEDIA_TYPE,
|
|
22
|
+
TELO_LAYER_MEDIA_TYPE,
|
|
23
|
+
type OciManifest,
|
|
24
|
+
} from "./oci-client.js";
|
|
25
|
+
import { OCI_SCHEME, isOciRef, parseOciRef } from "./oci-ref.js";
|
|
26
|
+
|
|
27
|
+
/** Pull the module blob, returning its extracted entries and the `telo.yaml`
|
|
28
|
+
* bytes, verified against the ref's inline hash and its own `filesIntegrity`. */
|
|
29
|
+
async function pullVerified(ref: string): Promise<FetchedArtifact> {
|
|
30
|
+
const { host, repo, reference, integrity } = parseOciRef(ref);
|
|
31
|
+
const client = new OciClient(host, repo);
|
|
32
|
+
const manifest = await client.pullManifest(reference);
|
|
33
|
+
const layer =
|
|
34
|
+
manifest.layers.find((l) => l.mediaType === TELO_LAYER_MEDIA_TYPE) ?? manifest.layers[0];
|
|
35
|
+
if (!layer) {
|
|
36
|
+
throw new Error(`OCI artifact ${ref} has no layers`);
|
|
37
|
+
}
|
|
38
|
+
const tar = await client.pullBlob(layer.digest);
|
|
39
|
+
const entries = await readTarGz(tar);
|
|
40
|
+
|
|
41
|
+
const teloEntry = entries.find((e) => e.name === DEFAULT_MANIFEST_FILENAME);
|
|
42
|
+
if (!teloEntry) {
|
|
43
|
+
throw new Error(`OCI artifact ${ref} blob does not contain ${DEFAULT_MANIFEST_FILENAME}`);
|
|
44
|
+
}
|
|
45
|
+
const manifestText =
|
|
46
|
+
typeof teloEntry.content === "string" ? teloEntry.content : teloEntry.content.toString("utf-8");
|
|
47
|
+
|
|
48
|
+
// Telo's inline hash is authoritative; the OCI content digest only corroborates.
|
|
49
|
+
if (integrity) {
|
|
50
|
+
await verifyIntegrity(new TextEncoder().encode(manifestText), integrity, ref);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const files = toPayloadFiles(entries);
|
|
54
|
+
|
|
55
|
+
const { filesIntegrity } = readOwnerManifest(manifestText);
|
|
56
|
+
if (filesIntegrity) {
|
|
57
|
+
const actual = await computeFilesIntegrity(files);
|
|
58
|
+
if (actual !== filesIntegrity) {
|
|
59
|
+
throw new IntegrityError(
|
|
60
|
+
`Integrity check failed for ${ref}: filesIntegrity expected ${filesIntegrity}, ` +
|
|
61
|
+
`got ${actual}. The payload does not match the recorded hash.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { manifest: manifestText, files };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* OCI transport: `oci://host/repo@reference` modules on any OCI distribution
|
|
71
|
+
* registry (GHCR / ECR / Docker Hub / Harbor), over a hand-rolled minimal
|
|
72
|
+
* client. A module is a single artifact — one tar blob carrying `telo.yaml`
|
|
73
|
+
* and the `files:` payload — pushed under a standard OCI artifact manifest.
|
|
74
|
+
*
|
|
75
|
+
* Not browser-reachable (token handshake, Docker credentials, tar extraction),
|
|
76
|
+
* so its resolution `source` is Node-only; the editor resolves `oci://` imports
|
|
77
|
+
* through the discovery hub instead.
|
|
78
|
+
*/
|
|
79
|
+
export class OciTransport implements Transport {
|
|
80
|
+
readonly source: ManifestSource;
|
|
81
|
+
|
|
82
|
+
constructor() {
|
|
83
|
+
this.source = {
|
|
84
|
+
supports: (url) => this.supports(url),
|
|
85
|
+
read: async (url) => {
|
|
86
|
+
const { manifest } = await pullVerified(url);
|
|
87
|
+
const { host, repo, reference } = parseOciRef(url);
|
|
88
|
+
return { text: manifest, source: `${OCI_SCHEME}${host}/${repo}@${reference}` };
|
|
89
|
+
},
|
|
90
|
+
resolveRelative: (base, relative) => this.resolveRelative(base, relative),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
supports(ref: string): boolean {
|
|
95
|
+
return isOciRef(ref);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
cacheLocation(ref: string): string[] | null {
|
|
99
|
+
let parsed: ReturnType<typeof parseOciRef>;
|
|
100
|
+
try {
|
|
101
|
+
parsed = parseOciRef(ref);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return ["__oci", parsed.host, ...parsed.repo.split("/"), parsed.reference];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Resolve a relative import against an `oci://` base, normalizing the repo to
|
|
109
|
+
* a directory base so `../lib` under `oci://ghcr.io/aws/my-app` →
|
|
110
|
+
* `oci://ghcr.io/aws/lib` (never `oci://ghcr.io/lib`). Non-relative refs pass
|
|
111
|
+
* through. The reference/tag is dropped — the caller re-pins from the
|
|
112
|
+
* sibling's own version. */
|
|
113
|
+
resolveRelative(base: string, relative: string): string {
|
|
114
|
+
if (!relative.startsWith(".") && !relative.startsWith("/")) return relative;
|
|
115
|
+
const { host, repo } = parseOciRef(base);
|
|
116
|
+
// Resolve the repo path with standard URL semantics against a directory base.
|
|
117
|
+
const resolved = new URL(relative, `https://${host}/${repo}/`);
|
|
118
|
+
const newRepo = resolved.pathname.replace(/^\/+/, "");
|
|
119
|
+
return `${OCI_SCHEME}${host}/${newRepo}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async listVersions(ref: string): Promise<string[] | null> {
|
|
123
|
+
const { host, repo } = parseOciRef(ref);
|
|
124
|
+
const tags = await new OciClient(host, repo).listTags();
|
|
125
|
+
return tags;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async fetchArtifact(ref: string): Promise<FetchedArtifact> {
|
|
129
|
+
return pullVerified(ref);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async publish(
|
|
133
|
+
destination: string,
|
|
134
|
+
bundle: PublishBundle,
|
|
135
|
+
_opts: PublishOptions = {},
|
|
136
|
+
): Promise<PublishResult> {
|
|
137
|
+
const identity = readOwnerManifest(bundle.manifest);
|
|
138
|
+
if (!identity.version) {
|
|
139
|
+
throw new Error("OCI publish requires metadata.version (used as the tag).");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Destination is a full repo (`oci://host/repo`) or host-only
|
|
143
|
+
// (`oci://host`), which defaults the repo to `<namespace>/<name>`.
|
|
144
|
+
const afterScheme = destination.replace(/^oci:\/\//, "").replace(/\/+$/, "");
|
|
145
|
+
const slash = afterScheme.indexOf("/");
|
|
146
|
+
const host = slash > 0 ? afterScheme.slice(0, slash) : afterScheme;
|
|
147
|
+
let repo = slash > 0 ? afterScheme.slice(slash + 1) : "";
|
|
148
|
+
if (!repo) {
|
|
149
|
+
if (!identity.namespace || !identity.name) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`OCI publish to host-only '${destination}' needs metadata.namespace and metadata.name to default the repo.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
repo = `${identity.namespace}/${identity.name}`;
|
|
155
|
+
}
|
|
156
|
+
const tag = identity.version;
|
|
157
|
+
|
|
158
|
+
// Pin the payload, then pack telo.yaml + files into the single module blob.
|
|
159
|
+
let manifestText = bundle.manifest;
|
|
160
|
+
if (bundle.files.length > 0) {
|
|
161
|
+
manifestText = injectFilesIntegrity(manifestText, await computeFilesIntegrity(bundle.files));
|
|
162
|
+
}
|
|
163
|
+
const tar = await makeTarGz([
|
|
164
|
+
{ name: DEFAULT_MANIFEST_FILENAME, content: manifestText },
|
|
165
|
+
...bundle.files.map((f) => ({ name: f.name, content: Buffer.from(f.content) })),
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
const client = new OciClient(host, repo);
|
|
169
|
+
const layerDigest = await client.pushBlob(tar);
|
|
170
|
+
const config = await client.pushEmptyConfig();
|
|
171
|
+
const manifest: OciManifest = {
|
|
172
|
+
schemaVersion: 2,
|
|
173
|
+
mediaType: OCI_MANIFEST_MEDIA_TYPE,
|
|
174
|
+
artifactType: TELO_LAYER_MEDIA_TYPE,
|
|
175
|
+
config,
|
|
176
|
+
layers: [{ mediaType: TELO_LAYER_MEDIA_TYPE, digest: layerDigest, size: tar.length }],
|
|
177
|
+
};
|
|
178
|
+
await client.pushManifest(tag, manifest);
|
|
179
|
+
|
|
180
|
+
return { label: `${repo}@${tag}`, url: `${OCI_SCHEME}${host}/${repo}@${tag}` };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
canonicalizeSiblingRef(
|
|
184
|
+
destination: string,
|
|
185
|
+
relativeSource: string,
|
|
186
|
+
sibling: SiblingIdentity,
|
|
187
|
+
): string {
|
|
188
|
+
// The sibling's repo is the destination repo with the relative applied; the
|
|
189
|
+
// version is its own (identity is the ref, not metadata).
|
|
190
|
+
return `${this.resolveRelative(destination, relativeSource)}@${sibling.version}`;
|
|
191
|
+
}
|
|
192
|
+
}
|