Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions lua/copy_build.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
57 changes: 54 additions & 3 deletions tests/end2end/test_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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',)
Expand Down
Loading