checkmk-dev-tools 2.2.0__tar.gz → 2.4.0__tar.gz

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.
Files changed (19) hide show
  1. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/PKG-INFO +2 -2
  2. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/ci_artifacts.py +237 -90
  3. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/version.py +1 -1
  4. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/pyproject.toml +2 -2
  5. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/README.md +0 -0
  6. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/__init__.py +0 -0
  7. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/activity_from_fs.py +0 -0
  8. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/binreplace.py +0 -0
  9. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/check_rpath.py +0 -0
  10. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/cli.py +0 -0
  11. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/cpumon.py +0 -0
  12. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/decent_output.py +0 -0
  13. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/jenkins_utils/__init__.py +0 -0
  14. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/job_resource_usage.py +0 -0
  15. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/last_access.py +0 -0
  16. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/lockable_resources.py +0 -0
  17. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/procmon.py +0 -0
  18. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/pycinfo.py +0 -0
  19. {checkmk_dev_tools-2.2.0 → checkmk_dev_tools-2.4.0}/cmk_dev/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: checkmk-dev-tools
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: Checkmk DevOps tools
5
5
  Author: Frans Fürst
6
6
  Author-email: frans.fuerst@checkmk.com
@@ -14,7 +14,7 @@ Requires-Dist: pydantic (>=2,<3)
14
14
  Requires-Dist: python-jenkins (>=1.8.3,<1.9.0)
15
15
  Requires-Dist: rich
16
16
  Requires-Dist: trickkiste (>=0.3.4,<0.4.0)
17
- Project-URL: Changelog, https://github.com/Checkmk/checkmk-dev-tools/blob/release/2.2.0/changelog.md
17
+ Project-URL: Changelog, https://github.com/Checkmk/checkmk-dev-tools/blob/release/2.4.0/changelog.md
18
18
  Project-URL: Repository, https://github.com/Checkmk/checkmk-dev-tools
19
19
  Description-Content-Type: text/markdown
20
20
 
@@ -14,8 +14,10 @@ import asyncio
14
14
  import json
15
15
  import logging
16
16
  import os
17
+ import shutil
17
18
  import sys
18
19
  import time
20
+ import zipfile
19
21
  from argparse import ArgumentParser
20
22
  from argparse import Namespace as Args
21
23
  from collections.abc import Mapping, MutableMapping, Sequence
@@ -24,7 +26,7 @@ from datetime import datetime
24
26
  from itertools import chain
25
27
  from pathlib import Path
26
28
  from subprocess import check_output
27
- from typing import Any, List, Literal, cast
29
+ from typing import Any, List, Literal, Set, TypedDict, cast
28
30
 
29
31
  import requests
30
32
  from influxdb_client import InfluxDBClient # type: ignore[attr-defined]
@@ -56,7 +58,14 @@ from .version import __version__
56
58
 
57
59
  PathHashes = Mapping[str, str]
58
60
  shared_build_info: asyncio.Queue[str] = asyncio.Queue(maxsize=1)
61
+
62
+
63
+ class FetchResult(TypedDict):
64
+ result: None | JobResult
65
+ artifacts: list[str]
66
+
59
67
  MAX_RETRY_ATTEMPTS = 3
68
+ MAX_SINGLE_FILE_DOWNLOADS = 10 # max number of single files to be downloaded before using archive.zip + decompression
60
69
 
61
70
 
62
71
  def parse_args() -> Args:
@@ -311,13 +320,11 @@ def download_artifacts(
311
320
  total_download_timeout: int = 240,
312
321
  no_remove_others: bool = False,
313
322
  no_raise: bool = False,
314
- ) -> tuple[Sequence[str | None], Sequence[str | None]]:
323
+ ) -> tuple[Sequence[str], Sequence[str]]:
315
324
  """Downloads all artifacts listed for given job/build to @out_dir"""
316
325
  # pylint: disable=protected-access
317
326
  # pylint: disable=too-many-locals
318
327
 
319
- downloaded_artifacts, skipped_artifacts = [], []
320
-
321
328
  # https://bugs.launchpad.net/python-jenkins/+bug/1973243
322
329
  # https://bugs.launchpad.net/python-jenkins/+bug/2018576
323
330
 
@@ -358,6 +365,99 @@ def download_artifacts(
358
365
  p.relative_to(out_dir).as_posix() for p in out_dir.glob("**/*") if p.is_file()
359
366
  )
360
367
 
