@lazycatcloud/lzc-cli 2.0.0-pre.1 → 2.0.0-pre.2

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.
@@ -10,13 +10,16 @@ import shellApi from '../shellapi.js';
10
10
  import { collectContextFromDockerFile } from './lpk_devshell_docker.js';
11
11
  import { isFileExist } from '../utils.js';
12
12
  import { resolveBuildRemoteFromOptions } from '../build_remote.js';
13
+ import { buildLocalImageForPack, removeLocalDockerImages, saveLocalDockerImagesArchive } from './lpk_build_images_local.js';
14
+ import { packLocalArchiveToStageDir } from './lpk_build_images_pack_local.js';
13
15
 
14
16
  const EMBED_PREFIX = 'embed:';
15
17
  const EMBED_TYPO_PREFIXES = ['emebd:'];
16
18
  const SHA256_PREFIX = 'sha256:';
17
19
  const DEFAULT_UPSTREAM_MATCH = 'registry.lazycat.cloud';
18
- const IMAGE_BUILD_CACHE_VERSION = 3;
20
+ const IMAGE_BUILD_CACHE_VERSION = 4;
19
21
  const IMAGE_PACKAGE_CACHE_VERSION = 1;
22
+ const DEFAULT_LOCAL_TARGET_PLATFORM = 'linux/amd64';
20
23
 
21
24
  function isPlainObject(value) {
22
25
  return value && typeof value === 'object' && !Array.isArray(value);
@@ -123,6 +126,15 @@ function normalizeImageBuildEntries(rawConfig, cwd, manifest) {
123
126
  }
124
127
  }
125
128
 
129
+ const builderValue = config.builder;
130
+ let builder = 'remote';
131
+ if (builderValue !== undefined && builderValue !== null) {
132
+ builder = String(builderValue).trim().toLowerCase();
133
+ if (builder !== 'remote' && builder !== 'local') {
134
+ throw new Error(`images.${alias}.builder must be "remote" or "local"`);
135
+ }
136
+ }
137
+
126
138
  entries.push({
127
139
  alias,
128
140
  contextDir,
@@ -130,6 +142,7 @@ function normalizeImageBuildEntries(rawConfig, cwd, manifest) {
130
142
  dockerfileInlineContent,
131
143
  imageLabel: `${packageName}-image-${sanitizeTagPart(alias, 'image')}:${version}`,
132
144
  upstreamMatch,
145
+ builder,
133
146
  });
134
147
  }
135
148
 
