diff --git a/__tests__/buildx/history.test.ts b/__tests__/buildx/history.test.ts new file mode 100644 index 00000000..a82a512d --- /dev/null +++ b/__tests__/buildx/history.test.ts @@ -0,0 +1,47 @@ +/** + * 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([ + ['docker~login-action~T0XYYW.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~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/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 new file mode 100644 index 00000000..24680ef7 --- /dev/null +++ b/src/types/buildkit/buildkit.ts @@ -0,0 +1,24 @@ +/** + * 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. + */ + +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/593aad1ea909b978d2c54fef2a138c6d3a9107e6/solver/llbsolver/history.go#L672 +export const MEDIATYPE_STATUS_V0 = 'application/vnd.buildkit.status.v0'; diff --git a/src/types/buildkit/client.ts b/src/types/buildkit/client.ts new file mode 100644 index 00000000..3c1f7fcb --- /dev/null +++ b/src/types/buildkit/client.ts @@ -0,0 +1,78 @@ +/** + * 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 {Digest} from '../oci/digest'; +import {ProgressGroup, Range, SourceInfo} from './ops'; + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/client/graph.go#L10-L19 +export interface Vertex { + digest?: Digest; + inputs?: Array; + name?: string; + started?: Date; + completed?: Date; + cached?: boolean; + error?: string; + progressGroup?: ProgressGroup; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/client/graph.go#L21-L30 +export interface VertexStatus { + id: string; + vertex?: Digest; + name?: string; + total?: number; + current: number; + timestamp?: Date; + started?: Date; + completed?: Date; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/client/graph.go#L32-L37 +export interface VertexLog { + vertex?: Digest; + stream?: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; + timestamp: Date; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/client/graph.go#L39-L48 +export interface VertexWarning { + vertex?: Digest; + level?: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + short?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + detail?: Array; + url?: string; + + sourceInfo?: SourceInfo; + range?: Range[]; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/client/graph.go#L50-L55 +export interface SolveStatus { + vertexes?: Vertex[]; + statuses?: VertexStatus[]; + logs?: VertexLog[]; + warnings?: VertexWarning[]; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/client/graph.go#L57-L60 +export interface SolveResponse { + exporterResponse: Record; +} diff --git a/src/types/buildkit/control.ts b/src/types/buildkit/control.ts new file mode 100644 index 00000000..18367387 --- /dev/null +++ b/src/types/buildkit/control.ts @@ -0,0 +1,108 @@ +/** + * 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 {Descriptor} from '../oci/descriptor'; +import {Digest} from '../oci/digest'; +import {ProgressGroup, Range, SourceInfo} from './ops'; +import {RpcStatus} from './rpc'; + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/api/services/control/control.pb.go#L1504-L1525 +export interface BuildHistoryRecord { + Ref: string; + Frontend: string; + FrontendAttrs: Record; + Exporters: Exporter[]; + error?: RpcStatus; + CreatedAt?: Date; + CompletedAt?: Date; + logs?: Descriptor; + ExporterResponse: Record; + Result?: BuildResultInfo; + Results: Record; + Generation: number; + trace?: Descriptor; + pinned: boolean; + numCachedSteps: number; + numTotalSteps: number; + numCompletedSteps: number; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/api/services/control/control.pb.go#L1909-L1917 +export interface Exporter { + Type: string; + Attrs: Record; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/api/services/control/control.pb.go#L1845-L1852 +export interface BuildResultInfo { + ResultDeprecated?: Descriptor; + Attestations?: Descriptor[]; + Results?: Record; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/api/services/control/control.pb.go#L751-L759 +export interface StatusResponse { + vertexes?: Vertex[]; + statuses?: VertexStatus[]; + logs?: VertexLog[]; + warnings?: VertexWarning[]; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/api/services/control/control.pb.go#L822-L834 +export interface Vertex { + digest: Digest; + inputs: Digest[]; + name?: string; + cached?: boolean; + started?: Date; + completed?: Date; + error?: string; + progressGroup?: ProgressGroup; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/api/services/control/control.pb.go#L911-L923 +export interface VertexStatus { + ID?: string; + vertex: Digest; + name?: string; + current?: number; + total?: number; + timestamp: Date; + started?: Date; + completed?: Date; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/api/services/control/control.pb.go#L1007-L1015 +export interface VertexLog { + vertex: Digest; + timestamp: Date; + stream?: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + msg?: any; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/api/services/control/control.pb.go#L1071-L1082 +export interface VertexWarning { + vertex: Digest; + level?: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + short?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + detail?: any[]; + url?: string; + info?: SourceInfo; + ranges?: Range[]; +} diff --git a/src/types/buildkit/ops.ts b/src/types/buildkit/ops.ts new file mode 100644 index 00000000..1a2d295f --- /dev/null +++ b/src/types/buildkit/ops.ts @@ -0,0 +1,82 @@ +/** + * 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/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/solver/pb/ops.pb.go#L1901-L1909 +export interface Definition { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + def?: Array; + metadata: Record; + Source?: Source; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/solver/pb/ops.pb.go#L1312-L1323 +export interface OpMetadata { + ignore_cache?: boolean; + description?: Record; + export_cache?: ExportCache; + caps: Record; + progress_group?: ProgressGroup; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/solver/pb/ops.pb.go#L1390-L1393 +export interface Source { + locations?: Record; + infos?: Array; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/solver/pb/ops.pb.go#L1439-L1441 +export interface Locations { + locations?: Array; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/solver/pb/ops.pb.go#L1545-L1548 +export interface Location { + sourceIndex?: number; + ranges?: Array; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/solver/pb/ops.pb.go#L1594-L1597 +export interface Range { + start: Position; + end: Position; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/solver/pb/ops.pb.go#L1643-L1646 +export interface Position { + line: number; + character: number; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/solver/pb/ops.pb.go#L1480-L1485 +export interface SourceInfo { + filename?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: any; + definition?: Definition; + language?: string; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/solver/pb/ops.pb.go#L1691-L1693 +export interface ExportCache { + Value?: boolean; +} + +// https://github.com/moby/buildkit/blob/593aad1ea909b978d2c54fef2a138c6d3a9107e6/solver/pb/ops.pb.go#L1731-L1735 +export interface ProgressGroup { + id?: string; + name?: string; + weak?: boolean; +} diff --git a/src/types/buildkit/rpc.ts b/src/types/buildkit/rpc.ts new file mode 100644 index 00000000..130e3151 --- /dev/null +++ b/src/types/buildkit/rpc.ts @@ -0,0 +1,27 @@ +/** + * 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. + */ + +export interface RpcStatus { + Code: number; + Message: string; + Details: { + // Define properties based on google.protobuf.Any + // For simplicity, assuming it has at least a type_url and a value. + type_url: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; + }[]; +} 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; +}