368
+ skipped_artifacts: List[str] = []
369
+ downloaded_artifacts: List[str] = []
370
+ if len(artifact_hashes) > MAX_SINGLE_FILE_DOWNLOADS:
371
+ downloaded_artifact = _download_compressed_artifacs(
372
+ client=client,
373
+ build=build,
374
+ existing_files=existing_files,
375
+ artifact_hashes=artifact_hashes,
376
+ out_dir=out_dir,
377
+ total_download_timeout=total_download_timeout,
378
+ )
379
+ downloaded_artifacts.append(downloaded_artifact)
380
+ downloaded_artifacts.extend(_decompress_artifacts(
381
+ artifact=out_dir / downloaded_artifact,
382
+ out_dir=out_dir,
383
+ ))
384
+ else:
385
+ downloaded_artifacts, skipped_artifacts, existing_files = _download_individual_artifacts(
386
+ client=client,
387
+ build=build,
388
+ existing_files=existing_files,
389
+ artifact_hashes=artifact_hashes,
390
+ out_dir=out_dir,
391
+ total_download_timeout=total_download_timeout,
392
+ )
393
+
394
+ if not no_remove_others:
395
+ for path in existing_files - set(downloaded_artifacts) - set(skipped_artifacts):
396
+ log().debug("Remove superfluous file %s", path)
397
+ with suppress(FileNotFoundError):
398
+ (out_dir / path).unlink()
399
+ log().info(
400
+ "%d artifacts available in '%s' (%d skipped, because they were up to date locally)",
401
+ len(downloaded_artifacts) + len(skipped_artifacts),
402
+ out_dir,
403
+ len(skipped_artifacts),
404
+ )
405
+
406
+ return downloaded_artifacts, skipped_artifacts
407
+
408
+ def _download_compressed_artifacs(
409
+ client: Jenkins,
410
+ build: Build,
411
+ existing_files: Set[str],
412
+ artifact_hashes: Mapping[str, str],
413
+ out_dir: Path,
414
+ total_download_timeout: int = 240,
415
+ ) -> str:
416
+
417
+ # https://ci.lan.tribe29.com/job/checkmk/job/master/job/cv/job/test-gerrit-single-k8s/710903/artifact/*zip*/archive.zip
418
+ artifact = "archive.zip"
419
+ artifact_filename = out_dir / artifact
420
+
421
+ # ignore the returned value of the function as it would be like "/*zip*/archive.zip"
422
+ _download_single_file(
423
+ client=client,
424
+ build=build,
425
+ artifact=f"/*zip*/{artifact}",
426
+ artifact_filename=artifact_filename,
427
+ total_download_timeout=total_download_timeout,
428
+ )
429
+
430
+ return artifact
431
+
432
+ def _decompress_artifacts(artifact: Path, out_dir: Path, decompressed_folder_name: str = "archive") -> List[str]:
433
+ with zipfile.ZipFile(artifact, "r") as zip_ref:
434
+ zip_ref.extractall(out_dir)
435
+
436
+ # The extracted folder is always named "archive"
437
+ archive_dir = out_dir / decompressed_folder_name
438
+
439
+ downloaded_artifacts = []
440
+
441
+ # Move all contents of the decompressed archive folder up one level
442
+ if archive_dir.exists() and archive_dir.is_dir():
443
+ for item in archive_dir.iterdir():
444
+ shutil.copytree(str(item), str(out_dir / item.name), dirs_exist_ok=True)
445
+ downloaded_artifacts += [str(f.relative_to(out_dir)) for f in (out_dir / item.name).rglob("*") if f.is_file()]
446
+
447
+ shutil.rmtree(archive_dir)
448
+
449
+ return downloaded_artifacts
450
+
451
+ def _download_individual_artifacts(
452
+ client: Jenkins,
453
+ build: Build,
454
+ existing_files: Set[str],
455
+ artifact_hashes: Mapping[str, str],
456
+ out_dir: Path,
457
+ total_download_timeout: int = 240,
458
+ ) -> tuple[List[str], List[str], Set[str]]:
459
+ downloaded_artifacts, skipped_artifacts = [], []
460
+
361
461
  for artifact in build.artifacts:
362
462
  existing_files -= {artifact}
363
463
  fp_hash = artifact_hashes[artifact]
@@ -378,54 +478,62 @@ def download_artifacts(
378
478
  fp_hash,
379
479
  )
380
480
 