@@ -276,6 +289,8 @@ function normalizeMetaForPackageCache(metaList) {
276
289
  return [...metaList]
277
290
  .map((meta) => ({
278
291
  alias: String(meta?.alias ?? ''),
292
+ builder: String(meta?.builder ?? ''),
293
+ platform: String(meta?.platform ?? ''),
279
294
  imageID: String(meta?.imageID ?? ''),
280
295
  upstream: String(meta?.upstream ?? ''),
281
296
  embeddedDiffIDs: [...(meta?.embeddedDiffIDs ?? [])].map((item) => String(item ?? '')),
@@ -488,6 +503,109 @@ async function sha256File(filePath) {
488
503
  });
489
504
  }
490
505
 
506
+ async function extractPackedImagesArchive(archivePath, targetDir) {
507
+ fs.mkdirSync(targetDir, { recursive: true });
508
+ await tar.x({
509
+ file: archivePath,
510
+ cwd: targetDir,
511
+ });
512
+ }
513
+
514
+ function loadPackedResult(stageDir) {
515
+ const packedResultPath = path.join(stageDir, 'pack-result.json');
516
+ if (!isFileExist(packedResultPath)) {
517
+ throw new Error('pack-result.json not found after pack-images');
518
+ }
519
+ const packedResult = JSON.parse(fs.readFileSync(packedResultPath, 'utf-8'));
520
+ fs.rmSync(packedResultPath, { force: true });
521
+ if (!isPlainObject(packedResult?.lockImages)) {
522
+ throw new Error('Invalid pack-result.json: lockImages is missing');
523
+ }
524
+ return {
525
+ lockImages: packedResult.lockImages,
526
+ embeddedLayerBytes: Number(packedResult?.embeddedLayerBytes ?? 0),
527
+ embeddedLayerCount: Number(packedResult?.embeddedLayerCount ?? 0),
528
+ };
529
+ }
530
+
531
+ function mergeOciLayout(sourceDir, targetDir) {
532
+ const sourceImagesDir = path.join(sourceDir, 'images');
533
+ if (!fs.existsSync(sourceImagesDir)) {
534
+ throw new Error(`packed images dir not found: ${sourceImagesDir}`);
535
+ }
536
+ const sourceBlobsDir = path.join(sourceImagesDir, 'blobs', 'sha256');
537
+ const targetImagesDir = path.join(targetDir, 'images');
538
+ const targetBlobsDir = path.join(targetImagesDir, 'blobs', 'sha256');
539
+ fs.mkdirSync(targetBlobsDir, { recursive: true });
540
+ if (fs.existsSync(sourceBlobsDir)) {
541
+ for (const fileName of fs.readdirSync(sourceBlobsDir)) {
542
+ const sourcePath = path.join(sourceBlobsDir, fileName);
543
+ const targetPath = path.join(targetBlobsDir, fileName);
544
+ if (!isFileExist(targetPath)) {
545
+ fs.copyFileSync(sourcePath, targetPath);
546
+ }
547
+ }
548
+ }
549
+
550
+ const sourceIndexPath = path.join(sourceImagesDir, 'index.json');
551
+ const targetIndexPath = path.join(targetImagesDir, 'index.json');
552
+ const sourceIndex = isFileExist(sourceIndexPath) ? JSON.parse(fs.readFileSync(sourceIndexPath, 'utf-8')) : { manifests: [] };
553
+ const targetIndex = isFileExist(targetIndexPath)
554
+ ? JSON.parse(fs.readFileSync(targetIndexPath, 'utf-8'))
555
+ : {
556
+ schemaVersion: 2,
557
+ manifests: [],
558
+ };
559
+ const seenDigest = new Set((targetIndex.manifests ?? []).map((item) => String(item?.digest ?? '').trim()).filter((item) => item));
560
+ for (const manifest of sourceIndex.manifests ?? []) {
561
+ const digest = String(manifest?.digest ?? '').trim();
562
+ if (!digest || seenDigest.has(digest)) {
563
+ continue;
564
+ }
565
+ seenDigest.add(digest);
566
+ targetIndex.manifests.push(manifest);
567
+ }
568
+ fs.mkdirSync(targetImagesDir, { recursive: true });
569
+ fs.writeFileSync(path.join(targetImagesDir, 'oci-layout'), JSON.stringify({ imageLayoutVersion: '1.0.0' }));
570
+ fs.writeFileSync(targetIndexPath, JSON.stringify(targetIndex, null, 2));
571
+ }
572
+
573
+ function computeEmbeddedStats(lockImages, tempDir) {
574
+ const imagesDir = path.join(tempDir, 'images', 'blobs', 'sha256');
575
+ const seenDigest = new Set();
576
+ let embeddedLayerBytes = 0;
577
+ for (const imageInfo of Object.values(lockImages ?? {})) {
578
+ for (const layer of imageInfo?.layers ?? []) {
579
+ const source = String(layer?.source ?? '').trim().toLowerCase();
580
+ const digest = String(layer?.digest ?? '').trim();
581
+ if (source !== 'embed' || !digest.startsWith(SHA256_PREFIX) || seenDigest.has(digest)) {
582
+ continue;
583
+ }
584
+ seenDigest.add(digest);
585
+ const blobPath = path.join(imagesDir, digest.slice(SHA256_PREFIX.length));
586
+ if (isFileExist(blobPath)) {
587
+ embeddedLayerBytes += fs.statSync(blobPath).size;
588
+ }
589
+ }
590
+ }
591
+ return {
592
+ embeddedLayerBytes,
593
+ embeddedLayerCount: seenDigest.size,
594
+ };
595
+ }
596
+
597
+ async function packToStageDir(bridge, targetDir, options) {
598
+ const packedArchivePath = path.join(targetDir, 'images.packed.tar');
599
+ if (options.mode === 'archive') {
600
+ await bridge.packImagesArchive(options.packSpecs, options.archivePath, packedArchivePath);
601
+ } else {
602
+ await bridge.packImages(options.packSpecs, packedArchivePath);
603
+ }
604
+ await extractPackedImagesArchive(packedArchivePath, targetDir);
605
+ fs.rmSync(packedArchivePath, { force: true });
606
+ return loadPackedResult(targetDir);
607
+ }
608
+
491
609
  export async function buildConfiguredImagesToTempDir(rawConfig, manifest, cwd, tempDir, options = {}) {
492
610
  const imageEntries = normalizeImageBuildEntries(rawConfig, cwd, manifest);
493
611
  if (imageEntries.length === 0) {
@@ -505,245 +623,301 @@ export async function buildConfiguredImagesToTempDir(rawConfig, manifest, cwd, t
505
623
  }
506
624
 
507
625
  const buildRemote = resolveBuildRemoteFromOptions(options, 'lzc-build.yml');
508
- if (!buildRemote) {
509
- await shellApi.init();
626
+ const hasLocalBuilder = imageEntries.some((item) => item.builder === 'local');
627
+ const hasRemoteBuilder = imageEntries.some((item) => item.builder === 'remote');
628
+ let bridge = null;
629
+ let targetPlatform = '';
630
+ if (hasRemoteBuilder) {
631
+ if (!buildRemote) {
632
+ await shellApi.init();
633
+ }
634
+ bridge = new DebugBridge(cwd, buildRemote);
635
+ await bridge.init();
636
+ }
637
+ if (hasLocalBuilder) {
638
+ targetPlatform = bridge ? await bridge.platform() : DEFAULT_LOCAL_TARGET_PLATFORM;
510
639
  }
511
- const bridge = new DebugBridge(cwd, buildRemote);
512
- await bridge.init();
513
-
514
640
  const imageMetaByRef = new Map();
515
641
  const imageRefs = [];
516
642
  const buildCache = loadImageBuildCache(cwd);
517
643
  const buildCacheRecords = { ...buildCache.records };
644
+ const localBuiltRefs = [];
518
645
 
519
- for (const entry of imageEntries) {
520
- const profileStartAt = Date.now();
521
- let contextCollectMs = 0;
522
- let buildStageMs = 0;
523
- let resolveStageMs = 0;
524
- let buildMode = 'build-pack';
525
-
526
- const dockerfileDesc = entry.dockerfilePath || '(inline dockerfile-content)';
527
- logger.info(`Build image for alias "${entry.alias}" from ${dockerfileDesc}`);
528
- const contextStartAt = Date.now();
529
- const dockerfileSource = ensureDockerfileForEntry(entry);
530
- let contextTar = '';
531
- try {
532
- contextTar = await collectContextFromDockerFile(entry.contextDir, dockerfileSource.dockerfilePath);
533
- } finally {
534
- dockerfileSource.cleanup();
535
- }
536
- contextCollectMs = Date.now() - contextStartAt;
537
- const contextHash = await sha256File(contextTar);
538
- const contextDigest = `${SHA256_PREFIX}${contextHash.digest}`;
539
-
540
- const cacheRecord = buildCache.records?.[entry.alias];
541
- let builtImageRef = '';
542
- let reusedFromCache = false;
543
- let imageID = '';
544
- let builtDiffIDs = [];
545
- let upstream = '';
546
- let upstreamDiffIDs = [];
547
- let archiveKey = '';
548
-
549
- if (
550
- cacheRecord &&
551
- cacheRecord.image_label === entry.imageLabel &&
552
- cacheRecord.context_digest === contextDigest &&
553
- cacheRecord.upstream_match === entry.upstreamMatch &&
554
- cacheRecord.build_mode === 'pack' &&
555
- Array.isArray(cacheRecord.built_diff_ids) &&
556
- typeof cacheRecord.image_id === 'string' &&
557
- cacheRecord.image_id.trim() !== ''
558
- ) {
646
+ try {
647
+ for (const entry of imageEntries) {
648
+ const profileStartAt = Date.now();
649
+ let contextCollectMs = 0;
650
+ let buildStageMs = 0;
651
+ let resolveStageMs = 0;
652
+ let buildMode = entry.builder === 'local' ? `local:${targetPlatform}` : 'build-pack';
653
+
654
+ const dockerfileDesc = entry.dockerfilePath || '(inline dockerfile-content)';
655
+ if (entry.builder === 'local') {
656
+ logger.info(`Build image for alias "${entry.alias}" from ${dockerfileDesc} (builder=local, target=${targetPlatform})`);
657
+ } else {
658
+ logger.info(`Build image for alias "${entry.alias}" from ${dockerfileDesc} (builder=remote, target-box=${bridge?.boxname ?? 'unknown'})`);
659
+ }
660
+ const contextStartAt = Date.now();
661
+ const dockerfileSource = ensureDockerfileForEntry(entry);
662
+ let contextTar = '';
559
663
  try {
560
- imageID = normalizeSha256Digest(cacheRecord.image_id, `cached image id of alias ${entry.alias}`);
561
- builtDiffIDs = normalizeDigestList(cacheRecord.built_diff_ids, `cached built diff ids of alias ${entry.alias}`);
562
- if (builtDiffIDs.length === 0) {
563
- throw new Error('built diff ids is empty');
564
- }
565
- upstream = cacheRecord.upstream ? String(cacheRecord.upstream).trim() : '';
566
- if (upstream && !upstream.includes('@sha256:')) {
567
- throw new Error(`invalid cached upstream: ${upstream}`);
664
+ contextTar = await collectContextFromDockerFile(entry.contextDir, dockerfileSource.dockerfilePath);
665
+ } finally {
666
+ dockerfileSource.cleanup();
667
+ }
668
+ contextCollectMs = Date.now() - contextStartAt;
669
+ const contextHash = await sha256File(contextTar);
670
+ const contextDigest = `${SHA256_PREFIX}${contextHash.digest}`;
671
+
672
+ const cacheRecord = buildCache.records?.[entry.alias];
673
+ let builtImageRef = '';
674
+ let reusedFromCache = false;
675
+ let imageID = '';
676
+ let builtDiffIDs = [];
677
+ let upstream = '';
678
+ let upstreamDiffIDs = [];
679
+ let archiveKey = '';
680
+
681
+ if (
682
+ entry.builder === 'remote' &&
683
+ cacheRecord &&
684
+ String(cacheRecord.builder ?? 'remote') === 'remote' &&
685
+ cacheRecord.image_label === entry.imageLabel &&
686
+ cacheRecord.context_digest === contextDigest &&
687
+ cacheRecord.upstream_match === entry.upstreamMatch &&
688
+ cacheRecord.build_mode === 'pack' &&
689
+ Array.isArray(cacheRecord.built_diff_ids) &&
690
+ typeof cacheRecord.image_id === 'string' &&
691
+ cacheRecord.image_id.trim() !== ''
692
+ ) {
693
+ try {
694
+ imageID = normalizeSha256Digest(cacheRecord.image_id, `cached image id of alias ${entry.alias}`);
695
+ builtDiffIDs = normalizeDigestList(cacheRecord.built_diff_ids, `cached built diff ids of alias ${entry.alias}`);
696
+ if (builtDiffIDs.length === 0) {
697
+ throw new Error('built diff ids is empty');
698
+ }
699
+ upstream = cacheRecord.upstream ? String(cacheRecord.upstream).trim() : '';
700
+ if (upstream && !upstream.includes('@sha256:')) {
701
+ throw new Error(`invalid cached upstream: ${upstream}`);
702
+ }
703
+ upstreamDiffIDs = Array.isArray(cacheRecord.upstream_diff_ids)
704
+ ? normalizeDigestList(cacheRecord.upstream_diff_ids, `cached upstream diff ids of alias ${entry.alias}`)
705
+ : [];
706
+ archiveKey = String(cacheRecord.archive_key ?? '').trim();
707
+ builtImageRef = typeof cacheRecord.image_ref === 'string' && cacheRecord.image_ref.trim() !== '' ? cacheRecord.image_ref : `debug.bridge/${entry.imageLabel}`;
708
+ reusedFromCache = true;
709
+ buildMode = 'build-pack-cache';
710
+ logger.info(`Reuse cached build-pack metadata for alias "${entry.alias}"`);
711
+ } catch (error) {
712
+ logger.info(`Cached build-pack metadata is invalid for alias "${entry.alias}", rebuild image`);
568
713
  }
569
- upstreamDiffIDs = Array.isArray(cacheRecord.upstream_diff_ids)
570
- ? normalizeDigestList(cacheRecord.upstream_diff_ids, `cached upstream diff ids of alias ${entry.alias}`)
571
- : [];
572
- archiveKey = String(cacheRecord.archive_key ?? '').trim();
573
- builtImageRef = typeof cacheRecord.image_ref === 'string' && cacheRecord.image_ref.trim() !== '' ? cacheRecord.image_ref : `debug.bridge/${entry.imageLabel}`;
574
- reusedFromCache = true;
575
- buildMode = 'build-pack-cache';
576
- logger.info(`Reuse cached build-pack metadata for alias "${entry.alias}"`);
577
- } catch (error) {
578
- logger.info(`Cached build-pack metadata is invalid for alias "${entry.alias}", rebuild image`);
579
714
  }
580
- }
581
715
 
582
- const buildStartAt = Date.now();
583
- if (!reusedFromCache) {
584
- const buildPackResult = await bridge.buildImageForPack(entry.imageLabel, contextTar);
585
- builtImageRef = String(buildPackResult?.tag ?? '').trim() || `debug.bridge/${entry.imageLabel}`;
586
- buildMode = 'build-pack';
587
- archiveKey = String(buildPackResult?.archiveKey ?? '').trim();
588
- imageID = normalizeSha256Digest(buildPackResult?.imageID, `build-pack image id of alias ${entry.alias}`);
589
- builtDiffIDs = normalizeDigestList(buildPackResult?.diffIDs ?? [], `build-pack diff ids of alias ${entry.alias}`);
590
- if (builtDiffIDs.length === 0) {
591
- throw new Error(`No rootfs layer found in build-pack output for alias "${entry.alias}"`);
592
- }
593
- const baseRepoDigest = String(buildPackResult?.baseRepoDigest ?? '').trim();
594
- if (baseRepoDigest) {
595
- if (!baseRepoDigest.includes('@sha256:')) {
596
- throw new Error(`Invalid baseRepoDigest from build-pack of alias "${entry.alias}": ${baseRepoDigest}`);
716
+ const buildStartAt = Date.now();
717
+ if (entry.builder === 'local') {
718
+ const dockerfileSourceForLocalBuild = ensureDockerfileForEntry(entry);
719
+ try {
720
+ const localBuildResult = await buildLocalImageForPack(entry, targetPlatform, dockerfileSourceForLocalBuild.dockerfilePath);
721
+ builtImageRef = localBuildResult.ref;
722
+ imageID = normalizeSha256Digest(localBuildResult.imageID, `local build image id of alias ${entry.alias}`);
723
+ builtDiffIDs = normalizeDigestList(localBuildResult.builtDiffIDs ?? [], `local build diff ids of alias ${entry.alias}`);
724
+ archiveKey = '';
725
+ upstream = localBuildResult.upstream ? String(localBuildResult.upstream).trim() : '';
726
+ upstreamDiffIDs = Array.isArray(localBuildResult.upstreamDiffIDs)
727
+ ? normalizeDigestList(localBuildResult.upstreamDiffIDs, `local build upstream diff ids of alias ${entry.alias}`)
728
+ : [];
729
+ localBuiltRefs.push(builtImageRef);
730
+ } finally {
731
+ dockerfileSourceForLocalBuild.cleanup();
732
+ fs.rmSync(contextTar, { force: true });
597
733
  }
598
- const baseRepo = extractRepoFromRepoDigest(baseRepoDigest);
599
- if (!entry.upstreamMatch || baseRepo.startsWith(entry.upstreamMatch)) {
600
- upstream = baseRepoDigest;
601
- if (Array.isArray(buildPackResult?.baseDiffIDs) && buildPackResult.baseDiffIDs.length > 0) {
602
- upstreamDiffIDs = normalizeDigestList(buildPackResult.baseDiffIDs, `build-pack base diff ids of alias ${entry.alias}`);
734
+ delete buildCacheRecords[entry.alias];
735
+ } else if (!reusedFromCache) {
736
+ const buildPackResult = await bridge.buildImageForPack(entry.imageLabel, contextTar);
737
+ builtImageRef = String(buildPackResult?.tag ?? '').trim() || `debug.bridge/${entry.imageLabel}`;
738
+ buildMode = 'build-pack';
739
+ archiveKey = String(buildPackResult?.archiveKey ?? '').trim();
740
+ imageID = normalizeSha256Digest(buildPackResult?.imageID, `build-pack image id of alias ${entry.alias}`);
741
+ builtDiffIDs = normalizeDigestList(buildPackResult?.diffIDs ?? [], `build-pack diff ids of alias ${entry.alias}`);
742
+ if (builtDiffIDs.length === 0) {
743
+ throw new Error(`No rootfs layer found in build-pack output for alias "${entry.alias}"`);
744
+ }
745
+ const baseRepoDigest = String(buildPackResult?.baseRepoDigest ?? '').trim();
746
+ if (baseRepoDigest) {
747
+ if (!baseRepoDigest.includes('@sha256:')) {
748
+ throw new Error(`Invalid baseRepoDigest from build-pack of alias "${entry.alias}": ${baseRepoDigest}`);
603
749
  }
604
- if (upstreamDiffIDs.length > 0 && !startsWithDigestList(builtDiffIDs, upstreamDiffIDs)) {
605
- logger.warn(`Ignore invalid upstream layer prefix for alias "${entry.alias}" from build-pack metadata`);
606
- upstream = '';
607
- upstreamDiffIDs = [];
750
+ const baseRepo = extractRepoFromRepoDigest(baseRepoDigest);
751
+ if (!entry.upstreamMatch || baseRepo.startsWith(entry.upstreamMatch)) {
752
+ upstream = baseRepoDigest;
753
+ if (Array.isArray(buildPackResult?.baseDiffIDs) && buildPackResult.baseDiffIDs.length > 0) {
754
+ upstreamDiffIDs = normalizeDigestList(buildPackResult.baseDiffIDs, `build-pack base diff ids of alias ${entry.alias}`);
755
+ }
756
+ if (upstreamDiffIDs.length > 0 && !startsWithDigestList(builtDiffIDs, upstreamDiffIDs)) {
757
+ logger.warn(`Ignore invalid upstream layer prefix for alias "${entry.alias}" from build-pack metadata`);
758
+ upstream = '';
759
+ upstreamDiffIDs = [];
760
+ }
608
761
  }
609
762
  }
763
+ } else {
764
+ fs.rmSync(contextTar, { force: true });
610
765
  }
611
- } else {
612
- fs.rmSync(contextTar, { force: true });
613
- }
614
- buildStageMs = Date.now() - buildStartAt;
615
- imageRefs.push(builtImageRef);
616
-
617
- const resolveStartAt = Date.now();
618
- if (!imageID || builtDiffIDs.length === 0) {
619
- throw new Error(`No rootfs layer metadata found in build-pack output for alias "${entry.alias}"`);
620
- }
621
- resolveStageMs = Date.now() - resolveStartAt;
622
-
623
- if (upstreamDiffIDs.length > 0 && !startsWithDigestList(builtDiffIDs, upstreamDiffIDs)) {
624
- throw new Error(`Failed to derive mixed layers for alias "${entry.alias}", built layers do not start with upstream layers`);
625
- }
766
+ buildStageMs = Date.now() - buildStartAt;
767
+ imageRefs.push(builtImageRef);
626
768
 
627
- const embeddedDiffIDs = upstreamDiffIDs.length === 0 ? builtDiffIDs : builtDiffIDs.slice(upstreamDiffIDs.length);
628
- if (embeddedDiffIDs.length === 0 && !upstream) {
629
- throw new Error(`Alias "${entry.alias}" has no embed layers and no upstream image`);
630
- }
769
+ const resolveStartAt = Date.now();
770
+ if (!imageID || builtDiffIDs.length === 0) {
771
+ throw new Error(`No rootfs layer metadata found in build output for alias "${entry.alias}"`);
772
+ }
773
+ resolveStageMs = Date.now() - resolveStartAt;
631
774
 
632
- imageMetaByRef.set(builtImageRef, {
633
- alias: entry.alias,
634
- imageID,
635
- upstream: upstream || '',
636
- embeddedDiffIDs,
637
- archiveKey: archiveKey || '',
638
- });
639
- buildCacheRecords[entry.alias] = {
640
- image_label: entry.imageLabel,
641
- context_digest: contextDigest,
642
- upstream_match: entry.upstreamMatch,
643
- image_ref: builtImageRef,
644
- image_id: imageID,
645
- build_mode: 'pack',
646
- built_diff_ids: builtDiffIDs,
647
- upstream: upstream || '',
648
- upstream_diff_ids: upstreamDiffIDs,
649
- archive_key: archiveKey || '',
650
- };
775
+ if (upstreamDiffIDs.length > 0 && !startsWithDigestList(builtDiffIDs, upstreamDiffIDs)) {
776
+ throw new Error(`Failed to derive mixed layers for alias "${entry.alias}", built layers do not start with upstream layers`);
777
+ }
651
778
 
652
- logger.info(`Image alias "${entry.alias}" is ready: ${builtImageRef}`);
653
- logger.info(
654
- `[profile] alias=${entry.alias} mode=${buildMode} context=${formatDurationMs(contextCollectMs)} build=${formatDurationMs(
655
- buildStageMs,
656
- )} resolve=${formatDurationMs(resolveStageMs)} total=${formatDurationMs(Date.now() - profileStartAt)}`,
657
- );
658
- }
659
- saveImageBuildCache(buildCache.cachePath, buildCacheRecords);
660
-
661
- const packageCacheKey = computePackageCacheKey([...imageMetaByRef.values()]);
662
- const cachedPackage = tryLoadPackageCache(cwd, packageCacheKey, tempDir);
663
- let convertResult = null;
664
- if (cachedPackage) {
665
- logger.info('Reuse cached OCI image package');
666
- convertResult = {
667
- lockImages: Object.fromEntries(
668
- [...imageMetaByRef.values()].map((meta) => [
669
- meta.alias,
670
- {
671
- image_id: meta.imageID,
672
- upstream: meta.upstream ?? '',
673
- },
674
- ]),
675
- ),
676
- embeddedLayerBytes: cachedPackage.embeddedLayerBytes,
677
- embeddedLayerCount: cachedPackage.embeddedLayerCount,
678
- };
679
- }
779
+ const embeddedDiffIDs = upstreamDiffIDs.length === 0 ? builtDiffIDs : builtDiffIDs.slice(upstreamDiffIDs.length);
780
+ if (embeddedDiffIDs.length === 0 && !upstream) {
781
+ throw new Error(`Alias "${entry.alias}" has no embed layers and no upstream image`);
782
+ }
680
783
 
681
- if (!convertResult) {
682
- const packBridgeStartAt = Date.now();
683
- const packedArchivePath = path.join(tempDir, 'images.packed.tar');
684
- const packSpecs = imageRefs.map((ref) => {
685
- const meta = imageMetaByRef.get(ref);
686
- if (!meta) {
687
- throw new Error(`Missing image meta for ref: ${ref}`);
784
+ imageMetaByRef.set(builtImageRef, {
785
+ alias: entry.alias,
786
+ builder: entry.builder,
787
+ platform: entry.builder === 'local' ? targetPlatform : '',
788
+ imageID,
789
+ upstream: upstream || '',
790
+ embeddedDiffIDs,
791
+ archiveKey: archiveKey || '',
792
+ });
793
+ if (entry.builder === 'remote') {
794
+ buildCacheRecords[entry.alias] = {
795
+ builder: 'remote',
796
+ image_label: entry.imageLabel,
797
+ context_digest: contextDigest,
798
+ upstream_match: entry.upstreamMatch,
799
+ image_ref: builtImageRef,
800
+ image_id: imageID,
801
+ build_mode: 'pack',
802
+ built_diff_ids: builtDiffIDs,
803
+ upstream: upstream || '',
804
+ upstream_diff_ids: upstreamDiffIDs,
805
+ archive_key: archiveKey || '',
806
+ };
688
807
  }
689
- return {
690
- ref,
691
- alias: meta.alias,
692
- imageID: meta.imageID,
693
- upstream: meta.upstream ?? '',
694
- embeddedDiffIDs: meta.embeddedDiffIDs,
695
- archiveKey: meta.archiveKey || undefined,
696
- };
697
- });
698
- logger.info('Pack built images to OCI layout in debug bridge');
699
- await bridge.packImages(packSpecs, packedArchivePath);
700
- const packBridgeMs = Date.now() - packBridgeStartAt;
701
- const extractStartAt = Date.now();
702
- await tar.x({
703
- file: packedArchivePath,
704
- cwd: tempDir,
705
- });
706
- const extractMs = Date.now() - extractStartAt;
707
- fs.rmSync(packedArchivePath, { force: true });
708
- const parseStartAt = Date.now();
709
- const packedResultPath = path.join(tempDir, 'pack-result.json');
710
- if (!isFileExist(packedResultPath)) {
711
- throw new Error('pack-result.json not found after pack-images');
712
- }
713
- const packedResult = JSON.parse(fs.readFileSync(packedResultPath, 'utf-8'));
714
- fs.rmSync(packedResultPath, { force: true });
715
- if (!isPlainObject(packedResult?.lockImages)) {
716
- throw new Error('Invalid pack-result.json: lockImages is missing');
717
- }
718
- convertResult = {
719
- lockImages: packedResult.lockImages,
720
- embeddedLayerBytes: Number(packedResult?.embeddedLayerBytes ?? 0),
721
- embeddedLayerCount: Number(packedResult?.embeddedLayerCount ?? 0),
722
- };
723
- const parseMs = Date.now() - parseStartAt;
808
+
809
+ logger.info(`Image alias "${entry.alias}" is ready: ${builtImageRef}`);
724
810
  logger.info(
725
- `[profile] pack-images bridge=${formatDurationMs(packBridgeMs)} extract=${formatDurationMs(extractMs)} parse=${formatDurationMs(parseMs)} total=${formatDurationMs(
726
- packBridgeMs + extractMs + parseMs,
727
- )}`,
811
+ `[profile] alias=${entry.alias} mode=${buildMode} context=${formatDurationMs(contextCollectMs)} build=${formatDurationMs(buildStageMs)} resolve=${formatDurationMs(resolveStageMs)} total=${formatDurationMs(Date.now() - profileStartAt)}`,
728
812
  );
729
813
  }
730
- convertResult.lockImages = rewriteImagesLock(tempDir, convertResult.lockImages);
731
- if (!cachedPackage) {
814
+ saveImageBuildCache(buildCache.cachePath, buildCacheRecords);
815
+
816
+ const packageCacheKey = computePackageCacheKey([...imageMetaByRef.values()]);
817
+ const cachedPackage = tryLoadPackageCache(cwd, packageCacheKey, tempDir);
818
+ let convertResult = null;
819
+ if (cachedPackage) {
820
+ logger.info('Reuse cached OCI image package');
821
+ convertResult = {
822
+ lockImages: Object.fromEntries(
823
+ [...imageMetaByRef.values()].map((meta) => [
824
+ meta.alias,
825
+ {
826
+ image_id: meta.imageID,
827
+ upstream: meta.upstream ?? '',
828
+ },
829
+ ]),
830
+ ),
831
+ embeddedLayerBytes: cachedPackage.embeddedLayerBytes,
832
+ embeddedLayerCount: cachedPackage.embeddedLayerCount,
833
+ };
834
+ }
835
+
836
+ if (!convertResult) {
837
+ const mergedLockImages = {};
838
+ const remotePackSpecs = [];
839
+ const localPackSpecs = [];
840
+ for (const ref of imageRefs) {
841
+ const meta = imageMetaByRef.get(ref);
842
+ if (!meta) {
843
+ throw new Error(`Missing image meta for ref: ${ref}`);
844
+ }
845
+ const spec = {
846
+ ref,
847
+ alias: meta.alias,
848
+ imageID: meta.imageID,
849
+ upstream: meta.upstream ?? '',
850
+ embeddedDiffIDs: meta.embeddedDiffIDs,
851
+ archiveKey: meta.archiveKey || undefined,
852
+ };
853
+ if (meta.builder === 'local') {
854
+ localPackSpecs.push(spec);
855
+ } else {
856
+ remotePackSpecs.push(spec);
857
+ }
858
+ }
859
+
860
+ const packStartAt = Date.now();
861
+ if (remotePackSpecs.length > 0) {
862
+ const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lzc-cli-pack-remote-'));
863
+ try {
864
+ const remoteResult = await packToStageDir(bridge, stageDir, {
865
+ mode: 'remote',
866
+ packSpecs: remotePackSpecs,
867
+ });
868
+ mergeOciLayout(stageDir, tempDir);
869
+ Object.assign(mergedLockImages, remoteResult.lockImages);
870
+ } finally {
871
+ fs.rmSync(stageDir, { recursive: true, force: true });
872
+ }
873
+ }
874
+ if (localPackSpecs.length > 0) {
875
+ const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lzc-cli-pack-local-'));
876
+ const archivePath = path.join(stageDir, 'local-images.docker.tar');
877
+ try {
878
+ const localRefs = localPackSpecs.map((item) => item.ref);
879
+ logger.info(`Prepare local-built images for packaging (builder=local, target=${targetPlatform})`);
880
+ logger.info(`Export local docker images to archive: ${localRefs.join(', ')}`);
881
+ await saveLocalDockerImagesArchive(localRefs, archivePath);
882
+ logger.info(`Pack local image archive to OCI layout locally (target=${targetPlatform})`);
883
+ const localResult = await packLocalArchiveToStageDir(archivePath, localPackSpecs, stageDir);
884
+ mergeOciLayout(stageDir, tempDir);
885
+ Object.assign(mergedLockImages, localResult.lockImages);
886
+ } finally {
887
+ fs.rmSync(stageDir, { recursive: true, force: true });
888
+ }
889
+ }
890
+ logger.info(`[profile] pack-images total=${formatDurationMs(Date.now() - packStartAt)}`);
891
+ const compactedLockImages = rewriteImagesLock(tempDir, mergedLockImages);
892
+ const embeddedStats = computeEmbeddedStats(compactedLockImages, tempDir);
893
+ convertResult = {
894
+ lockImages: compactedLockImages,
895
+ embeddedLayerBytes: embeddedStats.embeddedLayerBytes,
896
+ embeddedLayerCount: embeddedStats.embeddedLayerCount,
897
+ };
732
898
  savePackageCache(cwd, packageCacheKey, tempDir, convertResult);
733
899
  }
734
900
 
735
901
  const upstreamByAlias = {};
736
902
  const resolvedImageByAlias = {};
737
- for (const meta of imageMetaByRef.values()) {
738
- upstreamByAlias[meta.alias] = meta.upstream || '';
739
- resolvedImageByAlias[meta.alias] = meta.imageID;
740
- }
903
+ for (const meta of imageMetaByRef.values()) {
904
+ upstreamByAlias[meta.alias] = meta.upstream || '';
905
+ resolvedImageByAlias[meta.alias] = meta.imageID;
906
+ }
741
907
 
742
- return {
743
- imageCount: Object.keys(convertResult.lockImages).length,
744
- upstreamByAlias,
745
- resolvedImageByAlias,
746
- embeddedLayerBytes: convertResult.embeddedLayerBytes,
747
- embeddedLayerCount: convertResult.embeddedLayerCount,
748
- };
908
+ return {
909
+ imageCount: Object.keys(convertResult.lockImages).length,
910
+ upstreamByAlias,
911
+ resolvedImageByAlias,
912
+ embeddedLayerBytes: convertResult.embeddedLayerBytes,
913
+ embeddedLayerCount: convertResult.embeddedLayerCount,
914
+ };
915
+ } finally {
916
+ await removeLocalDockerImages(localBuiltRefs);
917
+ }
749
918
  }
919
+
920
+ export const __test__ = {
921
+ normalizeImageBuildEntries,
922
+ computePackageCacheKey,
923
+ };