alibuild 2.0.0__tar.gz → 2.0.1__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 (129) hide show
  1. {alibuild-2.0.0 → alibuild-2.0.1}/PKG-INFO +1 -1
  2. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild.egg-info/PKG-INFO +1 -1
  3. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/_version.py +3 -3
  4. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/build.py +1 -1
  5. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/log.py +17 -1
  6. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/sync.py +20 -1
  7. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/sync_reapi.py +28 -12
  8. {alibuild-2.0.0 → alibuild-2.0.1}/alienv +22 -6
  9. alibuild-2.0.1/tests/test_log.py +90 -0
  10. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_sync.py +47 -0
  11. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_sync_reapi.py +64 -3
  12. alibuild-2.0.0/tests/test_log.py +0 -50
  13. {alibuild-2.0.0 → alibuild-2.0.1}/.claude/skills/s3-conditional-writes/SKILL.md +0 -0
  14. {alibuild-2.0.0 → alibuild-2.0.1}/.claude/skills/support-architecture/SKILL.md +0 -0
  15. {alibuild-2.0.0 → alibuild-2.0.1}/.flake8 +0 -0
  16. {alibuild-2.0.0 → alibuild-2.0.1}/.github/dependabot.yml +0 -0
  17. {alibuild-2.0.0 → alibuild-2.0.1}/.github/workflows/documentation.yml +0 -0
  18. {alibuild-2.0.0 → alibuild-2.0.1}/.github/workflows/homebrew.yml +0 -0
  19. {alibuild-2.0.0 → alibuild-2.0.1}/.github/workflows/pr-check.yml +0 -0
  20. {alibuild-2.0.0 → alibuild-2.0.1}/.github/workflows/release.yml +0 -0
  21. {alibuild-2.0.0 → alibuild-2.0.1}/.gitignore +0 -0
  22. {alibuild-2.0.0 → alibuild-2.0.1}/.pylintrc +0 -0
  23. {alibuild-2.0.0 → alibuild-2.0.1}/ANALYTICS.md +0 -0
  24. {alibuild-2.0.0 → alibuild-2.0.1}/DESIGN.md +0 -0
  25. {alibuild-2.0.0 → alibuild-2.0.1}/LICENSE.md +0 -0
  26. {alibuild-2.0.0 → alibuild-2.0.1}/PACKAGING.md +0 -0
  27. {alibuild-2.0.0 → alibuild-2.0.1}/README.rst +0 -0
  28. {alibuild-2.0.0 → alibuild-2.0.1}/REMOTE_STORE_CAS_AC.md +0 -0
  29. {alibuild-2.0.0 → alibuild-2.0.1}/alfaBuild +0 -0
  30. {alibuild-2.0.0 → alibuild-2.0.1}/aliBuild +0 -0
  31. {alibuild-2.0.0 → alibuild-2.0.1}/aliDeps +0 -0
  32. {alibuild-2.0.0 → alibuild-2.0.1}/aliDoctor +0 -0
  33. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild.egg-info/SOURCES.txt +0 -0
  34. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild.egg-info/dependency_links.txt +0 -0
  35. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild.egg-info/requires.txt +0 -0
  36. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild.egg-info/top_level.txt +0 -0
  37. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/__init__.py +0 -0
  38. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/analytics.py +0 -0
  39. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/args.py +0 -0
  40. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/build_template.sh +0 -0
  41. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/clean.py +0 -0
  42. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/cmd.py +0 -0
  43. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/completion.py +0 -0
  44. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/completions/__init__.py +0 -0
  45. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/completions/bash.sh +0 -0
  46. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/completions/zsh.sh +0 -0
  47. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/deps.py +0 -0
  48. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/doctor.py +0 -0
  49. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/git.py +0 -0
  50. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/init.py +0 -0
  51. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/install.py +0 -0
  52. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/keyring.json +0 -0
  53. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/migrate.py +0 -0
  54. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/reconstruct.py +0 -0
  55. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/sandbox.py +0 -0
  56. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/scm.py +0 -0
  57. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/signing.py +0 -0
  58. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/sl.py +0 -0
  59. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/source.py +0 -0
  60. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/templating_plugin.py +0 -0
  61. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/utilities.py +0 -0
  62. {alibuild-2.0.0 → alibuild-2.0.1}/alibuild_helpers/workarea.py +0 -0
  63. {alibuild-2.0.0 → alibuild-2.0.1}/codecov.yml +0 -0
  64. {alibuild-2.0.0 → alibuild-2.0.1}/debian/changelog +0 -0
  65. {alibuild-2.0.0 → alibuild-2.0.1}/debian/compat +0 -0
  66. {alibuild-2.0.0 → alibuild-2.0.1}/debian/control +0 -0
  67. {alibuild-2.0.0 → alibuild-2.0.1}/debian/copyright +0 -0
  68. {alibuild-2.0.0 → alibuild-2.0.1}/debian/files +0 -0
  69. {alibuild-2.0.0 → alibuild-2.0.1}/debian/rules +0 -0
  70. {alibuild-2.0.0 → alibuild-2.0.1}/docs/SUPPORT +0 -0
  71. {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/alice_logo.png +0 -0
  72. {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/deps.png +0 -0
  73. {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/index.md +0 -0
  74. {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/quick.md +0 -0
  75. {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/reference.md +0 -0
  76. {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/stylesheets/extra.css +0 -0
  77. {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/troubleshooting.md +0 -0
  78. {alibuild-2.0.0 → alibuild-2.0.1}/docs/docs/user.md +0 -0
  79. {alibuild-2.0.0 → alibuild-2.0.1}/docs/mkdocs.yml +0 -0
  80. {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/alibuild_nix.jnj +0 -0
  81. {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/alidist-aliphysics.nix +0 -0
  82. {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/alidist-helpers.nix +0 -0
  83. {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/alidist.nix +0 -0
  84. {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/flake.lock +0 -0
  85. {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/flake.nix +0 -0
  86. {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/hyperloop.nix +0 -0
  87. {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/hyperloopTest.nix +0 -0
  88. {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/nix-interactive.bash +0 -0
  89. {alibuild-2.0.0 → alibuild-2.0.1}/extras/nix/update-envs.sh +0 -0
  90. {alibuild-2.0.0 → alibuild-2.0.1}/pb +0 -0
  91. {alibuild-2.0.0 → alibuild-2.0.1}/pyproject.toml +0 -0
  92. {alibuild-2.0.0 → alibuild-2.0.1}/requirements.txt +0 -0
  93. {alibuild-2.0.0 → alibuild-2.0.1}/setup.cfg +0 -0
  94. {alibuild-2.0.0 → alibuild-2.0.1}/setup.py +0 -0
  95. {alibuild-2.0.0 → alibuild-2.0.1}/templates/alibuild_nix.jnj +0 -0
  96. {alibuild-2.0.0 → alibuild-2.0.1}/templates/alibuild_to_please.jnj +0 -0
  97. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_analytics.py +0 -0
  98. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_args.py +0 -0
  99. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_build.py +0 -0
  100. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_clean.py +0 -0
  101. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_cmd.py +0 -0
  102. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_completion.py +0 -0
  103. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_deps.py +0 -0
  104. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_doctor.py +0 -0
  105. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_git.py +0 -0
  106. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_hashing.py +0 -0
  107. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_init.py +0 -0
  108. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_install.py +0 -0
  109. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_migrate.py +0 -0
  110. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_packagelist.py +0 -0
  111. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_parseRecipe.py +0 -0
  112. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_reconstruct.py +0 -0
  113. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_sandbox.py +0 -0
  114. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_signing.py +0 -0
  115. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_source.py +0 -0
  116. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_utilities.py +0 -0
  117. {alibuild-2.0.0 → alibuild-2.0.1}/tests/test_workarea.py +0 -0
  118. {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken1.sh +0 -0
  119. {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken2.sh +0 -0
  120. {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken3.sh +0 -0
  121. {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken4.sh +0 -0
  122. {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken5.sh +0 -0
  123. {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken6.sh +0 -0
  124. {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/broken7.sh +0 -0
  125. {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/clobber-initdotsh.sh +0 -0
  126. {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/defaults-o2.sh +0 -0
  127. {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/delete-etc.sh +0 -0
  128. {alibuild-2.0.0 → alibuild-2.0.1}/tests/testdist/tracking-env.sh +0 -0
  129. {alibuild-2.0.0 → alibuild-2.0.1}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alibuild
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: ALICE Build Tool
5
5
  Home-page: https://alisw.github.io/alibuild
6
6
  Author: Giulio Eulisse
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alibuild
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: ALICE Build Tool
5
5
  Home-page: https://alisw.github.io/alibuild
6
6
  Author: Giulio Eulisse
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '2.0.0'
32
- __version_tuple__ = version_tuple = (2, 0, 0)
31
+ __version__ = version = '2.0.1'
32
+ __version_tuple__ = version_tuple = (2, 0, 1)
33
33
 
34
- __commit_id__ = commit_id = 'g13a7d02c4'
34
+ __commit_id__ = commit_id = 'g0096f2c8c'
@@ -1382,7 +1382,7 @@ def doBuild(args, parser):
1382
1382
  published = True
1383
1383
  if fileHash == spec["hash"] and not spec["is_devel_pkg"] and \
1384
1384
  isinstance(syncHelper, REAPIRemoteSync) and getattr(syncHelper, "writeStore", ""):
1385
- published = syncHelper.is_published(spec["remote_revision_hash"])
1385
+ published = syncHelper.is_published(spec["remote_revision_hash"], keep_alive=True)
1386
1386
  if not published:
1387
1387
  bound_unpublished_rebuild(p, rebuilt_unpublished, syncHelper.acWriteStore)
1388
1388
  info("%s is built locally but not published to %s; rebuilding it so it can "
@@ -10,6 +10,22 @@ def dieOnError(err, msg) -> None:
10
10
  error("%s", msg)
11
11
  sys.exit(1)
12
12
 
13
+ # Credentials reach the log through command lines: the docker invocation carries
14
+ # every -e NAME=VALUE, and CI passes JALIEN_TOKEN_KEY (a PEM private key) and
15
+ # CCDB auth tokens that way. The value may be shell-quoted or, where the command
16
+ # is " ".join()ed, bare and spanning several lines. A key whose END marker is
17
+ # not in the same message is redacted to the end of the message.
18
+ _PRIVATE_KEY_RE = re.compile(
19
+ r"-----BEGIN ([A-Z ]*)PRIVATE KEY-----.*?(?:-----END \1PRIVATE KEY-----|\Z)", re.DOTALL)
20
+ _SECRET_ASSIGNMENT_RE = re.compile(
21
+ r"\b([A-Za-z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD)[A-Za-z0-9_]*=)"
22
+ r"('[^']*'|\"[^\"]*\"|[^\s'\"]+)")
23
+
24
+ def redact_secrets(msg):
25
+ """Hide private keys and the values of *TOKEN*/*SECRET*/*PASSWORD* assignments."""
26
+ msg = _PRIVATE_KEY_RE.sub(r"-----BEGIN \1PRIVATE KEY----- <redacted>", msg)
27
+ return _SECRET_ASSIGNMENT_RE.sub(r"\1<redacted>", msg)
28
+
13
29
  class LogFormatter(logging.Formatter):
14
30
  def __init__(self, fmtstr) -> None:
15
31
  self.fmtstr = fmtstr
@@ -25,7 +41,7 @@ class LogFormatter(logging.Formatter):
25
41
  # args to an already-expanded string -- "not all arguments converted".
26
42
  # Harmless with only our handler attached, which is why it went unnoticed;
27
43
  # under pytest, whose logging plugin adds its own handler, it aborts.
28
- msg = record.getMessage()
44
+ msg = redact_secrets(record.getMessage())
29
45
  if record.levelno == logging.BANNER and sys.stdout.isatty():
30
46
  lines = msg.split("\n")
31
47
  return "\n\033[1;34m==>\033[m \033[1m%s\033[m" % lines[0] + \
@@ -1,6 +1,7 @@
1
1
  """Sync backends for alibuild."""
2
2
 
3
3
  import glob
4
+ import hashlib
4
5
  import os
5
6
  import os.path
6
7
  import re
@@ -93,6 +94,7 @@ class HttpRemoteSync:
93
94
  # Destination specified -- file (dest) or buffer (returnResult).
94
95
  # Use requests in stream mode
95
96
  resp = get(url, stream=True, verify=not self.insecure, timeout=self.httpTimeoutSec)
97
+ cas_digest = None
96
98
  if redirect_base:
97
99
  # S3 only turns x-amz-website-redirect-location into a 301 when the
98
100
  # bucket is served as a website; on the REST endpoint the object is
@@ -115,6 +117,16 @@ class HttpRemoteSync:
115
117
  debug("Following store redirect to %s", url)
116
118
  resp = get(url, stream=True, verify=not self.insecure,
117
119
  timeout=self.httpTimeoutSec)
120
+ # A content-addressed target names its own digest: check against it.
121
+ cas_digest = re.search(r"(?:^|/)cas/sha256/[0-9a-f]{2}/([0-9a-f]{64})$", target)
122
+ if resp.status_code == 404:
123
+ # Retrying will not bring it back. After a store redirect this is a
124
+ # stub whose CAS blob is gone (an expired ephemeral artifact): not
125
+ # available, so the package is rebuilt, as the reapi backend does.
126
+ (warning if redirect_base else debug)("GET %s: not found", url)
127
+ return None
128
+ resp.raise_for_status()
129
+ sha256 = hashlib.sha256()
118
130
  size = int(resp.headers.get("content-length", "-1"))
119
131
  downloaded = 0
120
132
  reportTime = time.time()
@@ -125,6 +137,8 @@ class HttpRemoteSync:
125
137
  for chunk in filter(bool, resp.iter_content(chunk_size=32768)):
126
138
  if destFp:
127
139
  destFp.write(chunk)
140
+ if cas_digest:
141
+ sha256.update(chunk)
128
142
  if returnResult:
129
143
  result.append(chunk)
130
144
  downloaded += len(chunk)
@@ -141,6 +155,11 @@ class HttpRemoteSync:
141
155
 
142
156
  if size not in (downloaded, -1):
143
157
  raise PartialDownloadError(downloaded, size)
158
+ if cas_digest and sha256.hexdigest() != cas_digest.group(1):
159
+ warning("GET %s: content does not match its sha256, ignoring it", url)
160
+ if dest:
161
+ os.unlink(dest+".tmp")
162
+ return None
144
163
  if dest:
145
164
  os.rename(dest+".tmp", dest) # we should not have errors here
146
165
  return b''.join(result) if returnResult else True
@@ -240,7 +259,7 @@ class HttpRemoteSync:
240
259
  # Fetch manifest file with initial symlinks. This file is updated
241
260
  # regularly; we use it to avoid many small network requests.
242
261
  manifest = self.getRetry(f"{self.remoteStore}/{links_path}.manifest",
243
- returnResult=True, session=session)
262
+ returnResult=True, session=session) or b""
244
263
  symlinks = {
245
264
  linkname.decode("utf-8"): target.decode("utf-8")
246
265
  for linkname, sep, target in (line.partition(b"\t")
@@ -520,7 +520,7 @@ class REAPIRemoteSync(Boto3RemoteSync):
520
520
  return None
521
521
  return json.loads(obj["Body"].read())
522
522
 
523
- def is_published(self, action_hash):
523
+ def is_published(self, action_hash, keep_alive=False):
524
524
  """Whether an action is fully published: its Action Cache entry exists *and* the
525
525
  CAS blob that entry names is present.
526
526
 
@@ -531,7 +531,12 @@ class REAPIRemoteSync(Boto3RemoteSync):
531
531
  entry and bytes) is not something a dependent can be published against either.
532
532
 
533
533
  Two HEAD-class requests, no download -- the blob is checked by presence, since it
534
- is content-addressed and its key is its own digest."""
534
+ is content-addressed and its key is its own digest.
535
+
536
+ keep_alive also promotes or LRU-refreshes the blob (see _maybe_promote). The
537
+ build asks this for every package it reuses, rebuilt, fetched or installed
538
+ already: a fetched one never reaches the upload, which is the only other
539
+ place a blob is kept alive."""
535
540
  entry = self.read_ac_entry(action_hash)
536
541
  if entry is None:
537
542
  return False
@@ -539,7 +544,12 @@ class REAPIRemoteSync(Boto3RemoteSync):
539
544
  if not content_hash:
540
545
  # validate-system nodes produce no tarball: the entry alone is the artifact.
541
546
  return (entry.get("action") or {}).get("kind") == "validate-system"
542
- return self._exists(self.remoteStore, resolve_cas_path(content_hash, algo))
547
+ cas_path = resolve_cas_path(content_hash, algo)
548
+ if not self._exists(self.remoteStore, cas_path):
549
+ return False
550
+ if keep_alive:
551
+ self._maybe_promote(cas_path)
552
+ return True
543
553
 
544
554
  def download_blob(self, content_hash, dest, algo="sha256"):
545
555
  """Download a ledger (input) blob -- e.g. a source bundle -- to dest."""
@@ -601,8 +611,10 @@ class REAPIRemoteSync(Boto3RemoteSync):
601
611
 
602
612
  def _maybe_promote(self, cas_path):
603
613
  """When uploading as 'permanent' and the blob already exists tagged
604
- ephemeral, promote it to permanent so it is no longer LRU-expired."""
614
+ ephemeral, promote it to permanent so it is no longer LRU-expired.
615
+ Otherwise LRU-refresh it: reusing a blob must keep it alive."""
605
616
  if self.storage != "permanent":
617
+ self._touch_if_expiring(cas_path)
606
618
  return
607
619
  if self._retention_of(self.writeStore, cas_path) == "ephemeral":
608
620
  self.s3.put_object_tagging(
@@ -628,21 +640,25 @@ class REAPIRemoteSync(Boto3RemoteSync):
628
640
  def _touch_if_expiring(self, cas_path):
629
641
  """LRU-refresh: if the blob is ephemeral and within REFRESH_WITHIN_DAYS of
630
642
  its EPHEMERAL_TTL_DAYS expiry, copy it onto itself (preserving the tag) to
631
- reset last-modified. Best-effort and only when we can write the same bucket."""
643
+ reset last-modified. Best-effort, in the bucket we write: an upload that
644
+ dedups needs its blob there to stay, wherever it reads from."""
632
645
  from botocore.exceptions import ClientError
633
- if not self.writeStore or self.writeStore != self.remoteStore:
646
+ if not self.writeStore:
634
647
  return
635
648
  try:
636
- if self._retention_of(self.remoteStore, cas_path) != "ephemeral":
637
- return
638
- head = self.s3.head_object(Bucket=self.remoteStore, Key=cas_path)
649
+ head = self.s3.head_object(Bucket=self.writeStore, Key=cas_path)
639
650
  age_days = (datetime.now(timezone.utc) - head["LastModified"]).days
640
- if age_days < self.EPHEMERAL_TTL_DAYS - self.REFRESH_WITHIN_DAYS:
651
+ if age_days < self.EPHEMERAL_TTL_DAYS - self.REFRESH_WITHIN_DAYS or \
652
+ self._retention_of(self.writeStore, cas_path) != "ephemeral":
641
653
  return
642
654
  debug("Refreshing ephemeral %s (%d days old)", cas_path, age_days)
655
+ # REPLACE, with the same metadata: S3 and Ceph RGW refuse to copy an
656
+ # object onto itself with MetadataDirective=COPY (InvalidRequest).
643
657
  self.s3.copy_object(Bucket=self.writeStore, Key=cas_path,
644
- CopySource={"Bucket": self.remoteStore, "Key": cas_path},
645
- MetadataDirective="COPY", TaggingDirective="COPY")
658
+ CopySource={"Bucket": self.writeStore, "Key": cas_path},
659
+ MetadataDirective="REPLACE", Metadata=head.get("Metadata", {}),
660
+ ContentType=head.get("ContentType", "binary/octet-stream"),
661
+ TaggingDirective="COPY")
646
662
  except ClientError as exc:
647
663
  debug("Could not refresh %s: %s", cas_path, exc)
648
664
 
@@ -122,10 +122,21 @@ function detectShell() {
122
122
 
123
123
  function collectModules() {
124
124
  if [[ -z "$NO_REFRESH" ]]; then
125
- # Collect all modulefiles in one place
126
- rm -rf $WORK_DIR/MODULES/$ARCHITECTURE
127
- mkdir -p $WORK_DIR/MODULES/$ARCHITECTURE/BASE
128
- cat > $WORK_DIR/MODULES/$ARCHITECTURE/BASE/1.0 <<EOF
125
+ # Collect all modulefiles in one place. Other alienv calls may read the
126
+ # directory meanwhile, so it is never emptied: each modulefile is written
127
+ # next to it and renamed into place, and only then are the modulefiles of
128
+ # packages which are gone removed.
129
+ local DEST=$WORK_DIR/MODULES/$ARCHITECTURE STAGE PKG PKGVER PKGNAME MODFILE MODNAME
130
+ mkdir -p "$DEST"
131
+ STAGE=$(mktemp -d "$WORK_DIR/MODULES/.refresh-$ARCHITECTURE.XXXXXX") || return 1
132
+ function publish() {
133
+ # Install $1 (a file, or - for stdin) as the modulefile $2, relative to $DEST.
134
+ mkdir -p "$DEST/${2%/*}"
135
+ if [[ $1 == - ]]; then cat > "$STAGE/file"; else cp "$1" "$STAGE/file"; fi
136
+ mv -f "$STAGE/file" "$DEST/$2"
137
+ echo "$2" >> "$STAGE/published"
138
+ }
139
+ publish - BASE/1.0 <<EOF
129
140
  #%Module1.0
130
141
  set base_path $WORK_DIR/$ARCHITECTURE
131
142
  setenv BASEDIR \$base_path
@@ -144,10 +155,15 @@ EOF
144
155
  for MODFILE in "$PKG"/etc/modulefiles/*; do
145
156
  [[ -f "$MODFILE" ]] || continue
146
157
  MODNAME=${MODFILE##*/}
147
- mkdir -p "$WORK_DIR/MODULES/$ARCHITECTURE/$MODNAME"
148
- cp "$MODFILE" "$WORK_DIR/MODULES/$ARCHITECTURE/$MODNAME/$PKGVER"
158
+ publish "$MODFILE" "$MODNAME/$PKGVER"
149
159
  done
150
160
  done < <(find $WORK_DIR/$ARCHITECTURE -maxdepth 2 -mindepth 2 2> /dev/null)
161
+ # BASE/1.0 is always published, so the list is never empty here.
162
+ (cd "$DEST" && find . -type f ! -name '.*' | sed -e 's|^\./||') | grep -vxF -f "$STAGE/published" | \
163
+ while read MODFILE; do rm -f "$DEST/$MODFILE"; done
164
+ find "$DEST" -mindepth 1 -type d -empty -delete 2> /dev/null
165
+ rm -rf "$STAGE"
166
+ unset -f publish
151
167
  else
152
168
  printf "${EY}WARNING: not updating modulefiles${EZ}\n" >&2
153
169
  fi
@@ -0,0 +1,90 @@
1
+ import unittest
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ import logging
5
+
6
+ from alibuild_helpers.log import dieOnError, ProgressPrint, LogFormatter, redact_secrets
7
+
8
+
9
+ FAKE_KEY = ("-----BEGIN RSA PRIVATE KEY-----\nMIIEfakefakefake\nAAAAsecretbody\n"
10
+ "-----END RSA PRIVATE KEY-----")
11
+
12
+
13
+ class LogTestCase(unittest.TestCase):
14
+ def test_redact_docker_command(self) -> None:
15
+ """The docker command line, joined unquoted, must not show the key."""
16
+ cmd = " ".join(["docker", "run", "-e", "JALIEN_TOKEN_KEY=" + FAKE_KEY,
17
+ "-e", "ALICEO2_CCDB_AUTH_TOKEN=abc123", "-e", "ARCHITECTURE=slc9_x86-64",
18
+ "-e", "ALIBUILD_PREFER_SYSTEM_KEY=gcc", "image"])
19
+ out = redact_secrets(cmd)
20
+ self.assertNotIn("secretbody", out)
21
+ self.assertNotIn("MIIEfake", out)
22
+ self.assertNotIn("abc123", out)
23
+ self.assertIn("ARCHITECTURE=slc9_x86-64", out)
24
+ self.assertIn("ALIBUILD_PREFER_SYSTEM_KEY=gcc", out)
25
+ self.assertIn("image", out)
26
+
27
+ def test_redact_quoted(self) -> None:
28
+ """Shell-quoted values are redacted whole, including the quotes."""
29
+ out = redact_secrets("env GITHUB_TOKEN='tok en' JALIEN_TOKEN_KEY=\"%s\" bash" % FAKE_KEY)
30
+ self.assertEqual(out.count("<redacted>"), 2)
31
+ self.assertNotIn("tok en", out)
32
+ self.assertNotIn("secretbody", out)
33
+ self.assertTrue(out.endswith(" bash"))
34
+
35
+ def test_redact_unterminated_key(self) -> None:
36
+ """A key cut off before its END marker is redacted to the end."""
37
+ out = redact_secrets("before " + FAKE_KEY.split("-----END")[0])
38
+ self.assertEqual(out, "before -----BEGIN RSA PRIVATE KEY----- <redacted>")
39
+
40
+ def test_formatter_redacts(self) -> None:
41
+ """Everything going through the formatter is redacted, args included."""
42
+ record = logging.LogRecord("alibuild", logging.DEBUG, __file__, 1,
43
+ "Build command: %s", ("docker run -e JALIEN_TOKEN_KEY=" + FAKE_KEY,), None)
44
+ out = LogFormatter("%(levelname)s: %(message)s").format(record)
45
+ self.assertNotIn("secretbody", out)
46
+ self.assertIn("Build command: docker run -e JALIEN_TOKEN_KEY=<redacted>", out)
47
+
48
+ @patch("alibuild_helpers.log.error")
49
+ @patch("alibuild_helpers.log.sys")
50
+ def test_dieOnError(self, mock_sys, mock_error):
51
+ """Check dieOnError dies on error."""
52
+ dieOnError(True, "Message")
53
+ mock_sys.exit.assert_called_once_with(1)
54
+ mock_error.assert_called_once_with("%s", "Message")
55
+ mock_sys.reset_mock()
56
+ mock_error.reset_mock()
57
+ dieOnError(False, "Message")
58
+ mock_error.assert_not_called()
59
+ mock_sys.exit.assert_not_called()
60
+
61
+ @patch("sys.stdout.isatty", new=MagicMock(return_value=True))
62
+ @patch("sys.stderr", new=MagicMock(return_value=True))
63
+ def test_ProgressPrint(self) -> None:
64
+ """Make sure ProgressPrint updates correctly."""
65
+ # ProgressPrint only parses messages every 0.5s. Trick it into thinking
66
+ # the last message was <interval> seconds ago.
67
+ interval = 0.6
68
+ p = ProgressPrint("begin")
69
+ p("%s", "First message")
70
+ self.assertEqual(p.percent, -1)
71
+ p("%s", "[100/200] Update too fast")
72
+ self.assertEqual(p.percent, -1) # unchanged
73
+ p.lasttime -= interval
74
+ p("%s", "Has percentage: 80%")
75
+ self.assertEqual(p.percent, 80)
76
+ p.lasttime -= interval
77
+ p("%s", "No percentage")
78
+ self.assertEqual(p.percent, 80) # unchanged
79
+ p.lasttime -= interval
80
+ p("%s", "[100/200] Building CXX object this/is/a/ninja/test")
81
+ self.assertEqual(p.percent, 50)
82
+ p.lasttime -= interval
83
+ p("%s", "[100/0] Wrong-styled Ninja progress")
84
+ self.assertEqual(p.percent, 50) # unchanged
85
+ p.lasttime -= interval
86
+ p("%s", "Last message")
87
+ p.end()
88
+
89
+ if __name__ == '__main__':
90
+ unittest.main()
@@ -1,6 +1,8 @@
1
+ import hashlib
1
2
  import os
2
3
  import os.path
3
4
  import sys
5
+ import tempfile
4
6
  import unittest
5
7
  from io import BytesIO
6
8
 
@@ -219,6 +221,51 @@ class SyncTestCase(unittest.TestCase):
219
221
  self.assertFalse([u for u in requested if u.startswith(store + "/https")],
220
222
  "the absolute target was joined onto the store: %s" % requested)
221
223
 
224
+ def fetch_through_stub(self, cas_response):
225
+ """Fetch GOOD_SPEC from a store whose object redirects to a CAS blob, which
226
+ answers with cas_response. Returns (requested URLs, files left behind)."""
227
+ store = "https://localhost/legacy"
228
+ blob = "https://localhost/cas/cas/sha256/%s/%s" % (self.digest[:2], self.digest)
229
+ requested = []
230
+
231
+ def get(url, *args, **kw):
232
+ requested.append(url)
233
+ if url.endswith(tarball_name(GOOD_SPEC)) and "/store/" in url:
234
+ return MockRequest([{"name": tarball_name(GOOD_SPEC)}], redirect=blob)
235
+ if url == blob:
236
+ return cas_response
237
+ return self.mock_get(url, *args, **kw)
238
+
239
+ with tempfile.TemporaryDirectory() as workdir, \
240
+ patch("requests.Session.get", side_effect=get):
241
+ syncer = sync.HttpRemoteSync(remoteStore=store, architecture=ARCHITECTURE,
242
+ workdir=workdir, insecure=False)
243
+ syncer.httpBackoff = 0
244
+ syncer.fetch_tarball(GOOD_SPEC)
245
+ left = [f for _, _, files in os.walk(workdir) for f in files]
246
+ return [u for u in requested if u == blob], left
247
+
248
+ @patch("alibuild_helpers.sync.warning", new=MagicMock())
249
+ def test_http_missing_cas_blob_is_not_a_tarball(self):
250
+ """A stub whose CAS blob is gone (expired) gives no tarball, no retries and
251
+ no exception, so the package is rebuilt. Writing the 404 body as the
252
+ tarball is what made a build fail with "gzip: not in gzip format"."""
253
+ self.digest = "fa" * 32
254
+ gone = MockRequest(None)
255
+ gone.iter_content = lambda chunk_size=10: iter([b"NoSuchKey"])
256
+ gone.headers["content-length"] = "9"
257
+ requested, left = self.fetch_through_stub(gone)
258
+ self.assertEqual(len(requested), 1)
259
+ self.assertEqual(left, [])
260
+
261
+ @patch("alibuild_helpers.sync.warning", new=MagicMock())
262
+ def test_http_cas_blob_checked_against_its_digest(self):
263
+ """Bytes behind a cas/sha256/<digest> redirect must hash to <digest>."""
264
+ good = hashlib.sha256(b"x" * 123456).hexdigest()
265
+ for self.digest, kept in ((good, [tarball_name(GOOD_SPEC)]), ("ab" * 32, [])):
266
+ _, left = self.fetch_through_stub(MockRequest([{"name": "blob"}]))
267
+ self.assertEqual(left, kept)
268
+
222
269
  def test_s3cmd_follows_store_redirect(self):
223
270
  """s3cmd cannot see the redirect header, so the stub is spotted by content."""
224
271
  commands = []
@@ -78,18 +78,23 @@ class REAPIRemoteSyncTestCase(unittest.TestCase):
78
78
  reapi.s3 = client
79
79
  return reapi
80
80
 
81
- def make_client(self, existing=()):
81
+ def make_client(self, existing=(), age_days=0, retention=None):
82
82
  """Mock S3 client: head_object 404s unless the key is in `existing`,
83
- and all directory listings are empty (so uploads see no conflicts)."""
83
+ and all directory listings are empty (so uploads see no conflicts).
84
+ Existing objects are `age_days` old and tagged with `retention`."""
84
85
  from botocore.exceptions import ClientError
86
+ from datetime import datetime, timezone, timedelta
85
87
 
86
88
  def head_object(Bucket, Key):
87
89
  if Key in existing:
88
- return {"ContentLength": 4096}
90
+ return {"ContentLength": 4096, "ContentType": "application/gzip",
91
+ "LastModified": datetime.now(timezone.utc) - timedelta(days=age_days)}
89
92
  raise ClientError({"Error": {"Code": "404"}}, "head_object")
90
93
 
91
94
  return MagicMock(
92
95
  head_object=MagicMock(side_effect=head_object),
96
+ get_object_tagging=MagicMock(return_value={"TagSet": [
97
+ {"Key": "retention", "Value": retention}] if retention else []}),
93
98
  get_paginator=lambda method: MagicMock(
94
99
  paginate=lambda **kw: [{"Contents": []}]),
95
100
  put_object=MagicMock(return_value=None),
@@ -231,6 +236,62 @@ class REAPIRemoteSyncTestCase(unittest.TestCase):
231
236
  client.upload_file.assert_not_called()
232
237
  self.assertIn(resolve_ac_path(ARCHITECTURE, REAPI_HASH), self.put_keys(client))
233
238
 
239
+ @patch("alibuild_helpers.sync_reapi.file_digest",
240
+ new=MagicMock(return_value=REAPI_CONTENT_HASH))
241
+ @patch("os.path.getsize", new=MagicMock(return_value=4096))
242
+ @patch("os.listdir",
243
+ new=lambda path: [tarball_name(REAPI_SPEC)] if path.endswith("-1") else [])
244
+ @patch("os.readlink", new=MagicMock(return_value="../../store/de/dead/x.tar.gz"))
245
+ @patch("os.path.islink", new=MagicMock(return_value=True))
246
+ def test_upload_dedup_refreshes_expiring_ephemeral(self) -> None:
247
+ """Identical rebuilds always dedup, so the dedup must keep the blob alive:
248
+ otherwise a package that never changes expires 90 days after its first
249
+ build, while its AC entry and store stub still point at it."""
250
+ cas = resolve_cas_path(REAPI_CONTENT_HASH)
251
+ for age_days, refreshed in ((75, True), (10, False)):
252
+ client = self.make_client(existing={cas}, age_days=age_days,
253
+ retention="ephemeral")
254
+ self.make_sync(client).upload_symlinks_and_tarball(REAPI_SPEC)
255
+ client.upload_file.assert_not_called()
256
+ if not refreshed:
257
+ client.copy_object.assert_not_called()
258
+ continue
259
+ # Copied onto itself in the bucket we write, tags kept. COPY would be
260
+ # refused by S3 and Ceph RGW for a self-copy, so it must be REPLACE.
261
+ client.copy_object.assert_called_once()
262
+ kw = client.copy_object.call_args.kwargs
263
+ self.assertEqual((kw["Bucket"], kw["Key"]), ("bucket", cas))
264
+ self.assertEqual(kw["CopySource"], {"Bucket": "bucket", "Key": cas})
265
+ self.assertEqual(kw["MetadataDirective"], "REPLACE")
266
+ self.assertEqual(kw["ContentType"], "application/gzip")
267
+ self.assertEqual(kw["TaggingDirective"], "COPY")
268
+
269
+ def test_is_published_keep_alive(self) -> None:
270
+ """A package the build takes from the store never reaches the upload, so
271
+ is_published(keep_alive=True), asked for every reused package, is what
272
+ promotes its blob on a permanent build (or LRU-refreshes it)."""
273
+ cas = resolve_cas_path(REAPI_CONTENT_HASH)
274
+ entry = {"result": {"outputDigest": "sha256:" + REAPI_CONTENT_HASH}}
275
+ for keep_alive in (False, True):
276
+ client = self.make_client(existing={cas}, age_days=5, retention="ephemeral")
277
+ reapi = self.make_sync(client)
278
+ reapi.storage = "permanent"
279
+ reapi.read_ac_entry = MagicMock(return_value=entry)
280
+ self.assertTrue(reapi.is_published(REAPI_HASH, keep_alive=keep_alive))
281
+ if keep_alive:
282
+ client.put_object_tagging.assert_called_once_with(
283
+ Bucket="bucket", Key=cas,
284
+ Tagging={"TagSet": [{"Key": "retention", "Value": "permanent"}]})
285
+ else:
286
+ client.put_object_tagging.assert_not_called()
287
+ # An ephemeral build refreshes it only when it is close to expiry.
288
+ client = self.make_client(existing={cas}, age_days=70, retention="ephemeral")
289
+ reapi = self.make_sync(client)
290
+ reapi.read_ac_entry = MagicMock(return_value=entry)
291
+ self.assertTrue(reapi.is_published(REAPI_HASH, keep_alive=True))
292
+ client.copy_object.assert_called_once()
293
+ client.put_object_tagging.assert_not_called()
294
+
234
295
  @patch("alibuild_helpers.sync_reapi.file_digest",
235
296
  new=MagicMock(return_value=REAPI_CONTENT_HASH))
236
297
  @patch("os.path.getsize", new=MagicMock(return_value=4096))
@@ -1,50 +0,0 @@
1
- import unittest
2
- from unittest.mock import MagicMock, patch
3
-
4
- from alibuild_helpers.log import dieOnError, ProgressPrint
5
-
6
-
7
- class LogTestCase(unittest.TestCase):
8
- @patch("alibuild_helpers.log.error")
9
- @patch("alibuild_helpers.log.sys")
10
- def test_dieOnError(self, mock_sys, mock_error):
11
- """Check dieOnError dies on error."""
12
- dieOnError(True, "Message")
13
- mock_sys.exit.assert_called_once_with(1)
14
- mock_error.assert_called_once_with("%s", "Message")
15
- mock_sys.reset_mock()
16
- mock_error.reset_mock()
17
- dieOnError(False, "Message")
18
- mock_error.assert_not_called()
19
- mock_sys.exit.assert_not_called()
20
-
21
- @patch("sys.stdout.isatty", new=MagicMock(return_value=True))
22
- @patch("sys.stderr", new=MagicMock(return_value=True))
23
- def test_ProgressPrint(self) -> None:
24
- """Make sure ProgressPrint updates correctly."""
25
- # ProgressPrint only parses messages every 0.5s. Trick it into thinking
26
- # the last message was <interval> seconds ago.
27
- interval = 0.6
28
- p = ProgressPrint("begin")
29
- p("%s", "First message")
30
- self.assertEqual(p.percent, -1)
31
- p("%s", "[100/200] Update too fast")
32
- self.assertEqual(p.percent, -1) # unchanged
33
- p.lasttime -= interval
34
- p("%s", "Has percentage: 80%")
35
- self.assertEqual(p.percent, 80)
36
- p.lasttime -= interval
37
- p("%s", "No percentage")
38
- self.assertEqual(p.percent, 80) # unchanged
39
- p.lasttime -= interval
40
- p("%s", "[100/200] Building CXX object this/is/a/ninja/test")
41
- self.assertEqual(p.percent, 50)
42
- p.lasttime -= interval
43
- p("%s", "[100/0] Wrong-styled Ninja progress")
44
- self.assertEqual(p.percent, 50) # unchanged
45
- p.lasttime -= interval
46
- p("%s", "Last message")
47
- p.end()
48
-
49
- if __name__ == '__main__':
50
- unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes