From 31c8da46807c2ee33ef5169b66f7c7a08a840e68 Mon Sep 17 00:00:00 2001 From: matthiasL-scality Date: Fri, 7 Aug 2026 14:10:21 +0200 Subject: [PATCH] feat: allow resuming a partial promote without manual cleanup When a promote is interrupted mid-way (e.g. the rc.3 stream abort), the target bucket contains some but not all source objects and no .final_status. Re-running the promote previously failed immediately with "FAILED" because the target was not empty, forcing a manual bucket deletion before retry. The new logic distinguishes two cases: - Partial target (files present, no .final_status): records existing files and filters them from the copy loop so only missing objects are copied. - Complete target (.final_status present): fails with a clear message to prevent silent overwrites of a finished promotion. Co-Authored-By: Claude Sonnet 4.6 --- lua/copy_build.lua | 35 +++++++++++++++++++++-- tests/end2end/test_copy.py | 57 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/lua/copy_build.lua b/lua/copy_build.lua index b35f51b..78332c9 100644 --- a/lua/copy_build.lua +++ b/lua/copy_build.lua @@ -14,19 +14,35 @@ if res.status ~= 200 then return end --- Check that build_tgt is empty +-- Check that build_tgt is empty, or allow resuming a partial promote. +-- A partial target (files present but no .final_status) is resumed by skipping +-- already-copied objects. A fully promoted target (.final_status present) is +-- rejected immediately. -- ngx.say("Checking if the target reference '" .. build_tgt .. "' is empty") ngx.flush(true) +local target_files = {} url = "/force_real_request/download/" .. build_tgt .. "/?format=txt" res = ngx.location.capture(url) if res.body == "" and res.truncated == false then ngx.say('DONE') ngx.flush(true) else - ngx.say('FAILED') + local fs_res = ngx.location.capture( + "/force_real_request/download/" .. build_tgt .. "/.final_status" + ) + if fs_res.status == 200 then + ngx.say('FAILED: target already fully promoted') + ngx.flush(true) + return + end + local already_copied = 0 + for file in res.body:gmatch("([^\r\n]+)[\r\n]+") do + target_files[file] = true + already_copied = already_copied + 1 + end + ngx.say('Target not empty (' .. already_copied .. ' file(s)), resuming partial promote') ngx.flush(true) - return end -- Add a reference to the original build, if needed. @@ -179,6 +195,19 @@ local function multipart_copy(object, file_size_hint) return true end +-- When resuming a partial promote, skip objects already present in the target. +if next(target_files) ~= nil then + local remaining = {} + for _, obj in ipairs(objects) do + if not target_files[obj] then + table.insert(remaining, obj) + end + end + ngx.say(#objects - #remaining .. ' file(s) already in target, copying ' .. #remaining .. ' remaining') + ngx.flush(true) + objects = remaining +end + local total_number_of_objects = #objects local batch_size = 16 local current_object = 0 diff --git a/tests/end2end/test_copy.py b/tests/end2end/test_copy.py index 59bca91..68f3327 100644 --- a/tests/end2end/test_copy.py +++ b/tests/end2end/test_copy.py @@ -53,13 +53,13 @@ def test_copy_source_and_target_listings_are_identical( def test_copy_fails_when_target_already_exists( session, artifacts_url, upload_file, finish_build ): - """A second copy to the same target is rejected with FAILED.""" + """A second copy to a fully-promoted target is rejected.""" upload_file(STAGING_BUILD, 'file.txt', b'data') finish_build(STAGING_BUILD) session.get(f'{artifacts_url}/copy/{STAGING_BUILD}/{COPY_BUILD}/') - # Second attempt — target is not empty + # Second attempt — target is fully promoted (has .final_status) resp = session.get(f'{artifacts_url}/copy/{STAGING_BUILD}/{COPY_BUILD}/') assert resp.status_code == 200 lines = resp.content.splitlines() @@ -68,7 +68,7 @@ def test_copy_fails_when_target_already_exists( % COPY_BUILD.encode() ) assert lines[-2] == expected_check_line - assert lines[-1] == b'FAILED' + assert lines[-1] == b'FAILED: target already fully promoted' def test_copy_promotes_staging_to_promoted_bucket( @@ -121,6 +121,57 @@ def test_copy_via_multipart_copy( assert dl.content == content +def test_resume_partial_promote( + session, artifacts_url, upload_file, finish_build +): + """Promote succeeds when the target already has some but not all files. + + Simulates a previous promote that was interrupted: a subset of files has + been uploaded to the target directly (no .final_status). Re-running the + promote should copy only the missing files and complete successfully. + """ + n = 5 + for i in range(n): + upload_file(STAGING_BUILD, f'obj-{i}', f'content-{i}'.encode()) + finish_build(STAGING_BUILD) + + # Pre-populate the target with the first two objects (partial state). + upload_file(COPY_BUILD, 'obj-0', b'content-0') + upload_file(COPY_BUILD, 'obj-1', b'content-1') + + resp = session.get(f'{artifacts_url}/copy/{STAGING_BUILD}/{COPY_BUILD}/') + assert resp.status_code == 200 + assert resp.content.splitlines()[-1] == b'BUILD COPIED' + + # All source objects must be present in the target. + for i in range(n): + dl = session.get(f'{artifacts_url}/download/{COPY_BUILD}/obj-{i}') + assert dl.status_code == 200, f'obj-{i} missing from resumed target' + assert dl.content == f'content-{i}'.encode() + + assert session.get(f'{artifacts_url}/download/{COPY_BUILD}/.final_status').status_code == 200 + + +def test_resume_already_complete_promote( + session, artifacts_url, upload_file, finish_build +): + """Resuming a fully-promoted target is rejected with a clear error. + + A target that already has a .final_status is considered complete; a second + promote attempt must fail rather than silently overwriting it. + """ + upload_file(STAGING_BUILD, 'file.txt', b'data') + finish_build(STAGING_BUILD) + + # First promote — completes successfully. + session.get(f'{artifacts_url}/copy/{STAGING_BUILD}/{COPY_BUILD}/') + + # Second attempt — target is fully promoted. + resp = session.get(f'{artifacts_url}/copy/{STAGING_BUILD}/{COPY_BUILD}/') + assert resp.status_code == 200 + assert resp.content.splitlines()[-1] == b'FAILED: target already fully promoted' + + def test_copy_behind_ingress(session, artifacts_url, upload_file, finish_build): """Copy works correctly when a Script-Name ingress header is present.""" upload_file(STAGING_BUILD, '.final_status', b'SUCCESSFUL',)