631 lines
20 KiB
JavaScript
631 lines
20 KiB
JavaScript
import { spawnSync } from "node:child_process";
|
|
|
|
import { ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs";
|
|
|
|
const envVarName = "HWLAB_DEV_BASE_IMAGE";
|
|
const dockerCommand = "docker";
|
|
const dockerTimeoutMs = 15_000;
|
|
const node20TagPattern = /^20-/u;
|
|
const recommendedNode20Image = "node:20-bookworm-slim";
|
|
const recommendedHwlabBaseImage = "127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim";
|
|
const recommendedPreloadTar = "hwlab-node20-bookworm-slim.tar";
|
|
const forbiddenImageFragments = Object.freeze([
|
|
"unidesk",
|
|
"backend-core",
|
|
"provider-gateway",
|
|
"microservice-proxy",
|
|
"code-queue"
|
|
]);
|
|
const allowedExplicitBaseImages = Object.freeze([
|
|
"node:20-*",
|
|
"hwlab-dev-base:*",
|
|
"*/hwlab-dev-base:*",
|
|
"hwlab-node20-base:*",
|
|
"*/hwlab-node20-base:*",
|
|
"hwlab-node-runtime-base:*",
|
|
"*/hwlab-node-runtime-base:*"
|
|
]);
|
|
|
|
function baseImageRecommendation(envValue = recommendedNode20Image) {
|
|
return {
|
|
source: "Preloaded D601 local Docker cache or D601-local registry tag.",
|
|
envVar: envVarName,
|
|
preferredImageTag: recommendedNode20Image,
|
|
preferredHwlabImageTag: recommendedHwlabBaseImage,
|
|
recommendedEnvValue: envValue
|
|
};
|
|
}
|
|
|
|
function provisionPlan({ exactBlocker = null, envValue = recommendedNode20Image, required = true } = {}) {
|
|
return {
|
|
required,
|
|
exactBlocker,
|
|
expectedImageTag: recommendedNode20Image,
|
|
recommendedEnv: {
|
|
name: envVarName,
|
|
value: envValue,
|
|
assignment: `${envVarName}=${envValue}`
|
|
},
|
|
recommendedHwlabImageTag: recommendedHwlabBaseImage,
|
|
nextHumanCommand: required ? `docker pull ${recommendedNode20Image}` : null,
|
|
commands: {
|
|
d601OnlinePreload: [
|
|
`docker pull ${recommendedNode20Image}`,
|
|
`${envVarName}=${recommendedNode20Image} node scripts/preflight-dev-base-image.mjs`
|
|
],
|
|
d601HwlabTagProvision: [
|
|
`docker pull ${recommendedNode20Image}`,
|
|
`docker tag ${recommendedNode20Image} ${recommendedHwlabBaseImage}`,
|
|
`${envVarName}=${recommendedHwlabBaseImage} node scripts/preflight-dev-base-image.mjs`
|
|
],
|
|
offlineSourceExport: [
|
|
`docker pull ${recommendedNode20Image}`,
|
|
`docker save ${recommendedNode20Image} -o ${recommendedPreloadTar}`
|
|
],
|
|
offlineD601Import: [
|
|
`docker load -i ${recommendedPreloadTar}`,
|
|
`${envVarName}=${recommendedNode20Image} node scripts/preflight-dev-base-image.mjs`
|
|
],
|
|
artifactPublishDryRun: [
|
|
`${envVarName}=${envValue} node scripts/artifact-publish.mjs --preflight --no-report`
|
|
]
|
|
}
|
|
};
|
|
}
|
|
|
|
function blockedReportFor(exactBlocker) {
|
|
const plan = provisionPlan({ exactBlocker });
|
|
return {
|
|
exactBlocker,
|
|
expectedImageTag: plan.expectedImageTag,
|
|
recommendedEnv: plan.recommendedEnv,
|
|
recommendedHwlabImageTag: plan.recommendedHwlabImageTag,
|
|
nextHumanCommand: plan.nextHumanCommand,
|
|
preloadCommands: plan.commands.d601OnlinePreload,
|
|
offlinePreloadCommands: {
|
|
sourceExport: plan.commands.offlineSourceExport,
|
|
d601Import: plan.commands.offlineD601Import
|
|
}
|
|
};
|
|
}
|
|
|
|
function runDocker(args) {
|
|
const result = spawnSync(dockerCommand, args, {
|
|
encoding: "utf8",
|
|
timeout: dockerTimeoutMs
|
|
});
|
|
|
|
return {
|
|
ok: result.status === 0,
|
|
status: result.status,
|
|
signal: result.signal,
|
|
errorCode: result.error?.code ?? null,
|
|
stdout: result.stdout?.trim() ?? "",
|
|
stderr: result.stderr?.trim() ?? "",
|
|
timedOut: result.error?.code === "ETIMEDOUT"
|
|
};
|
|
}
|
|
|
|
function splitJsonLines(stdout) {
|
|
return stdout
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line));
|
|
}
|
|
|
|
function candidateTag(image) {
|
|
return `${image.Repository}:${image.Tag}`;
|
|
}
|
|
|
|
function imageRefFromInspect(requestedImage, inspectDoc) {
|
|
const repoTags = Array.isArray(inspectDoc.RepoTags) ? inspectDoc.RepoTags : [];
|
|
if (repoTags.includes(requestedImage)) {
|
|
return requestedImage;
|
|
}
|
|
return repoTags.find((tag) => !tag.includes("<none>")) ?? requestedImage;
|
|
}
|
|
|
|
function isNode20BaseRef(imageRef) {
|
|
const lowerRef = imageRef.toLowerCase();
|
|
return lowerRef.startsWith("node:20-");
|
|
}
|
|
|
|
function isHwlabDevBaseRef(imageRef) {
|
|
const lowerRef = imageRef.toLowerCase();
|
|
return /(^|\/)(hwlab-dev-base|hwlab-node20-base|hwlab-node-runtime-base)(?=[:@]|$)/u.test(lowerRef);
|
|
}
|
|
|
|
function isTaggedImage(image) {
|
|
return image.Repository && image.Tag && image.Repository !== "<none>" && image.Tag !== "<none>";
|
|
}
|
|
|
|
function approvalForBaseRef(imageRef) {
|
|
if (isNode20BaseRef(imageRef)) {
|
|
return {
|
|
approved: true,
|
|
reason: "local Node 20 builder base image"
|
|
};
|
|
}
|
|
if (isHwlabDevBaseRef(imageRef)) {
|
|
return {
|
|
approved: true,
|
|
reason: "explicit HWLAB DEV builder base image"
|
|
};
|
|
}
|
|
return {
|
|
approved: false,
|
|
reason: `image reference is not one of the approved builder base patterns: ${allowedExplicitBaseImages.join(", ")}`
|
|
};
|
|
}
|
|
|
|
function forbiddenReasons(imageRef) {
|
|
const lowerRef = imageRef.toLowerCase();
|
|
const reasons = [];
|
|
|
|
for (const fragment of forbiddenImageFragments) {
|
|
if (lowerRef.includes(fragment)) {
|
|
reasons.push(`image reference contains forbidden runtime/build substitute fragment: ${fragment}`);
|
|
}
|
|
}
|
|
|
|
for (const serviceId of SERVICE_IDS) {
|
|
if (
|
|
lowerRef.includes(`/pikastech/${serviceId}:`) ||
|
|
lowerRef.includes(`/pikastech/${serviceId}@`) ||
|
|
lowerRef.startsWith(`${serviceId}:`) ||
|
|
lowerRef.startsWith(`${serviceId}@`)
|
|
) {
|
|
reasons.push(`image reference points at HWLAB service artifact ${serviceId}, not a builder base image`);
|
|
}
|
|
}
|
|
|
|
return reasons;
|
|
}
|
|
|
|
function inspectEvidenceStrings(inspectDoc) {
|
|
const labels = inspectDoc.Config?.Labels ?? {};
|
|
const env = Array.isArray(inspectDoc.Config?.Env) ? inspectDoc.Config.Env : [];
|
|
return [
|
|
...(Array.isArray(inspectDoc.RepoTags) ? inspectDoc.RepoTags : []),
|
|
...(Array.isArray(inspectDoc.RepoDigests) ? inspectDoc.RepoDigests : []),
|
|
...Object.entries(labels).map(([key, value]) => `${key}=${value}`),
|
|
...env
|
|
].filter((value) => typeof value === "string" && value.length > 0);
|
|
}
|
|
|
|
function inspectEvidenceBlockers(inspectDoc) {
|
|
const blockers = [];
|
|
for (const evidence of inspectEvidenceStrings(inspectDoc)) {
|
|
for (const reason of forbiddenReasons(evidence)) {
|
|
blockers.push(`${reason} in image inspect evidence ${JSON.stringify(evidence)}`);
|
|
}
|
|
}
|
|
return [...new Set(blockers)];
|
|
}
|
|
|
|
function baseResult(overrides) {
|
|
return {
|
|
preflight: "hwlab-dev-base-image",
|
|
environment: ENVIRONMENT_DEV,
|
|
status: "blocked",
|
|
publishUsable: false,
|
|
artifactPublish: {
|
|
issue: "#35",
|
|
usable: false,
|
|
reason: "No approved local DEV builder base image was selected."
|
|
},
|
|
imageSource: "none",
|
|
requestedImage: null,
|
|
localTag: null,
|
|
imageId: null,
|
|
repoDigests: [],
|
|
candidates: [],
|
|
rejectedCandidates: [],
|
|
blockers: [],
|
|
nextSteps: [],
|
|
recommendation: baseImageRecommendation(),
|
|
provision: provisionPlan(),
|
|
blockedReport: null,
|
|
constraints: {
|
|
localOnly: true,
|
|
doesNotPull: true,
|
|
doesNotBuild: true,
|
|
doesNotPush: true,
|
|
doesNotDeploy: true,
|
|
doesNotReadSecrets: true,
|
|
allowedExplicitBaseImages,
|
|
forbiddenImageFragments,
|
|
forbiddenHwlabServiceImages: SERVICE_IDS
|
|
},
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function blocked(overrides) {
|
|
const exactBlocker = overrides.blockers?.length
|
|
? overrides.blockers.join("; ")
|
|
: "DEV builder base image preflight did not return ready.";
|
|
const envValue = overrides.recommendedEnvValue ?? overrides.requestedImage ?? recommendedNode20Image;
|
|
return baseResult({
|
|
status: "blocked",
|
|
publishUsable: false,
|
|
artifactPublish: {
|
|
issue: "#35",
|
|
usable: false,
|
|
reason: "DEV artifact publish must stop until the blocker is resolved."
|
|
},
|
|
recommendation: baseImageRecommendation(envValue),
|
|
provision: provisionPlan({ exactBlocker, envValue }),
|
|
blockedReport: blockedReportFor(exactBlocker),
|
|
...overrides
|
|
});
|
|
}
|
|
|
|
function ready({ imageSource, approval, requestedImage, localTag, inspectDoc, candidates }) {
|
|
return baseResult({
|
|
status: "ready",
|
|
publishUsable: true,
|
|
artifactPublish: {
|
|
issue: "#35",
|
|
usable: true,
|
|
reason: "A local approved DEV builder base image is available."
|
|
},
|
|
imageSource,
|
|
approval,
|
|
requestedImage,
|
|
localTag,
|
|
imageId: inspectDoc.Id ?? null,
|
|
repoDigests: Array.isArray(inspectDoc.RepoDigests) ? inspectDoc.RepoDigests : [],
|
|
candidates,
|
|
recommendation: baseImageRecommendation(localTag),
|
|
provision: provisionPlan({
|
|
envValue: localTag,
|
|
required: false
|
|
}),
|
|
blockedReport: null,
|
|
blockers: [],
|
|
nextSteps: [
|
|
"Use the reported localTag as the DEV builder base image for artifact publish.",
|
|
"Keep publish DEV-only and record the produced HWLAB service image tags and digests separately."
|
|
]
|
|
});
|
|
}
|
|
|
|
function inspectLocalImage(imageRef) {
|
|
const inspect = runDocker(["image", "inspect", imageRef]);
|
|
if (!inspect.ok) {
|
|
return {
|
|
ok: false,
|
|
inspect,
|
|
docs: []
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
inspect,
|
|
docs: JSON.parse(inspect.stdout)
|
|
};
|
|
}
|
|
|
|
function dockerUnavailableResult(versionResult) {
|
|
const reason = versionResult.timedOut
|
|
? "Docker CLI timed out during version detection."
|
|
: "Docker CLI is not available in this runner.";
|
|
return blocked({
|
|
blockers: [reason],
|
|
nextSteps: [
|
|
"Run this preflight in the D601 builder environment with Docker available.",
|
|
`Preload an approved local Node 20 builder image and set ${envVarName}=<local-image> when using a non-node:20-* tag.`,
|
|
"Do not use a UniDesk, Code Queue, backend-core, provider-gateway, or microservice-proxy runtime image as a substitute."
|
|
],
|
|
containerEngine: {
|
|
command: dockerCommand,
|
|
available: false,
|
|
version: null,
|
|
errorCode: versionResult.errorCode,
|
|
stderr: versionResult.stderr
|
|
}
|
|
});
|
|
}
|
|
|
|
function candidateSource(image) {
|
|
if (image.Repository === "node" && node20TagPattern.test(image.Tag)) {
|
|
return "local-docker-node:20-*";
|
|
}
|
|
if (isHwlabDevBaseRef(candidateTag(image))) {
|
|
return "local-docker-hwlab-dev-base";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function candidatePriority(candidate) {
|
|
if (candidate.source === "local-docker-node:20-*") return 0;
|
|
if (candidate.source === "local-docker-hwlab-dev-base") return 1;
|
|
return 2;
|
|
}
|
|
|
|
function rejectedBaseCandidate(image) {
|
|
if (!isTaggedImage(image)) return null;
|
|
const localTag = candidateTag(image);
|
|
if (image.Repository === "node" && !node20TagPattern.test(image.Tag)) {
|
|
return {
|
|
source: "local-docker-node",
|
|
localTag,
|
|
imageId: image.ID ?? null,
|
|
reason: "local Node image is not Node 20 and is not approved as the HWLAB DEV builder base image"
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function selectLocalBaseCandidate(images) {
|
|
const candidates = images
|
|
.filter(isTaggedImage)
|
|
.map((image) => {
|
|
const source = candidateSource(image);
|
|
if (!source) return null;
|
|
const localTag = candidateTag(image);
|
|
return {
|
|
source,
|
|
approval: approvalForBaseRef(localTag),
|
|
localTag,
|
|
imageId: image.ID ?? null,
|
|
size: image.Size ?? null,
|
|
createdSince: image.CreatedSince ?? null
|
|
};
|
|
})
|
|
.filter(Boolean)
|
|
.map((image) => ({
|
|
...image,
|
|
priority: candidatePriority(image)
|
|
}))
|
|
.sort((left, right) => (
|
|
left.priority - right.priority || left.localTag.localeCompare(right.localTag)
|
|
))
|
|
.map(({ priority, ...candidate }) => candidate);
|
|
|
|
const rejectedCandidates = images
|
|
.map(rejectedBaseCandidate)
|
|
.filter(Boolean)
|
|
.sort((left, right) => left.localTag.localeCompare(right.localTag));
|
|
|
|
return {
|
|
candidates,
|
|
rejectedCandidates,
|
|
selected: candidates[0] ?? null
|
|
};
|
|
}
|
|
|
|
function explicitImageBlockers(requestedImage, localTag) {
|
|
const approval = approvalForBaseRef(localTag);
|
|
const requestedApproval = approvalForBaseRef(requestedImage);
|
|
if (approval.approved || requestedApproval.approved) {
|
|
return {
|
|
approval: approval.approved ? approval : requestedApproval,
|
|
blockers: []
|
|
};
|
|
}
|
|
|
|
return {
|
|
approval,
|
|
blockers: [approval.reason]
|
|
};
|
|
}
|
|
|
|
function readyFromExplicitImage({ requestedImage, inspectDoc, containerEngine }) {
|
|
const localTag = imageRefFromInspect(requestedImage, inspectDoc);
|
|
const localTagBlockers = forbiddenReasons(localTag);
|
|
const evidenceBlockers = inspectEvidenceBlockers(inspectDoc);
|
|
const localTagApproval = explicitImageBlockers(requestedImage, localTag);
|
|
const blockers = [...localTagBlockers, ...evidenceBlockers, ...localTagApproval.blockers];
|
|
if (blockers.length > 0) {
|
|
return blocked({
|
|
imageSource: `env:${envVarName}`,
|
|
requestedImage,
|
|
localTag,
|
|
imageId: inspectDoc.Id ?? null,
|
|
blockers,
|
|
recommendedEnvValue: recommendedNode20Image,
|
|
nextSteps: [
|
|
`Set ${envVarName} to an approved local builder base image, not a runtime service image.`,
|
|
`Allowed references are local node:20-* tags or HWLAB dev base tags such as ${recommendedHwlabBaseImage}.`,
|
|
"Do not continue #35 artifact publish with this image."
|
|
],
|
|
containerEngine
|
|
});
|
|
}
|
|
|
|
return {
|
|
...ready({
|
|
imageSource: `env:${envVarName}`,
|
|
approval: localTagApproval.approval,
|
|
requestedImage,
|
|
localTag,
|
|
inspectDoc,
|
|
candidates: [
|
|
{
|
|
source: `env:${envVarName}`,
|
|
approval: localTagApproval.approval,
|
|
localTag,
|
|
imageId: inspectDoc.Id ?? null
|
|
}
|
|
]
|
|
}),
|
|
containerEngine
|
|
};
|
|
}
|
|
|
|
export async function runDevBaseImagePreflight({ env = process.env } = {}) {
|
|
const requestedImage = env[envVarName]?.trim() || null;
|
|
const version = runDocker(["--version"]);
|
|
if (!version.ok) {
|
|
return dockerUnavailableResult(version);
|
|
}
|
|
|
|
const containerEngine = {
|
|
command: dockerCommand,
|
|
available: true,
|
|
version: version.stdout,
|
|
errorCode: null,
|
|
stderr: ""
|
|
};
|
|
|
|
if (requestedImage) {
|
|
const refBlockers = forbiddenReasons(requestedImage);
|
|
const requestedApproval = approvalForBaseRef(requestedImage);
|
|
if (refBlockers.length > 0 || !requestedApproval.approved) {
|
|
return blocked({
|
|
imageSource: `env:${envVarName}`,
|
|
requestedImage,
|
|
blockers: [
|
|
...refBlockers,
|
|
...(requestedApproval.approved ? [] : [requestedApproval.reason])
|
|
],
|
|
recommendedEnvValue: recommendedNode20Image,
|
|
nextSteps: [
|
|
`Set ${envVarName} to an approved local builder base image, not a runtime service image.`,
|
|
`Allowed references are local node:20-* tags or HWLAB dev base tags such as ${recommendedHwlabBaseImage}.`,
|
|
"Do not continue #35 artifact publish with this image."
|
|
],
|
|
containerEngine
|
|
});
|
|
}
|
|
|
|
const inspected = inspectLocalImage(requestedImage);
|
|
if (!inspected.ok || inspected.docs.length === 0) {
|
|
return blocked({
|
|
imageSource: `env:${envVarName}`,
|
|
requestedImage,
|
|
blockers: [`${envVarName} is set to ${requestedImage}, but that image is not present in the local Docker image cache.`],
|
|
nextSteps: [
|
|
`Preload ${recommendedNode20Image} or tag it as ${recommendedHwlabBaseImage} before artifact publish.`,
|
|
`Keep ${envVarName} pointed at the exact local tag once the image is available.`,
|
|
"Do not pull from third-party hosting or substitute a UniDesk runtime image during this preflight."
|
|
],
|
|
containerEngine
|
|
});
|
|
}
|
|
|
|
return readyFromExplicitImage({
|
|
requestedImage,
|
|
inspectDoc: inspected.docs[0],
|
|
containerEngine
|
|
});
|
|
}
|
|
|
|
const imageList = runDocker(["image", "ls", "--format", "{{json .}}"]);
|
|
if (!imageList.ok) {
|
|
return blocked({
|
|
blockers: ["Docker image list failed; local base-image cache could not be inspected."],
|
|
nextSteps: [
|
|
"Start or repair the local Docker daemon for the D601 builder environment.",
|
|
`Then set ${envVarName}=<approved-local-image> or provide a local node:20-* image.`,
|
|
"Do not claim #35 artifact publish readiness until this preflight returns ready."
|
|
],
|
|
containerEngine: {
|
|
...containerEngine,
|
|
stderr: imageList.stderr,
|
|
errorCode: imageList.errorCode
|
|
}
|
|
});
|
|
}
|
|
|
|
const { candidates, rejectedCandidates, selected } = selectLocalBaseCandidate(splitJsonLines(imageList.stdout));
|
|
if (!selected) {
|
|
return blocked({
|
|
blockers: [`${envVarName} is not set and no approved local DEV builder base image was found in the Docker image cache; expected ${recommendedNode20Image} or ${recommendedHwlabBaseImage}.`],
|
|
candidates,
|
|
rejectedCandidates,
|
|
nextSteps: [
|
|
`Preload ${recommendedNode20Image} into the D601 Docker cache, or tag it as ${recommendedHwlabBaseImage}.`,
|
|
`Set ${envVarName}=${recommendedNode20Image} or ${envVarName}=${recommendedHwlabBaseImage} when invoking artifact publish.`,
|
|
"Rerun this preflight and require status=ready before #35 artifact publish.",
|
|
"Do not use UniDesk runtime, Code Queue runner, backend-core, provider-gateway, or microservice-proxy images as substitutes."
|
|
],
|
|
containerEngine
|
|
});
|
|
}
|
|
|
|
const inspected = inspectLocalImage(selected.localTag);
|
|
if (!inspected.ok || inspected.docs.length === 0) {
|
|
return blocked({
|
|
imageSource: selected.source,
|
|
requestedImage: null,
|
|
localTag: selected.localTag,
|
|
candidates,
|
|
rejectedCandidates,
|
|
blockers: [`Selected local candidate ${selected.localTag} could not be inspected.`],
|
|
nextSteps: [
|
|
`Set ${envVarName} to a known-good approved local base image and rerun this preflight.`,
|
|
"Do not continue #35 artifact publish until image inspect succeeds."
|
|
],
|
|
containerEngine
|
|
});
|
|
}
|
|
const evidenceBlockers = inspectEvidenceBlockers(inspected.docs[0]);
|
|
if (evidenceBlockers.length > 0) {
|
|
return blocked({
|
|
imageSource: selected.source,
|
|
requestedImage: null,
|
|
localTag: selected.localTag,
|
|
candidates,
|
|
rejectedCandidates,
|
|
imageId: inspected.docs[0].Id ?? null,
|
|
blockers: evidenceBlockers,
|
|
nextSteps: [
|
|
`Replace ${selected.localTag} with a clean approved Node 20 builder base image.`,
|
|
"Do not continue #35 artifact publish with an image whose inspect metadata points at a forbidden runtime substitute."
|
|
],
|
|
containerEngine
|
|
});
|
|
}
|
|
|
|
return {
|
|
...ready({
|
|
imageSource: selected.source,
|
|
approval: selected.approval,
|
|
requestedImage: null,
|
|
localTag: selected.localTag,
|
|
inspectDoc: inspected.docs[0],
|
|
candidates
|
|
}),
|
|
rejectedCandidates,
|
|
containerEngine
|
|
};
|
|
}
|
|
|
|
export function unexpectedPreflightErrorResult(error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return blocked({
|
|
blockers: [`Unexpected preflight error: ${message}`],
|
|
nextSteps: [
|
|
"Treat this as a blocker and inspect the preflight command output.",
|
|
"Do not continue #35 artifact publish until the preflight returns status=ready."
|
|
]
|
|
});
|
|
}
|
|
|
|
export function formatPreflightResult(result) {
|
|
return JSON.stringify(result, null, 2);
|
|
}
|
|
|
|
export function exitCodeForPreflight(result) {
|
|
return result.publishUsable ? 0 : 2;
|
|
}
|
|
|
|
export function summarizeForRuntimeBase(result) {
|
|
return {
|
|
status: result.status,
|
|
publishUsable: result.publishUsable,
|
|
imageSource: result.imageSource,
|
|
requestedImage: result.requestedImage,
|
|
localTag: result.localTag,
|
|
imageId: result.imageId,
|
|
blockers: result.blockers,
|
|
nextSteps: result.nextSteps,
|
|
containerEngine: result.containerEngine
|
|
};
|
|
}
|