From e832899f3c0e36397f8115f307a3a09ad692f718 Mon Sep 17 00:00:00 2001 From: Christian Nuss Date: Thu, 18 Jun 2026 05:53:48 -0400 Subject: [PATCH 1/2] fix: rewrite config rootfs.diff_ids when injecting layers (#5) Deploying an image with layersFrom (rowdy runtime injection) produced an invalid OCI image: the injected layers were appended to the manifest but the image config's rootfs.diff_ids was left unchanged, so len(manifest.layers) != len(config.rootfs.diff_ids). Registries/runtimes then drop the app layer and the function crashes with ENOENT on its entrypoint. This affected both multi-arch and single-arch images. When a layersFrom match is found, fetch both the app and runtime image configs, append the runtime config's diff_ids (the uncompressed digests, only derivable from that config) and history to the app config, re-serialize it to a new digest/size, re-point data.config, and push the rewritten config blob instead of streaming the stale original. Assert layers.length === diff_ids.length before push. Also handle a bare image manifest (e.g. a single-platform build pushed without buildx attestations) by treating it as a one-entry index, reading the platform from its config, so `provenance: false` builds can be deployed. Add types: ["node", "jest"] to tsconfig so editors resolve node globals. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api/internal/transfer.ts | 213 +++++++++++++++++++++------- tests/api/internal/transfer.test.ts | 24 ++++ tsconfig.json | 1 + 3 files changed, 188 insertions(+), 50 deletions(-) diff --git a/src/api/internal/transfer.ts b/src/api/internal/transfer.ts index 1d99a66..b892b50 100644 --- a/src/api/internal/transfer.ts +++ b/src/api/internal/transfer.ts @@ -54,6 +54,10 @@ export type External = { Entrypoint: string[]; WorkingDir: string; }>; + architecture: string; + os: string; + rootfs: Partial<{ type: string; diff_ids: string[] }>; + history: Record[]; }>; }; @@ -79,7 +83,10 @@ export type DenormalizedImage = { export type ImageManifest = Image & { index: External['Index']; - images: { manifest: External['Manifest']; image: External['Image'] }[]; + // `config` holds the re-serialized image config bytes when layers have been injected: the app + // config's rootfs.diff_ids/history are rewritten so the manifest stays valid and must be pushed + // in place of the original config blob. + images: { manifest: External['Manifest']; image: External['Image']; config?: Buffer }[]; headers: HttpHeaders; }; @@ -245,31 +252,57 @@ export class Transfer implements ILoggable { return response; }) ).pipe( - map(({ headers, data }) => { + switchMap(async (response) => { + const responseHeaders = response.headers; + const data = response.data as External['Index'] & External['Image']; + if (data.schemaVersion !== 2) { throw new Error(`Unsupported schemaVersion on index: ${data.schemaVersion}`); } + const digest = responseHeaders['docker-content-digest']; + if (!digest) { + throw new Error(`No docker-content-digest header found on response for ${image.url}`); + } + + let index: External['Index']; if ( - data.mediaType !== 'application/vnd.oci.image.index.v1+json' && - data.mediaType !== 'application/vnd.docker.distribution.manifest.list.v2+json' + data.mediaType === 'application/vnd.oci.image.index.v1+json' || + data.mediaType === 'application/vnd.docker.distribution.manifest.list.v2+json' + ) { + index = data; + } else if ( + data.mediaType === 'application/vnd.oci.image.manifest.v1+json' || + data.mediaType === 'application/vnd.docker.distribution.manifest.v2+json' ) { + // A bare image manifest (e.g. a single-platform build pushed without buildx + // attestations) has no index wrapper. Treat it as a one-entry index so the rest of + // the pipeline stays platform-agnostic. The platform comes from the image config. + const platform = await Transfer.fetchPlatform(http, image.url, data, headers); + index = { + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.index.v1+json', + manifests: [ + { + mediaType: data.mediaType, + digest, + size: Number(responseHeaders['content-length']) || JSON.stringify(data).length, + platform, + }, + ], + }; + } else { throw new Error(`Unsupported mediaType on index: ${data.mediaType}`); } if (additional) { // Drop attestation manifests, since we're fundamentally altering the image with new layers - data.manifests = data.manifests?.filter((m) => m.platform?.architecture !== 'unknown'); - } - - const digest = headers['docker-content-digest']; - if (!digest) { - throw new Error(`No docker-content-digest header found on response for ${image.url}`); + index.manifests = index.manifests?.filter((m) => m.platform?.architecture !== 'unknown'); } return { digest, - index: data, + index, additional, }; }), @@ -287,7 +320,7 @@ export class Transfer implements ILoggable { mergeMap( ({ manifest, url }) => from(http.get(url, { headers: headers.intoAxios() })).pipe( - map(({ data }) => { + switchMap(async ({ data }) => { if (data.schemaVersion !== 2) { throw new Error(`Unsupported schemaVersion on manifest: ${data.schemaVersion}`); } @@ -305,23 +338,69 @@ export class Transfer implements ILoggable { m.platform?.os === manifest.platform?.os ); - if (match) { - data.layers = data.layers || []; - data.layers.push( - ...( - additional?.images?.find((img) => img.manifest.digest === match.digest)?.image.layers || - [] - ).map((l) => ({ - ...l, - annotations: { ...l.annotations, 'run.rowdy.index.url': additional!.url }, - })) + if (!match) { + return { manifest, image: data, config: undefined }; + } + + const runtime = additional?.images?.find((img) => img.manifest.digest === match.digest); + + data.layers = data.layers || []; + data.layers.push( + ...(runtime?.image.layers || []).map((l) => ({ + ...l, + annotations: { ...l.annotations, 'run.rowdy.index.url': additional!.url }, + })) + ); + + // The injected layers must also be reflected in the image config's + // rootfs.diff_ids (the uncompressed layer digests, only available from the + // runtime image's config) or the image is invalid: len(layers) != len(diff_ids) + // and the registry/runtime drops the app layer (issue #5). + if (!data.config?.digest || !data.config?.mediaType) { + throw new Error( + `App image manifest ${manifest.digest} has no config; cannot inject layers` ); } + if (!runtime?.image.config?.digest || !runtime.image.config.mediaType) { + throw new Error(`Runtime image ${match.digest} has no config; cannot inject layers`); + } + + const appCfgUrl = `${image.url.split('/').slice(0, -2).join('/')}/blobs/${data.config.digest}`; + const rtCfgUrl = `${additional!.url.split('/').slice(0, -2).join('/')}/blobs/${runtime.image.config.digest}`; + + const [appCfg, rtCfg] = await Promise.all([ + http + .get(appCfgUrl, { + headers: headers.with('accept', data.config.mediaType).intoAxios(), + }) + .then((r) => r.data), + http + .get(rtCfgUrl, { + headers: additional!.headers.with('accept', runtime.image.config.mediaType).intoAxios(), + }) + .then((r) => r.data), + ]); + + appCfg.rootfs = appCfg.rootfs || { type: 'layers', diff_ids: [] }; + appCfg.rootfs.diff_ids = [ + ...(appCfg.rootfs.diff_ids || []), + ...(rtCfg.rootfs?.diff_ids || []), + ]; + if (rtCfg.history?.length) { + appCfg.history = [...(appCfg.history || []), ...rtCfg.history]; + } - return { - manifest: manifest, - image: data, - }; + const config = Buffer.from(JSON.stringify(appCfg)); + const configDigest = `sha256:${createHash('sha256').update(config).digest('hex')}`; + data.config = { mediaType: data.config.mediaType, digest: configDigest, size: config.length }; + + if (data.layers.length !== appCfg.rootfs.diff_ids.length) { + throw new Error( + `layer/diffID mismatch after injection: ${data.layers.length} != ${appCfg.rootfs.diff_ids.length}` + ); + } + + return { manifest, image: data, config }; }) ), Environment.CONCURRENCY @@ -348,6 +427,27 @@ export class Transfer implements ILoggable { }; } + // Reads architecture/os from a bare image manifest's config blob, so a single image manifest + // (no index wrapper) can be represented as a one-entry index. + private static async fetchPlatform( + http: AxiosInstance, + manifestUrl: string, + image: External['Image'], + headers: HttpHeaders + ): Promise<{ architecture: string; os: string }> { + if (!image.config?.digest || !image.config?.mediaType) { + throw new Error(`Bare image manifest has no config; cannot determine platform`); + } + const configUrl = `${manifestUrl.split('/').slice(0, -2).join('/')}/blobs/${image.config.digest}`; + const { data } = await http.get(configUrl, { + headers: headers.with('accept', image.config.mediaType).intoAxios(), + }); + if (!data.architecture || !data.os) { + throw new Error(`Bare image config is missing architecture/os`); + } + return { architecture: data.architecture, os: data.os }; + } + static prepare( log: Logger, http: AxiosInstance, @@ -367,9 +467,13 @@ export class Transfer implements ILoggable { transfer .with( transfer.manifest.images - .map((img) => img.image.config) - .filter((c): c is Required => !!c && !!c.digest && !!c.mediaType) - .map((ref) => new Upload(transfer, 'config', ref)) + .filter( + (img): img is { manifest: External['Manifest']; image: External['Image']; config?: Buffer } => + !!img.image.config && !!img.image.config.digest && !!img.image.config.mediaType + ) + // When `config` is set the original config blob was rewritten (layers injected); push + // those bytes instead of streaming the now-stale original from the source registry. + .map((img) => new Upload(transfer, 'config', img.image.config as Required, img.config)) ) .with( transfer.manifest.images @@ -595,7 +699,7 @@ export class Upload implements ILoggable { public readonly transfer: Transfer, public readonly type: 'blob' | 'config' | 'manifest', public readonly ref: External['Ref'], - public readonly content?: Readable + public readonly content?: Readable | Buffer ) { this.bytes = { received: 0, @@ -724,26 +828,35 @@ export class Upload implements ILoggable { toUrl = location; } - let { data: download } = - this.type !== 'manifest' - ? await promiseRetry(() => - this.http.get(fromUrl, { - responseType: 'stream', - maxBodyLength: Infinity, - maxContentLength: Infinity, - headers: this.transfer.fromHeaders.with('accept', this.mediaType).intoAxios(), - onDownloadProgress: (e) => (this.bytes.received += e.bytes), - }) - ) - : { data: this.content }; - - if (this.type === 'config') { - // Intercept configs and extract entrypoint/cmd/workdir - const chunks: Buffer[] = []; - download?.on('data', (chunk) => { - chunks.push(chunk); - }); - download?.on('end', () => status.withConfig(Buffer.concat(chunks), this.mediaType)); + let download: Readable | Buffer | undefined; + if (this.content) { + // Pre-supplied content: manifests, or a config blob rewritten to add the injected layers' + // diff_ids. Upload it directly instead of streaming the stale original from the source. + if (this.type === 'config' && Buffer.isBuffer(this.content)) { + status.withConfig(this.content, this.mediaType); + } + download = this.content; + } else if (this.type !== 'manifest') { + download = ( + await promiseRetry(() => + this.http.get(fromUrl, { + responseType: 'stream', + maxBodyLength: Infinity, + maxContentLength: Infinity, + headers: this.transfer.fromHeaders.with('accept', this.mediaType).intoAxios(), + onDownloadProgress: (e) => (this.bytes.received += e.bytes), + }) + ) + ).data; + + if (this.type === 'config' && download instanceof Readable) { + // Intercept configs and extract entrypoint/cmd/workdir + const chunks: Buffer[] = []; + download.on('data', (chunk) => { + chunks.push(chunk); + }); + download.on('end', () => status.withConfig(Buffer.concat(chunks), this.mediaType)); + } } if (this.type !== 'manifest') { diff --git a/tests/api/internal/transfer.test.ts b/tests/api/internal/transfer.test.ts index 1896059..fb9bdaa 100644 --- a/tests/api/internal/transfer.test.ts +++ b/tests/api/internal/transfer.test.ts @@ -3,6 +3,7 @@ import { Transfer, External, ImageManifest } from '../../../src/api/internal/tra import { Logger, Rowdy } from '@scaffoldly/rowdy'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { readFileSync, writeFileSync } from 'fs'; +import { createHash } from 'crypto'; describe('transfers', () => { const logger = new Logger(); @@ -229,6 +230,29 @@ describe('transfers', () => { }, ]; + it('should keep layers and config rootfs.diff_ids aligned when injecting layersFrom (issue #5)', async () => { + const result = await lastValueFrom( + of('mirror.gcr.io/library/busybox:latest').pipe( + Transfer.normalize(), + Transfer.collect(logger, rowdy.http, 'ghcr.io/scaffoldly/rowdy:beta') + ) + ); + + // At least one platform must have had layers injected + const injected = result.images.filter((img) => !!img.config); + expect(injected.length).toBeGreaterThan(0); + + for (const img of injected) { + const config = JSON.parse(img.config!.toString('utf-8')) as External['Config']; + // The rewritten config blob's digest/size must match what the manifest points at + const digest = `sha256:${createHash('sha256').update(img.config!).digest('hex')}`; + expect(img.image.config!.digest).toBe(digest); + expect(img.image.config!.size).toBe(img.config!.length); + // The core invariant: one diff_id per manifest layer + expect(img.image.layers!.length).toBe(config.rootfs!.diff_ids!.length); + } + }, 60000); + tests.forEach(({ normalized, collected }) => { it(`should collect manifests for ${normalized.image}`, async () => { const result = await lastValueFrom(of(normalized).pipe(Transfer.collect(logger, rowdy.http))); diff --git a/tsconfig.json b/tsconfig.json index 73516b0..9ef75b7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2020", "module": "commonjs", "lib": ["ES2020"], + "types": ["node", "jest"], "outDir": "./dist", "strict": true, "esModuleInterop": true, From 7229a490e1631c2db49ebe20f7847a89fc35576d Mon Sep 17 00:00:00 2001 From: Christian Nuss Date: Thu, 18 Jun 2026 06:00:17 -0400 Subject: [PATCH 2/2] chore: clean up tests/tsconfig.json diagnostics Editor surfaced rootDir + deprecation errors on tests/tsconfig.json: - noEmit + rootDir ".." resolve the "not under rootDir" / common-source-dir errors caused by including ../src/**/*.ts without an explicit rootDir. - ignoreDeprecations silences the node10 moduleResolution / baseUrl deprecation notices pending a future TS migration. This config is type-check/editor only; emit goes through tsup (root tsconfig) and tests run via jest, both unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/tsconfig.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 68c74cb..a774359 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,6 +1,9 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "noEmit": true, + "rootDir": "..", + "ignoreDeprecations": "6.0", "module": "commonjs", "moduleResolution": "node", "allowSyntheticDefaultImports": true,