381
- for attempts_left in reversed(range(MAX_RETRY_ATTEMPTS + 1)):
382
- time_start = time.time()
383
- try:
384
- with client._session.get(f"{build.url}artifact/{artifact}", stream=True) as reply:
385
- log().debug("download: %s", artifact)
386
- reply.raise_for_status()
387
- artifact_filename.parent.mkdir(parents=True, exist_ok=True)
388
- current_dl_duration = 0.0
389
- with open(artifact_filename, "wb") as out_file:
390
- for chunk in reply.iter_content(chunk_size=8192):
391
- if (
392
- current_dl_duration := (time.time() - time_start)
393
- ) > total_download_timeout:
394
- raise TimeoutError(
395
- f"Downloading of {reply.url} took longer than {total_download_timeout}s"
396
- )
397
- if chunk: # Filter out keep-alive chunks
398
- out_file.write(chunk)
399
- log().debug(
400
- "download: %s - successful (took %.2fs)", artifact, current_dl_duration
401
- )
402
- downloaded_artifacts.append(artifact)
403
- break
404
- except (
405
- requests.exceptions.ChunkedEncodingError,
406
- requests.exceptions.ConnectionError,
407
- TimeoutError,
408
- ) as exc:
409
- if not attempts_left:
410
- raise
411
- log().warning(
412
- "download_artifacts() caught %r (%s retries left)", exc, attempts_left
413
- )
481
+ downloaded_artifacts.append(
482
+ _download_single_file(
483
+ client=client,
484
+ build=build,
485
+ artifact=artifact,
486
+ artifact_filename=artifact_filename,
487
+ total_download_timeout=total_download_timeout,
488
+ )
489
+ )
414
490
 
415
- if not no_remove_others:
416
- for path in existing_files - set(downloaded_artifacts) - set(skipped_artifacts):
417
- log().debug("Remove superfluous file %s", path)
418
- with suppress(FileNotFoundError):
419
- (out_dir / path).unlink()
420
- log().info(
421
- "%d artifacts available in '%s' (%d skipped, because they were up to date locally)",
422
- len(downloaded_artifacts) + len(skipped_artifacts),
423
- out_dir,
424
- len(skipped_artifacts),
425
- )
491
+ return downloaded_artifacts, skipped_artifacts, existing_files
426
492
 
427
- return downloaded_artifacts, skipped_artifacts
493
+ def _download_single_file(
494
+ client: Jenkins,
495
+ build: Build,
496
+ artifact: str,
497
+ artifact_filename: Path,
498
+ total_download_timeout: int = 240,
499
+ ) -> str:
500
+ downloaded_artifact: str = ""
501
+
502
+ for attempts_left in reversed(range(MAX_RETRY_ATTEMPTS + 1)):
503
+ time_start = time.time()
504
+ try:
505
+ with client._session.get(f"{build.url}artifact/{artifact}", stream=True) as reply:
506
+ log().debug("download: %s", artifact)
507
+ reply.raise_for_status()
508
+ artifact_filename.parent.mkdir(parents=True, exist_ok=True)
509
+ current_dl_duration = 0.0
510
+ with open(artifact_filename, "wb") as out_file:
511
+ for chunk in reply.iter_content(chunk_size=8192):
512
+ if (
513
+ current_dl_duration := (time.time() - time_start)
514
+ ) > total_download_timeout:
515
+ raise TimeoutError(
516
+ f"Downloading of {reply.url} took longer than {total_download_timeout}s"
517
+ )
518
+ if chunk: # Filter out keep-alive chunks
519
+ out_file.write(chunk)
520
+ log().debug(
521
+ "download: %s - successful (took %.2fs)", artifact, current_dl_duration
522
+ )
523
+ downloaded_artifact = artifact
524
+ break
525
+ except (
526
+ requests.exceptions.ChunkedEncodingError,
527
+ requests.exceptions.ConnectionError,
528
+ TimeoutError,
529
+ ) as exc:
530
+ if not attempts_left:
531
+ raise
532
+ log().warning(
533
+ "download_artifacts() caught %r (%s retries left)", exc, attempts_left
534
+ )
428
535
 
536
+ return downloaded_artifact
429
537
 
430
538
  def path_hashes_match(actual: PathHashes, required: PathHashes) -> bool:
