@phystack/device-phyos 4.5.18-dev → 4.5.20-dev

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.
@@ -13,6 +13,8 @@ const docker_progress_tracker_1 = require("./docker-progress-tracker");
13
13
  const uuid_1 = require("uuid");
14
14
  const hub_client_1 = require("@phystack/hub-client");
15
15
  const fs_1 = __importDefault(require("fs"));
16
+ const path_1 = __importDefault(require("path"));
17
+ const IMAGE_PULL_TIMES_FILE = '/data/settings/phyhub/docker-image-pull-times.json';
16
18
  class DockerManager {
17
19
  docker;
18
20
  logger;
@@ -26,6 +28,7 @@ class DockerManager {
26
28
  sessionId = null;
27
29
  operationLock = new async_mutex_1.Mutex();
28
30
  imagePullsInProgress = new Map();
31
+ imagePullTimes = new Map();
29
32
  constructor() {
30
33
  this.docker = new dockerode_1.default();
31
34
  this.logger = new phy_logger_1.PhyLogger({
@@ -37,6 +40,10 @@ class DockerManager {
37
40
  this.twinInstancesRunning = new Map();
38
41
  this.imagesInProgress = new Map();
39
42
  this.instances = new Map();
43
+ this.loadImagePullTimes();
44
+ this.cleanupStaleImagePullTimes().catch((error) => {
45
+ this.logger.warn('cleanupStaleImagePullTimes(): Failed to cleanup stale entries', error);
46
+ });
40
47
  this.listenToDockerEvents();
41
48
  }
42
49
  async initializeHubSignals(initSignalsParams, dataResidency) {
@@ -59,24 +66,39 @@ class DockerManager {
59
66
  eventStream.on('data', async (buffer) => {
60
67
  try {
61
68
  const event = JSON.parse(buffer.toString());
62
- if (event.status === 'die' && event.exitCode !== 0) {
63
- await this.onContainerCrash(event.id);
69
+ if (event.status === 'die') {
70
+ const exitCode = parseInt(event.Actor?.Attributes?.exitCode || '0', 10);
71
+ if (exitCode !== 0) {
72
+ await this.onContainerCrash(event.id);
73
+ }
64
74
  }
65
75
  }
66
76
  catch (error) {
77
+ this.logger.error('listenToDockerEvents(): Error processing Docker event:', error);
67
78
  }
68
79
  });
69
80
  eventStream.on('error', (error) => {
81
+ this.logger.error('listenToDockerEvents(): Docker event stream error:', error);
70
82
  setTimeout(() => this.listenToDockerEvents(), 5000);
71
83
  });
72
84
  eventStream.on('end', () => {
85
+ this.logger.warn('listenToDockerEvents(): Docker event stream ended, attempting to reconnect...');
73
86
  setTimeout(() => this.listenToDockerEvents(), 5000);
74
87
  });
75
88
  }
76
89
  catch (error) {
90
+ this.logger.error('listenToDockerEvents(): Failed to set up Docker event listener:', error);
77
91
  setTimeout(() => this.listenToDockerEvents(), 5000);
78
92
  }
79
93
  }
94
+ isCredentialError(error) {
95
+ const msg = error?.message || '';
96
+ return (msg.includes('unauthorized') ||
97
+ msg.includes('authentication required') ||
98
+ msg.includes('access denied') ||
99
+ msg.includes('credential') ||
100
+ error?.statusCode === 401);
101
+ }
80
102
  getDefaultPlatform() {
81
103
  const arch = process.arch;
82
104
  if (arch === 'x64') {
@@ -130,12 +152,12 @@ class DockerManager {
130
152
  : 0;
131
153
  const files = fs_1.default.readdirSync(coreDumpDir);
132
154
  const coreFiles = files
133
- .filter((fileName) => fileName.startsWith('core.'))
134
- .map((fileName) => ({
155
+ .filter(fileName => fileName.startsWith('core.'))
156
+ .map(fileName => ({
135
157
  name: fileName,
136
158
  mtime: fs_1.default.statSync(`${coreDumpDir}/${fileName}`).mtime.getTime(),
137
159
  }))
138
- .filter((coreFile) => crashTime === 0 || coreFile.mtime >= crashTime)
160
+ .filter(coreFile => crashTime === 0 || coreFile.mtime >= crashTime)
139
161
  .sort((firstFile, secondFile) => secondFile.mtime - firstFile.mtime);
140
162
  if (coreFiles.length > 0) {
141
163
  coreDumpInfo = {
@@ -190,7 +212,7 @@ class DockerManager {
190
212
  };
191
213
  await this.initializeHubSignals(initSignalsParams, deviceStatus.dataResidency.toUpperCase());
192
214
  }
193
- if (this.phyHubClient) {
215
+ if (this.phyHubClient && this.signalsClient) {
194
216
  if (!this.sessionId) {
195
217
  const instance = this.signalsClient.getInstanceProps();
196
218
  this.sessionId = instance.sessionId;
@@ -283,6 +305,7 @@ class DockerManager {
283
305
  return resolve();
284
306
  }
285
307
  let isCompleted = false;
308
+ const streamErrors = [];
286
309
  const abortHandler = () => {
287
310
  if (isCompleted)
288
311
  return;
@@ -317,13 +340,7 @@ class DockerManager {
317
340
  if (err) {
318
341
  clearTimeout(timeoutId);
319
342
  this.logger.error(`pullImage(): Error initiating pull for image ${image}`, err);
320
- const isCredentialError = err.message &&
321
- (err.message.includes('unauthorized') ||
322
- err.message.includes('authentication required') ||
323
- err.message.includes('access denied') ||
324
- err.message.includes('credential') ||
325
- err.statusCode === 401);
326
- if (isCredentialError) {
343
+ if (this.isCredentialError(err)) {
327
344
  this.logger.error(`pullImage(): Credential error detected for image ${image}, releasing lock immediately`);
328
345
  const imageProgress = this.imagesInProgress.get(image);
329
346
  if (imageProgress) {
@@ -344,25 +361,82 @@ class DockerManager {
344
361
  clearTimeout(timeoutId);
345
362
  if (isCompleted)
346
363
  return;
347
- if (err) {
348
- this.logger.error(`pullImage(): Error pulling image ${image}`, err);
364
+ const actualError = err ||
365
+ (streamErrors.length > 0
366
+ ? new Error(`Pull failed with ${streamErrors.length} error(s): ${streamErrors.map(streamError => streamError.message).join('; ')}`)
367
+ : null);
368
+ if (actualError) {
369
+ this.logger.error(`pullImage(): Error pulling image ${image}`, actualError);
370
+ if (streamErrors.length > 0) {
371
+ this.logger.error(`pullImage(): Stream errors captured during pull:`, streamErrors.map(streamError => streamError.message));
372
+ }
349
373
  imageProgress.inProgress = false;
350
374
  this.imagesInProgress.delete(image);
351
375
  isCompleted = true;
352
- progressTracker.markFailed(err);
353
- reject(err);
376
+ progressTracker.markFailed(actualError);
377
+ reject(actualError);
354
378
  return;
355
379
  }
356
380
  this.logger.info(`pullImage(): Image ${image} download complete`);
357
- imageProgress.inProgress = false;
358
- this.imagesInProgress.delete(image);
359
- isCompleted = true;
360
- progressTracker.markComplete();
361
- resolve();
381
+ (async () => {
382
+ try {
383
+ const imageExists = await this.imageExists(image);
384
+ if (!imageExists) {
385
+ const errorMsg = `Image ${image} pull reported success but image is not available locally. ` +
386
+ `This may indicate a network issue, proxy problem, or the image does not exist.`;
387
+ this.logger.error(`pullImage(): ${errorMsg}`);
388
+ imageProgress.inProgress = false;
389
+ this.imagesInProgress.delete(image);
390
+ isCompleted = true;
391
+ progressTracker.markFailed(new Error(errorMsg));
392
+ reject(new Error(errorMsg));
393
+ return;
394
+ }
395
+ imageProgress.inProgress = false;
396
+ this.imagesInProgress.delete(image);
397
+ isCompleted = true;
398
+ progressTracker.markComplete();
399
+ (async () => {
400
+ try {
401
+ const imageObject = this.docker.getImage(image);
402
+ const imageInspection = await imageObject.inspect();
403
+ const imageId = imageInspection.Id;
404
+ const pullTimestamp = Math.floor(Date.now() / 1000);
405
+ this.imagePullTimes.set(imageId, pullTimestamp);
406
+ const pullTimeFormatted = new Date(pullTimestamp * 1000).toISOString();
407
+ this.logger.info(`pullImage(): Recorded pull time for ${image} ` +
408
+ `(ID: ${imageId.substring(0, 12)}): ${pullTimeFormatted}`);
409
+ }
410
+ catch (error) {
411
+ this.logger.warn(`pullImage(): Failed to record pull time for ${image}`, error);
412
+ }
413
+ })();
414
+ this.logger.info(`pullImage(): Starting image management for ${image}`);
415
+ this.manageDockerImages(image).catch((error) => {
416
+ this.logger.error(`pullImage(): Error managing images after pull for ${image}`, error);
417
+ });
418
+ resolve();
419
+ }
420
+ catch (verifyError) {
421
+ const errorMsg = `Failed to verify image ${image} exists after pull: ` +
422
+ `${verifyError.message || verifyError}`;
423
+ this.logger.error(`pullImage(): ${errorMsg}`, verifyError);
424
+ imageProgress.inProgress = false;
425
+ this.imagesInProgress.delete(image);
426
+ isCompleted = true;
427
+ progressTracker.markFailed(verifyError);
428
+ reject(new Error(errorMsg));
429
+ }
430
+ })();
362
431
  }, (event) => {
363
432
  if (abortController.signal.aborted || isCompleted) {
364
433
  return;
365
434
  }
435
+ if (event.error || event.errorDetail) {
436
+ const errorMsg = event.errorDetail?.message || event.error || 'Unknown pull error';
437
+ this.logger.error(`pullImage(): Error event during pull for ${image}: ${errorMsg}`, { event });
438
+ streamErrors.push(new Error(errorMsg));
439
+ }
366
440
  progressTracker.processEvent(event);
367
441
  if (event.id && event.progressDetail) {
368
442
  imageProgress.layersProgress.set(event.id, event.progressDetail);
@@ -379,6 +453,24 @@ class DockerManager {
379
453
  this.logger.info(`createContainer(): Device requests for ${twin.id}:`, JSON.stringify(parsedOptions?.HostConfig?.DeviceRequests, null, 2));
380
454
  const isHostNetwork = parsedOptions?.HostConfig?.NetworkMode === 'host';
381
455
  const existingBinds = parsedOptions?.HostConfig?.Binds ?? [];
456
+ const coreDumpHostDir = '/var/coredumps';
457
+ try {
458
+ if (!fs_1.default.existsSync(coreDumpHostDir)) {
459
+ fs_1.default.mkdirSync(coreDumpHostDir, { recursive: true, mode: 0o777 });
460
+ this.logger.info(`createContainer(): Created core dump directory ${coreDumpHostDir}`);
461
+ }
462
+ try {
463
+ fs_1.default.chmodSync(coreDumpHostDir, 0o777);
464
+ this.logger.info(`createContainer(): Set permissions on ${coreDumpHostDir} to 777`);
465
+ }
466
+ catch (chmodError) {
467
+ this.logger.error(`createContainer(): CRITICAL: Could not set permissions on ${coreDumpHostDir}. ` +
468
+ `Container may not be able to write coredumps. Error:`, chmodError);
469
+ }
470
+ }
471
+ catch (error) {
472
+ this.logger.error(`createContainer(): Failed to create or verify ${coreDumpHostDir}, mount may fail`, error);
473
+ }
382
474
  const coreDumpBind = '/var/coredumps:/var/coredumps:z';
383
475
  const hasCoreDumpBind = existingBinds.some((bind) => bind.includes('/var/coredumps'));
384
476
  const allBinds = hasCoreDumpBind ? existingBinds : [...existingBinds, coreDumpBind];
@@ -392,7 +484,11 @@ class DockerManager {
392
484
  ];
393
485
  const containerConfig = {
394
486
  Image: image,
395
- Env: [...(parsedOptions?.Env ?? []), `TWIN_ID=${twin.id}`],
487
+ Env: [
488
+ ...(parsedOptions?.Env ?? []),
489
+ `TWIN_ID=${twin.id}`,
490
+ ...(process.env.TZ ? [`TZ=${process.env.TZ}`] : []),
491
+ ],
396
492
  Entrypoint: parsedOptions?.Entrypoint,
397
493
  Cmd: parsedOptions?.Cmd,
398
494
  HostConfig: {
@@ -509,22 +605,17 @@ class DockerManager {
509
605
  error.message.includes('gpu'));
510
606
  if (isGpuError) {
511
607
  this.logger.warn(`startContainer(): GPU support not available for twin ${twin.id}, retrying without GPU capabilities. Error: ${error.message}`, { error: error.message });
512
- try {
513
- this.instances.delete(twin.id);
514
- const containerId = error.json?.message?.match(/^.*container: (.*?)(?:\s|$)/)?.[1];
515
- if (containerId) {
516
- try {
517
- await this.docker.getContainer(containerId).remove({ force: true });
518
- this.logger.info(`startContainer(): Removed failed GPU container ${containerId}`);
519
- }
520
- catch (removeError) {
521
- this.logger.info(`startContainer(): Could not remove container ${containerId}, may not exist`);
522
- }
608
+ const existingInstance = this.instances.get(twin.id);
609
+ if (existingInstance?.container) {
610
+ try {
611
+ await existingInstance.container.remove({ force: true });
612
+ this.logger.info(`startContainer(): Removed failed GPU container for twin ${twin.id}`);
613
+ }
614
+ catch (removeError) {
615
+ this.logger.info(`startContainer(): Could not remove container for twin ${twin.id}, may not exist`);
523
616
  }
524
617
  }
525
- catch (cleanupError) {
526
- this.logger.warn(`startContainer(): Error during cleanup: ${cleanupError.message}`);
527
- }
618
+ this.instances.delete(twin.id);
528
619
  const container = await this.createContainer(twin, true);
529
620
  this.instances.set(twin.id, { container, twin });
530
621
  await container.start();
@@ -577,18 +668,20 @@ class DockerManager {
577
668
  this.logger.info(`ensureImageAvailable(): Pull already in progress for ${image}, waiting for it to complete`);
578
669
  try {
579
670
  await existingPull.promise;
580
- this.logger.info(`ensureImageAvailable(): Existing pull for ${image} completed successfully`);
671
+ const imageExists = await this.imageExists(image);
672
+ if (!imageExists) {
673
+ const errorMsg = `Existing pull for ${image} reported success but image is not available locally`;
674
+ this.logger.error(`ensureImageAvailable(): ${errorMsg}`);
675
+ this.imagePullsInProgress.delete(image);
676
+ return false;
677
+ }
678
+ this.logger.info(`ensureImageAvailable(): Existing pull for ${image} completed successfully ` +
679
+ `and image verified locally`);
581
680
  return true;
582
681
  }
583
682
  catch (error) {
584
683
  this.logger.error(`ensureImageAvailable(): Existing pull for ${image} failed`, error);
585
- const isCredentialError = error.message &&
586
- (error.message.includes('unauthorized') ||
587
- error.message.includes('authentication required') ||
588
- error.message.includes('access denied') ||
589
- error.message.includes('credential') ||
590
- error.statusCode === 401);
591
- if (isCredentialError) {
684
+ if (this.isCredentialError(error)) {
592
685
  this.logger.error(`ensureImageAvailable(): Credential error detected for existing pull of ${image}`);
593
686
  }
594
687
  this.imagePullsInProgress.delete(image);
@@ -606,21 +699,21 @@ class DockerManager {
606
699
  });
607
700
  try {
608
701
  await pullPromise;
609
- this.logger.info(`ensureImageAvailable(): Pull for ${image} completed successfully`);
702
+ const imageExists = await this.imageExists(image);
703
+ if (!imageExists) {
704
+ const errorMsg = `Pull for ${image} reported success but image is not available locally`;
705
+ this.logger.error(`ensureImageAvailable(): ${errorMsg}`);
706
+ return false;
707
+ }
708
+ this.logger.info(`ensureImageAvailable(): Pull for ${image} completed successfully ` +
709
+ `and image verified locally`);
610
710
  return true;
611
711
  }
612
712
  catch (error) {
613
713
  this.logger.error(`ensureImageAvailable(): Pull failed for ${image}`, error);
614
- const isCredentialError = error.message &&
615
- (error.message.includes('unauthorized') ||
616
- error.message.includes('authentication required') ||
617
- error.message.includes('access denied') ||
618
- error.message.includes('credential') ||
619
- error.statusCode === 401);
620
- if (isCredentialError) {
714
+ if (this.isCredentialError(error)) {
621
715
  this.logger.error(`ensureImageAvailable(): Credential error detected for pull of ${image}`);
622
716
  }
623
- this.imagePullsInProgress.delete(image);
624
717
  return false;
625
718
  }
626
719
  }
@@ -671,15 +764,10 @@ class DockerManager {
671
764
  catch (error) {
672
765
  this.logger.error(`startEdgeApp(): Error pulling image ${image}`, error);
673
766
  const errorMsg = error.message || '';
674
- const isCredentialError = errorMsg.includes('unauthorized') ||
675
- errorMsg.includes('authentication required') ||
676
- errorMsg.includes('access denied') ||
677
- errorMsg.includes('credential') ||
678
- error.statusCode === 401;
679
767
  return {
680
768
  status: twin_types_1.TwinStatusEnum.ImageNotFound,
681
769
  error: {
682
- message: isCredentialError
770
+ message: this.isCredentialError(error)
683
771
  ? `Authentication failed for image ${image}. Please check your credentials.`
684
772
  : `Failed to pull image ${image}: ${errorMsg}`,
685
773
  timestamp: new Date(),
@@ -834,30 +922,159 @@ class DockerManager {
834
922
  getInstances() {
835
923
  return this.instances;
836
924
  }
925
+ extractRepositoryName(image) {
926
+ this.logger.info(`extractRepositoryName(): Processing image: ${image}`);
927
+ let normalizedImage = image;
928
+ if (image.startsWith('docker.io/')) {
929
+ normalizedImage = image.substring('docker.io/'.length);
930
+ this.logger.info(`extractRepositoryName(): Removed docker.io/ prefix: ${normalizedImage}`);
931
+ }
932
+ const tagSeparatorIndex = normalizedImage.lastIndexOf(':');
933
+ const pathSeparatorIndex = normalizedImage.lastIndexOf('/');
934
+ if (tagSeparatorIndex === -1) {
935
+ this.logger.info(`extractRepositoryName(): No tag found, repository: ${normalizedImage}`);
936
+ return normalizedImage;
937
+ }
938
+ if (pathSeparatorIndex !== -1 && tagSeparatorIndex < pathSeparatorIndex) {
939
+ this.logger.info(`extractRepositoryName(): Colon is port separator, repository: ${normalizedImage}`);
940
+ return normalizedImage;
941
+ }
942
+ const repositoryName = normalizedImage.substring(0, tagSeparatorIndex);
943
+ this.logger.info(`extractRepositoryName(): Extracted repository: ${repositoryName}`);
944
+ return repositoryName;
945
+ }
837
946
  async manageDockerImages(image) {
838
947
  try {
839
- const images = await this.docker.listImages({
840
- filters: {
841
- reference: [image.split(':')[0]],
842
- },
948
+ this.logger.info(`manageDockerImages(): Starting management for ${image}`);
949
+ const repositoryName = this.extractRepositoryName(image);
950
+ const referenceFilters = [repositoryName];
951
+ if (!repositoryName.startsWith('docker.io/')) {
952
+ referenceFilters.push(`docker.io/${repositoryName}`);
953
+ }
954
+ this.logger.info(`manageDockerImages(): Querying images for repository: ${repositoryName}`);
955
+ let repositoryImages = await this.docker.listImages({
956
+ filters: { reference: referenceFilters },
843
957
  });
844
- images.sort((a, b) => b.Created - a.Created);
845
- if (images.length > 2) {
846
- for (const img of images.slice(2)) {
847
- try {
848
- await this.docker.getImage(img.Id).remove({ force: true });
849
- this.logger.info(`manageDockerImages(): Removed old image ${img.Id}`);
850
- }
851
- catch (error) {
852
- this.logger.warn(`manageDockerImages(): Failed to remove image ${img.Id}`, error);
853
- }
958
+ if (repositoryImages.length === 0) {
959
+ this.logger.info(`manageDockerImages(): Reference filter returned no results, trying manual filter`);
960
+ const allImages = await this.docker.listImages();
961
+ repositoryImages = allImages.filter(imageInfo => {
962
+ const imageTags = imageInfo.RepoTags || [];
963
+ return imageTags.some((imageTag) => {
964
+ let normalizedTag = imageTag;
965
+ if (imageTag.startsWith('docker.io/')) {
966
+ normalizedTag = imageTag.substring('docker.io/'.length);
967
+ }
968
+ const tagRepositoryName = this.extractRepositoryName(normalizedTag);
969
+ return (tagRepositoryName === repositoryName || normalizedTag.startsWith(`${repositoryName}:`));
970
+ });
971
+ });
972
+ this.logger.info(`manageDockerImages(): Manual filter found ${repositoryImages.length} image(s)`);
973
+ }
974
+ if (repositoryImages.length === 0) {
975
+ this.logger.warn(`manageDockerImages(): No images found for repository ${repositoryName}`);
976
+ return;
977
+ }
978
+ if (repositoryImages.length <= 2) {
979
+ this.logger.info(`manageDockerImages(): Repository has ${repositoryImages.length} image(s), no cleanup needed`);
980
+ return;
981
+ }
982
+ this.logger.info(`manageDockerImages(): Determining pull times for ${repositoryImages.length} images`);
983
+ const imagesWithPullTimes = repositoryImages.map(imageInfo => {
984
+ const imageTags = imageInfo.RepoTags || imageInfo.RepoDigests || ['<untagged>'];
985
+ const trackedPullTime = this.imagePullTimes.get(imageInfo.Id);
986
+ const pullTime = trackedPullTime || imageInfo.Created;
987
+ const pullTimeFormatted = new Date(pullTime * 1000).toISOString();
988
+ const imageTagsString = imageTags.join(', ');
989
+ if (trackedPullTime) {
990
+ this.logger.info(`manageDockerImages(): Using tracked pull time for ${imageTagsString}: ` +
991
+ `${pullTimeFormatted}`);
992
+ }
993
+ else {
994
+ this.logger.info(`manageDockerImages(): Using Created time for ${imageTagsString}: ` +
995
+ `${pullTimeFormatted}`);
996
+ }
997
+ return {
998
+ imageInfo,
999
+ pullTime,
1000
+ imageTags,
1001
+ };
1002
+ });
1003
+ imagesWithPullTimes.sort((firstImage, secondImage) => secondImage.pullTime - firstImage.pullTime);
1004
+ this.logger.info(`manageDockerImages(): Sorted ${imagesWithPullTimes.length} images by pull time`);
1005
+ const imagesToRemove = imagesWithPullTimes.slice(2);
1006
+ this.logger.info(`manageDockerImages(): Removing ${imagesToRemove.length} oldest image(s), keeping newest 2`);
1007
+ for (const imageToRemove of imagesToRemove) {
1008
+ try {
1009
+ const imageTagsString = imageToRemove.imageTags.join(', ');
1010
+ const pullTimeFormatted = new Date(imageToRemove.pullTime * 1000).toISOString();
1011
+ this.logger.info(`manageDockerImages(): Removing image: ${imageTagsString} ` +
1012
+ `(Pulled: ${pullTimeFormatted})`);
1013
+ await this.docker.getImage(imageToRemove.imageInfo.Id).remove({ force: true });
1014
+ this.imagePullTimes.delete(imageToRemove.imageInfo.Id);
1015
+ this.persistImagePullTimes();
1016
+ this.logger.info(`manageDockerImages(): Successfully removed: ${imageToRemove.imageTags.join(', ')}`);
1017
+ }
1018
+ catch (error) {
1019
+ this.logger.error(`manageDockerImages(): Failed to remove image ${imageToRemove.imageInfo.Id}`, error);
854
1020
  }
855
1021
  }
1022
+ this.logger.info(`manageDockerImages(): Completed management for ${image}`);
856
1023
  }
857
1024
  catch (error) {
858
1025
  this.logger.error(`manageDockerImages(): Error managing images for ${image}`, error);
859
1026
  }
860
1027
  }
1028
+ loadImagePullTimes() {
1029
+ try {
1030
+ if (fs_1.default.existsSync(IMAGE_PULL_TIMES_FILE)) {
1031
+ const data = JSON.parse(fs_1.default.readFileSync(IMAGE_PULL_TIMES_FILE, 'utf-8'));
1032
+ const validEntries = Object.entries(data)
1033
+ .filter(([_, value]) => typeof value === 'number' && value > 0)
1034
+ .map(([key, value]) => [key, value]);
1035
+ this.imagePullTimes = new Map(validEntries);
1036
+ this.logger.info(`loadImagePullTimes(): Loaded ${this.imagePullTimes.size} image pull times from disk`);
1037
+ }
1038
+ }
1039
+ catch (error) {
1040
+ this.logger.warn('loadImagePullTimes(): Failed to load image pull times, starting fresh', error);
1041
+ }
1042
+ }
1043
+ async cleanupStaleImagePullTimes() {
1044
+ try {
1045
+ const allImages = await this.docker.listImages();
1046
+ const existingImageIds = new Set(allImages.map(img => img.Id));
1047
+ let cleanedCount = 0;
1048
+ for (const [imageId] of this.imagePullTimes.entries()) {
1049
+ if (!existingImageIds.has(imageId)) {
1050
+ this.imagePullTimes.delete(imageId);
1051
+ cleanedCount++;
1052
+ }
1053
+ }
1054
+ if (cleanedCount > 0) {
1055
+ this.logger.info(`cleanupStaleImagePullTimes(): Removed ${cleanedCount} stale image pull time entries`);
1056
+ this.persistImagePullTimes();
1057
+ }
1058
+ }
1059
+ catch (error) {
1060
+ this.logger.warn('cleanupStaleImagePullTimes(): Error during cleanup', error);
1061
+ }
1062
+ }
1063
+ persistImagePullTimes() {
1064
+ try {
1065
+ const dir = path_1.default.dirname(IMAGE_PULL_TIMES_FILE);
1066
+ if (!fs_1.default.existsSync(dir)) {
1067
+ fs_1.default.mkdirSync(dir, { recursive: true });
1068
+ }
1069
+ const data = Object.fromEntries(this.imagePullTimes);
1070
+ const tempFile = `${IMAGE_PULL_TIMES_FILE}.tmp`;
1071
+ fs_1.default.writeFileSync(tempFile, JSON.stringify(data, null, 2));
1072
+ fs_1.default.renameSync(tempFile, IMAGE_PULL_TIMES_FILE);
1073
+ }
1074
+ catch (error) {
1075
+ this.logger.warn('persistImagePullTimes(): Failed to persist image pull times', error);
1076
+ }
1077
+ }
861
1078
  async isContainerRunning(twinId) {
862
1079
  try {
863
1080
  const containers = await this.docker.listContainers();