diff --git a/__tests__/buildx/history.test.ts b/__tests__/buildx/history.test.ts new file mode 100644 index 00000000..fe2b4004 --- /dev/null +++ b/__tests__/buildx/history.test.ts @@ -0,0 +1,52 @@ +/** + * Copyright 2024 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {afterEach, beforeEach, describe, expect, jest, test} from '@jest/globals'; +import path from 'path'; +import * as rimraf from 'rimraf'; + +import {History} from '../../src/buildx/history'; + +const fixturesDir = path.join(__dirname, '..', 'fixtures'); + +// prettier-ignore +const tmpDir = path.join(process.env.TEMP || '/tmp', 'docker-jest'); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +afterEach(function () { + rimraf.sync(tmpDir); +}); + +describe('load', () => { + // prettier-ignore + test.each([ + ['crazy-max~docker-alpine-s6~II9A63.dockerbuild'], + ['docker~build-push-action~2778G2.dockerbuild'], + ['docker~login-action~T0XYYW.dockerbuild'], + ['docker~test-docker-action~dfile-error~DEBCS4.dockerbuild'], + ['docker~test-docker-action~go-error~BGI5SX.dockerbuild'], + ['moby~buildkit~LWDOW6.dockerbuild'], + ])('loading %p', async (filename) => { + const res = await History.load({ + file: path.join(fixturesDir, 'oci-archive', filename) + }); + console.log(JSON.stringify(res, null, 2)); + expect(res).toBeDefined(); + }); +}); diff --git a/__tests__/fixtures/oci-archive/docker~build-push-action~2778G2.dockerbuild b/__tests__/fixtures/oci-archive/docker~build-push-action~2778G2.dockerbuild new file mode 100644 index 00000000..f95e686d Binary files /dev/null and b/__tests__/fixtures/oci-archive/docker~build-push-action~2778G2.dockerbuild differ diff --git a/__tests__/fixtures/oci-archive/docker~login-action~T0XYYW.dockerbuild b/__tests__/fixtures/oci-archive/docker~login-action~T0XYYW.dockerbuild new file mode 100644 index 00000000..9d0e18de Binary files /dev/null and b/__tests__/fixtures/oci-archive/docker~login-action~T0XYYW.dockerbuild differ diff --git a/__tests__/fixtures/oci-archive/docker~test-docker-action~dfile-error~DEBCS4.dockerbuild b/__tests__/fixtures/oci-archive/docker~test-docker-action~dfile-error~DEBCS4.dockerbuild new file mode 100644 index 00000000..166bebd9 Binary files /dev/null and b/__tests__/fixtures/oci-archive/docker~test-docker-action~dfile-error~DEBCS4.dockerbuild differ diff --git a/__tests__/fixtures/oci-archive/docker~test-docker-action~go-error~BGI5SX.dockerbuild b/__tests__/fixtures/oci-archive/docker~test-docker-action~go-error~BGI5SX.dockerbuild new file mode 100644 index 00000000..4cc0b466 Binary files /dev/null and b/__tests__/fixtures/oci-archive/docker~test-docker-action~go-error~BGI5SX.dockerbuild differ diff --git a/src/buildx/history.ts b/src/buildx/history.ts index 970e58cf..d558e417 100644 --- a/src/buildx/history.ts +++ b/src/buildx/history.ts @@ -26,8 +26,18 @@ import {Context} from '../context'; import {Docker} from '../docker/docker'; import {Exec} from '../exec'; import {GitHub} from '../github'; - -import {ExportRecordOpts, ExportRecordResponse, Summaries} from '../types/buildx/history'; +import {OCI} from '../oci/oci'; + +import {ExportRecordOpts, ExportRecordResponse, LoadRecordOpts, Summaries} from '../types/buildx/history'; +import {Index} from '../types/oci'; +import {MEDIATYPE_IMAGE_INDEX_V1, MEDIATYPE_IMAGE_MANIFEST_V1} from '../types/oci/mediatype'; +import {Archive} from '../types/oci/oci'; +import {BuildRecord} from '../types/buildx/buildx'; +import {Descriptor} from '../types/oci/descriptor'; +import {MEDIATYPE_PAYLOAD as MEDIATYPE_INTOTO_PAYLOAD, MEDIATYPE_PREDICATE} from '../types/intoto/intoto'; +import {ProvenancePredicate} from '../types/intoto/slsa_provenance/v0.2/provenance'; +import {ANNOTATION_REF_KEY, MEDIATYPE_HISTORY_RECORD_V0, MEDIATYPE_SOLVE_STATUS_V0} from '../types/buildkit/buildkit'; +import {SolveStatus} from '../types/buildkit/client'; export interface HistoryOpts { buildx?: Buildx; @@ -42,6 +52,80 @@ export class History { this.buildx = opts?.buildx || new Buildx(); } + public static async load(opts: LoadRecordOpts): Promise> { + const ociArchive = await OCI.loadArchive({ + file: opts.file + }); + return History.readRecords(ociArchive.root.index, ociArchive); + } + + private static readRecords(index: Index, archive: Archive): Record { + const res: Record = {}; + index.manifests.forEach(desc => { + switch (desc.mediaType) { + case MEDIATYPE_IMAGE_MANIFEST_V1: { + const record = History.readRecord(desc, archive); + res[record.Ref] = record; + break; + } + case MEDIATYPE_IMAGE_INDEX_V1: { + if (!Object.prototype.hasOwnProperty.call(archive.indexes, desc.digest)) { + throw new Error(`Missing index: ${desc.digest}`); + } + const records = History.readRecords(archive.indexes[desc.digest], archive); + for (const ref in records) { + if (!Object.prototype.hasOwnProperty.call(records, ref)) { + continue; + } + res[ref] = records[ref]; + } + break; + } + } + }); + return res; + } + + private static readRecord(desc: Descriptor, archive: Archive): BuildRecord { + if (!Object.prototype.hasOwnProperty.call(archive.manifests, desc.digest)) { + throw new Error(`Missing manifest: ${desc.digest}`); + } + const manifest = archive.manifests[desc.digest]; + if (manifest.config.mediaType !== MEDIATYPE_HISTORY_RECORD_V0) { + throw new Error(`Unexpected config media type: ${manifest.config.mediaType}`); + } + if (!Object.prototype.hasOwnProperty.call(archive.blobs, manifest.config.digest)) { + throw new Error(`Missing config blob: ${manifest.config.digest}`); + } + const record = JSON.parse(archive.blobs[manifest.config.digest]); + if (manifest.annotations && ANNOTATION_REF_KEY in manifest.annotations) { + if (record.Ref !== manifest.annotations[ANNOTATION_REF_KEY]) { + throw new Error(`Mismatched ref ${desc.digest}: ${record.Ref} != ${manifest.annotations[ANNOTATION_REF_KEY]}`); + } + } + manifest.layers.forEach(layer => { + switch (layer.mediaType) { + case MEDIATYPE_SOLVE_STATUS_V0: { + if (!Object.prototype.hasOwnProperty.call(archive.blobs, layer.digest)) { + throw new Error(`Missing blob: ${layer.digest}`); + } + record.solveStatus = JSON.parse(archive.blobs[layer.digest]); + break; + } + case MEDIATYPE_INTOTO_PAYLOAD: { + if (!Object.prototype.hasOwnProperty.call(archive.blobs, layer.digest)) { + throw new Error(`Missing blob: ${layer.digest}`); + } + if (layer.annotations && MEDIATYPE_PREDICATE in layer.annotations && layer.annotations[MEDIATYPE_PREDICATE].startsWith('https://slsa.dev/provenance/')) { + record.provenance = JSON.parse(archive.blobs[layer.digest]); + } + break; + } + } + }); + return record; + } + public async export(opts: ExportRecordOpts): Promise { if (os.platform() === 'win32') { throw new Error('Exporting a build record is currently not supported on Windows'); diff --git a/src/types/buildkit/buildkit.ts b/src/types/buildkit/buildkit.ts index 04d0ff52..c2e52675 100644 --- a/src/types/buildkit/buildkit.ts +++ b/src/types/buildkit/buildkit.ts @@ -14,5 +14,11 @@ * limitations under the License. */ +export const ANNOTATION_REF_KEY = 'vnd.buildkit.history.reference'; + +export const MEDIATYPE_SOLVE_STATUS_V0 = 'application/vnd.buildkit.solvestatus.v0'; + +export const MEDIATYPE_HISTORY_RECORD_V0 = 'application/vnd.buildkit.historyrecord.v0'; + // https://github.com/moby/buildkit/blob/v0.14.0/solver/llbsolver/history.go#L672 export const MEDIATYPE_STATUS_V0 = 'application/vnd.buildkit.status.v0'; diff --git a/src/types/buildx/buildx.ts b/src/types/buildx/buildx.ts index 6dc5e6fb..123d9142 100644 --- a/src/types/buildx/buildx.ts +++ b/src/types/buildx/buildx.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +import {SolveStatus} from '../buildkit/client'; +import {BuildHistoryRecord} from '../buildkit/control'; +import {ProvenancePredicate} from '../intoto/slsa_provenance/v0.2/provenance'; + export interface Cert { cacert?: string; cert?: string; @@ -44,3 +48,23 @@ export interface LocalState { DockerfilePath: string; GroupRef?: string; } + +export interface StateGroup { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Definition: any; + Targets: Array; + Inputs: Array; + Refs?: Array; +} + +// https://github.com/docker/desktop-build/blob/b609016485f6d37cb22cdfb616c6222c85c30683/tools/export-build/main.go#L48-L54 +export interface ExportedRecord extends BuildHistoryRecord { + localState: LocalState; + stateGroup: StateGroup; + DefaultPlatform: string; +} + +export interface BuildRecord extends ExportedRecord { + solveStatus?: SolveStatus; + provenance?: ProvenancePredicate; +} diff --git a/src/types/buildx/history.ts b/src/types/buildx/history.ts index 67fbe685..5db393de 100644 --- a/src/types/buildx/history.ts +++ b/src/types/buildx/history.ts @@ -42,3 +42,7 @@ export interface RecordSummary { frontendAttrs: Record; error?: string; } + +export interface LoadRecordOpts { + file: string; +} diff --git a/src/types/intoto/intoto.ts b/src/types/intoto/intoto.ts new file mode 100644 index 00000000..0bad854e --- /dev/null +++ b/src/types/intoto/intoto.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2024 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// https://github.com/in-toto/in-toto-golang/blob/dd6278764ab1dae7301609c7510129888e2fd569/in_toto/envelope.go#L17 +export const MEDIATYPE_PAYLOAD = 'application/vnd.in-toto+json'; + +export const MEDIATYPE_PREDICATE = 'in-toto.io/predicate-type'; diff --git a/src/types/intoto/slsa_provenance/v0.2/provenance.ts b/src/types/intoto/slsa_provenance/v0.2/provenance.ts new file mode 100644 index 00000000..832fd38b --- /dev/null +++ b/src/types/intoto/slsa_provenance/v0.2/provenance.ts @@ -0,0 +1,69 @@ +/** + * Copyright 2024 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// https://github.com/in-toto/in-toto-golang/blob/master/in_toto/slsa_provenance/v0.2/provenance.go + +export const PREDICATE_SLSA_PROVENANCE = 'https://slsa.dev/provenance/v0.2'; + +export interface ProvenancePredicate { + builder: ProvenanceBuilder; + buildType: string; + invocation?: ProvenanceInvocation; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + buildConfig?: any; + metadata: ProvenanceMetadata; + materials?: Material[]; +} + +export interface ProvenanceBuilder { + id: string; +} + +export interface ProvenanceInvocation { + configSource?: ConfigSource; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parameters?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + environment?: any; +} + +export interface DigestSet { + [key: string]: string; +} + +export interface ConfigSource { + uri?: string; + digest?: DigestSet; + entryPoint?: string; +} + +export interface Completeness { + parameters?: boolean; + environment?: boolean; + materials?: boolean; +} + +export interface ProvenanceMetadata { + buildInvocationId?: string; + buildStartedOn?: string; + completeness?: Completeness; + reproducible?: boolean; +} + +export interface Material { + uri: string; + digest: DigestSet; +}