Skip to content

Commit 6d00d05

Browse files
committed
Isolate staged source archive generations during HF publication
1 parent db16677 commit 6d00d05

2 files changed

Lines changed: 43 additions & 1 deletion

File tree

‎platform/api/services/remote_execution/bundle.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1464,7 +1464,10 @@ def _staged_source_archive(repo_root: Path, data_root: Path, revision: str,
14641464
if _sha256_file(archive) != expected:
14651465
_SOURCE_ARCHIVE_DIGESTS.pop(key, None)
14661466
raise RemoteBundleError('Cached source archive changed during staging')
1467-
os.link(archive, staged)
1467+
# Each publisher needs a private inode: sibling staging links/unlinks
1468+
# and cache recency updates otherwise change its HF generation identity.
1469+
# Keep the broker's byte and metadata checks intact.
1470+
shutil.copy2(archive, staged)
14681471
os.utime(archive, None, follow_symlinks=False)
14691472
_prune_source_archives(cache_root)
14701473
# Only the shared archive/copy needs serialization. Extraction writes into

‎platform/api/tests/test_hf_asset_sources.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,45 @@ def counted_hash(*args):
177177
assert api.uploads[0][0][1] == f'sha256/{artifact.sha256}/image.sif'
178178

179179

180+
def test_source_publish_survives_another_attempt_staging(configured, monkeypatch, tmp_path, capability):
181+
import subprocess
182+
from services.remote_execution import bundle
183+
184+
repo = tmp_path / 'repo'
185+
repo.mkdir()
186+
subprocess.run(['git', 'init', '-q', str(repo)], check=True)
187+
(repo / 'source.txt').write_text('immutable committed source')
188+
subprocess.run(['git', '-C', str(repo), 'add', '.'], check=True)
189+
subprocess.run(['git', '-C', str(repo), '-c', 'user.name=Fixture',
190+
'-c', 'user.email=fixture@example.invalid', 'commit', '-qm', 'fixture'], check=True)
191+
revision = subprocess.check_output(['git', '-C', str(repo), 'rev-parse', 'HEAD'], text=True).strip()
192+
data = tmp_path / 'data'
193+
first = tmp_path / 'first'
194+
digest = bundle._staged_source_archive(repo, data, revision, first, extract=False)
195+
source = first / '.bms-source.tar.gz'
196+
entry = SimpleNamespace(source=source, role='source', sha256=digest, size_bytes=source.stat().st_size)
197+
monkeypatch.setattr(worker, 'MIN_BYTES', 1)
198+
monkeypatch.setenv('BMS_HF_ASSET_ALLOW_PUBLISH', '1')
199+
api = Api(entry, False)
200+
original_upload = api.batch_bucket_files
201+
generations = []
202+
203+
def upload(bucket, *, add):
204+
original_upload(bucket, add=add)
205+
before = worker._identity(source.stat())
206+
second = tmp_path / 'second'
207+
assert bundle._staged_source_archive(repo, data, revision, second, extract=False) == digest
208+
# A sibling launch and its cleanup must not change the publisher's generation.
209+
(second / '.bms-source.tar.gz').unlink()
210+
assert hashlib.sha256(source.read_bytes()).hexdigest() == digest
211+
generations.append((before, worker._identity(source.stat())))
212+
213+
monkeypatch.setattr(api, 'batch_bucket_files', upload)
214+
assert worker._prepare(request(entry), broker.configuration(), api, Http(capability), 'token') == capability
215+
assert len(api.uploads) == 1
216+
assert len(generations) == 1 and generations[0][0] == generations[0][1]
217+
218+
180219
@pytest.mark.parametrize('failure', ['missing', 'public', 'owner', 'size', 'quota', 'hash', 'symlink', 'changed', 'uncertain'])
181220
def test_fail_closed_no_capability(configured, monkeypatch, artifact, capability, failure):
182221
api, http = Api(artifact), Http(capability)

0 commit comments

Comments
 (0)