431
539
  """Returns True if two given path hash mappings are semantically equal, i.e. at least one hash
@@ -733,6 +841,7 @@ async def _fn_request_build(args: Args) -> None:
733
841
  path_hashes=compose_path_hashes(args.base_dir, args.dependency_paths),
734
842
  time_constraints=args.time_constraints,
735
843
  next_check_sleep=args.poll_queue_sleep,
844
+ ignore_build_queue=args.ignore_build_queue,
736
845
  args=args,
737
846
  )
738
847
  ):
@@ -901,54 +1010,72 @@ def query_matching_builds(
901
1010
  return []
902
1011
 
903
1012
 
904
- async def _fn_fetch(args: Args) -> None:
905
- """Entry point for fetching (request and download combined) artifacts"""
906
- out_dir = compose_out_dir(args.base_dir, args.out_dir)
907
- path_hashes = compose_path_hashes(args.base_dir, args.dependency_paths)
1013
+ async def fetch_artifacts(
1014
+ job: str,
1015
+ params: None | JobParams = None,
1016
+ *,
1017
+ out_dir: Path = Path("out"),
1018
+ base_dir: Path = Path("."),
1019
+ params_no_check: None | JobParams = None,
1020
+ dependency_paths: None | Sequence[str] = None,
1021
+ time_constraints: None | str = None,
1022
+ force_new_build: bool = False,
1023
+ omit_new_build: bool = False,
1024
+ download: bool = True,
1025
+ no_remove_others: bool = False,
1026
+ no_raise: bool = False,
1027
+ poll_queue_sleep: int = 30,
1028
+ poll_sleep: int = 60,
1029
+ timeout: int = 120,
1030
+ total_download_timeout: int = 240,
1031
+ credentials: None | Mapping[str, str] = None,
1032
+ credentials_file: str = "~/.config/jenkins_jobs/jenkins_jobs.ini",
1033
+ ) -> FetchResult:
1034
+ """Find or trigger a matching build, wait for it, download artifacts, return result dict."""
1035
+ resolved_out_dir = compose_out_dir(base_dir, out_dir)
1036
+ path_hashes = compose_path_hashes(base_dir, dependency_paths or [])
908
1037
  async with AugmentedJenkinsClient(
909
- **extract_credentials(args.credentials), timeout=args.timeout
1038
+ **extract_credentials(credentials, credentials_file=credentials_file), timeout=timeout
910
1039
  ) as jenkins_client:
911
- if not (job := await jenkins_client.job_info(args.job)).type == "WorkflowJob":
912
- raise Fatal(f"{args.job} is not a WorkflowJob")
913
- # In case we force a new build anyway we don't have to look for an existing one
1040
+ if not (job_info := await jenkins_client.job_info(job)).type == "WorkflowJob":
1041
+ raise Fatal(f"{job} is not a WorkflowJob")
914
1042
  matching_build = (
915
1043
  None
916
- if args.force_new_build
1044
+ if force_new_build
917
1045
  else await identify_matching_build(
918
- job,
1046
+ job_info,
919
1047
  jenkins_client=jenkins_client,
920
- params=flatten(args.params),
921
- path_hashes=compose_path_hashes(args.base_dir, args.dependency_paths),
922
- time_constraints=args.time_constraints,
923
- next_check_sleep=args.poll_queue_sleep,
924
- args=args,
1048
+ params=params,
1049
+ path_hashes=path_hashes,
1050
+ time_constraints=time_constraints,
1051
+ next_check_sleep=poll_queue_sleep,
925
1052
  )
926
1053
  )
927
- if args.omit_new_build and not matching_build:
928
- raise Fatal(f"No matching build found for job '{job.name}' but new builds are omitted.")
929
-
1054
+ if omit_new_build and not matching_build:
1055
+ raise Fatal(
1056
+ f"No matching build found for job '{job_info.name}' but new builds are omitted."
1057
+ )
930
1058
  build_candidate = matching_build or await trigger_build(
931
1059
  jenkins_client=jenkins_client,
932
- job=job,
1060
+ job=job_info,
933
1061
  params=compose_build_params(
934
- params=flatten(args.params),
935
- params_no_check=flatten(args.params_no_check),
936
- path_hashes=compose_path_hashes(args.base_dir, args.dependency_paths),
1062
+ params=params,
1063
+ params_no_check=params_no_check,
1064
+ path_hashes=path_hashes,
937
1065
  ),
938
- next_check_sleep=args.poll_queue_sleep,
1066
+ next_check_sleep=poll_queue_sleep,
939
1067
  )
940
-
941
1068
  for key, value in build_candidate.__dict__.items():
942
1069
  log().debug(" %s: %s", key, value)
943
-
944
1070
  completed_build = await await_build(
945
- job.path,
1071
+ job_info.path,
946
1072
  build_candidate.number,
947
1073
  jenkins_client=jenkins_client,
948
1074
  check_result=True,
949
1075
  path_hashes=path_hashes,
950
- next_check_sleep=args.poll_sleep,
951
- no_raise=args.no_raise,
1076
+ next_check_sleep=poll_sleep,
1077
+ no_raise=no_raise,
1078
+ download=download,
952
1079
  )
953
1080
  downloaded_artifacts = (
954
1081
  list(
@@ -956,25 +1083,44 @@ async def _fn_fetch(args: Args) -> None:
956
1083
  *download_artifacts(
957
1084
  jenkins_client.client,
958
1085
  completed_build,
959
- out_dir,
960
- args.total_download_timeout,
961
- args.no_remove_others,
962
- args.no_raise,
1086
+ resolved_out_dir,
1087
+ total_download_timeout,
1088
+ no_remove_others,
1089
+ no_raise,
963
1090
  )
964
1091
  )
965
1092
  )
966
- if args.download
1093
+ if download
967
1094
  else []
968
1095
  )
1096
+ return {"result": completed_build.result, "artifacts": downloaded_artifacts}
969
1097
 
970
- print(
971
- json.dumps(
972
- {
973
- "result": completed_build.result,
974
- "artifacts": downloaded_artifacts,
975
- }
1098
+
1099
+ async def _fn_fetch(args: Args) -> None:
1100
+ """Entry point for fetching (request and download combined) artifacts"""
1101
+ print(
1102
+ json.dumps(
1103
+ await fetch_artifacts(
1104
+ job=args.job,
1105
+ params=flatten(args.params),
1106
+ out_dir=args.out_dir,
1107
+ base_dir=args.base_dir,
1108
+ params_no_check=flatten(args.params_no_check),
1109
+ dependency_paths=args.dependency_paths,
1110
+ time_constraints=args.time_constraints,
1111
+ force_new_build=args.force_new_build,
1112
+ omit_new_build=args.omit_new_build,
1113
+ download=args.download,
1114
+ no_remove_others=args.no_remove_others,
1115
+ no_raise=args.no_raise,
1116
+ poll_queue_sleep=args.poll_queue_sleep,
1117
+ poll_sleep=args.poll_sleep,
1118
+ timeout=args.timeout,
1119
+ total_download_timeout=args.total_download_timeout,
1120
+ credentials=args.credentials,
976
1121
  )
977
1122
  )
1123
+ )
978
1124
 
979
1125
 
980
1126
  async def identify_matching_build(
@@ -985,6 +1131,7 @@ async def identify_matching_build(
985
1131
  path_hashes: PathHashes,
986
1132
  time_constraints: None | str,
987
1133
  next_check_sleep: int = 30,
1134
+ ignore_build_queue: bool = False,
988
1135
  args: None | Args = None,
989
1136
  ) -> None | Build:
990
1137
  """Find an existing build (finished, still running or queued) which matches our
@@ -1102,7 +1249,7 @@ async def identify_matching_build(
1102
1249
  log().info("No matching builds found in the InfluxDB")
1103
1250
 
1104
1251
  log().info("Check for matching queue items via Jenkins API")
1105
- if hasattr(args, "ignore_build_queue") and not args.ignore_build_queue:
1252
+ if not ignore_build_queue:
1106
1253
  if matching_item := await find_matching_queue_item(
1107
1254
  jenkins_client=jenkins_client,
1108
1255
  job=job,
@@ -1143,7 +1290,7 @@ async def identify_matching_build(
1143
1290
  log().info("No matching non finished builds found via the Jenkins API")
1144
1291
 
1145
1292
  log().info("Check for matching queue items via Jenkins API")
1146
- if args and not args.ignore_build_queue:
1293
+ if not ignore_build_queue:
1147
1294
  if matching_item := await find_matching_queue_item(
1148
1295
  jenkins_client=jenkins_client,
1149
1296
  job=job,
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: UTF-8 -*-
3
3
 
4
- __version_info__ = ("2", "2", "0")
4
+ __version_info__ = ("2", "4", "0")
5
5
  __version__ = '.'.join(__version_info__)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "checkmk-dev-tools"
3
- version = "2.2.0" # will-be-updated-automatically
3
+ version = "2.4.0" # will-be-updated-automatically
4
4
  description = "Checkmk DevOps tools"
5
5
  authors = [
6
6
  "Frans Fürst <frans.fuerst@checkmk.com>",
@@ -14,7 +14,7 @@ exclude = ["cmk_dev/out"]
14
14
 
15
15
  [tool.poetry.urls]
16
16
  Repository = "https://github.com/Checkmk/checkmk-dev-tools"
17
- Changelog = "https://github.com/Checkmk/checkmk-dev-tools/blob/release/2.2.0/changelog.md"
17
+ Changelog = "https://github.com/Checkmk/checkmk-dev-tools/blob/release/2.4.0/changelog.md"
18
18
 
19
19
  [tool.poetry.scripts]
20
20
  activity-from-fs = 'cmk_dev.activity_from_fs:main'