From 3888a68d3c4c92c344376f2cf55fe165cf776233 Mon Sep 17 00:00:00 2001 From: Filip Nikolovski Date: Fri, 26 Jun 2026 12:08:21 +0200 Subject: [PATCH] fix(KONFLUX-10809): fail incrementer on empty tag list Add extract_tags() that validates the list-tags response and checks repo existence via skopeo inspect when Tags is empty. If the repo exists but returned no tags, the task now fails instead of producing a potentially destructive increment value. Assisted-by: Claude Code Signed-off-by: Filip Nikolovski --- .../managed/apply-mapping/apply-mapping.yaml | 35 ++++- tasks/managed/apply-mapping/tests/mocks.sh | 17 +++ ...mapping-fail-empty-tags-existing-repo.yaml | 131 ++++++++++++++++++ 3 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 tasks/managed/apply-mapping/tests/test-apply-mapping-fail-empty-tags-existing-repo.yaml diff --git a/tasks/managed/apply-mapping/apply-mapping.yaml b/tasks/managed/apply-mapping/apply-mapping.yaml index e7f139aa63..7e39978a1e 100644 --- a/tasks/managed/apply-mapping/apply-mapping.yaml +++ b/tasks/managed/apply-mapping/apply-mapping.yaml @@ -198,13 +198,42 @@ spec: exit 0 fi + # Fetch and validate tags from a repository. Fails if the repo exists but + # returns an empty tag list (likely a transient registry error) to prevent + # silently falling back to increment 1 and overwriting existing tags. + extract_tags() { + local repo="$1" + local raw_json + raw_json=$(skopeo list-tags --retry-times 3 docker://"${repo}") + + if ! jq -e '.Tags' <<< "${raw_json}" > /dev/null 2>&1; then + echo "Error: skopeo list-tags returned invalid response (missing Tags key) for ${repo}" >&2 + exit 1 + fi + + local tag_count + tag_count=$(jq '.Tags | length' <<< "${raw_json}") + + if [[ "${tag_count}" -eq 0 ]]; then + if skopeo inspect --retry-times 3 --no-tags --raw docker://"${repo}" > /dev/null 2>&1; then + echo "Error: Repository ${repo} exists but skopeo list-tags returned an empty tag list." >&2 + echo "This may indicate a transient registry error. Failing to prevent overwriting" >&2 + echo "existing tags by falling back to increment 1." >&2 + exit 1 + fi + echo "" + return + fi + + jq -r '.Tags[]' <<< "${raw_json}" + } + # Function to handle incrementer logic increment_tag() { local tag_template="$1" local repo="$2" - # Use `skopeo list-tags` to fetch all tags from the repository - existing_tags=$(skopeo list-tags --retry-times 3 docker://"${repo}" | jq -r '.Tags[]') + existing_tags=$(extract_tags "${repo}") || exit 1 # Remove `{{ incrementer }}` placeholder to get the version prefix for regex pattern # shellcheck disable=SC2001 @@ -284,7 +313,7 @@ spec: local repo repo=$(jq -r --argjson r "$r" '.[$r]' <<< "$all_repos_json") local existing_tags - existing_tags=$(skopeo list-tags --retry-times 3 docker://"${repo}" | jq -r '.Tags[]') + existing_tags=$(extract_tags "${repo}") || exit 1 local repo_max repo_max=$(echo "${existing_tags}" | { grep -E "${tag_pattern}" || true; } \ | sed -E "s/^${escaped_prefix}//" | sort -nr | head -n1) diff --git a/tasks/managed/apply-mapping/tests/mocks.sh b/tasks/managed/apply-mapping/tests/mocks.sh index 839e81c3be..ff1f08e3bd 100644 --- a/tasks/managed/apply-mapping/tests/mocks.sh +++ b/tasks/managed/apply-mapping/tests/mocks.sh @@ -80,6 +80,12 @@ function skopeo() { return fi + # Existing repo that transiently returns empty tags (simulates the bug scenario) + if [[ "$*" =~ list-tags\ --retry-times\ 3\ docker://repo-empty-tags-existing ]]; then + echo '{"Tags": []}' + return + fi + # Raw manifest inspections (for annotations and config.mediaType) - these use the digest from get-image-architectures if [[ "$*" == "inspect --retry-times 3 --no-tags --raw docker://quay.io/myorg/helm-chart"* ]] then @@ -94,6 +100,17 @@ function skopeo() { then echo '{"config": {"mediaType": "application/vnd.oci.image.config.v1+json"}, "annotations": {"org.opencontainers.image.created": "2024-07-29T02:17:29Z"}}' return + elif [[ "$*" == "inspect --retry-times 3 --no-tags --raw docker://repo-empty-tags-existing"* ]] + then + # Existing repo that transiently returns empty tags - inspect succeeds + echo '{"config": {"mediaType": "application/vnd.oci.image.config.v1+json"}, "annotations": {}}' + return + elif [[ "$*" == "inspect --retry-times 3 --no-tags --raw docker://repoa"* ]] || \ + [[ "$*" == "inspect --retry-times 3 --no-tags --raw docker://repo2"* ]] + then + # Genuinely new repos (no manifests yet) - inspect fails + echo "Error: repository not found" >&2 + return 1 elif [[ "$*" == "inspect --retry-times 3 --no-tags --raw docker://"* ]] then # Default: standard OCI container image config diff --git a/tasks/managed/apply-mapping/tests/test-apply-mapping-fail-empty-tags-existing-repo.yaml b/tasks/managed/apply-mapping/tests/test-apply-mapping-fail-empty-tags-existing-repo.yaml new file mode 100644 index 0000000000..a240d25936 --- /dev/null +++ b/tasks/managed/apply-mapping/tests/test-apply-mapping-fail-empty-tags-existing-repo.yaml @@ -0,0 +1,131 @@ +--- +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: test-apply-mapping-fail-empty-tags-existing-repo + annotations: + test/assert-task-failure: "run-task" +spec: + description: | + Run the apply-mapping task with a component that uses {{ incrementer }} against a repository + that exists but returns an empty tag list. The task should fail instead of silently falling + back to increment 1, which could overwrite existing tags. + params: + - name: ociStorage + description: The OCI repository where the Trusted Artifacts are stored. + type: string + - name: ociArtifactExpiresAfter + description: Expiration date for the trusted artifacts created in the + OCI repository. An empty string means the artifacts do not expire. + type: string + default: "1d" + - name: orasOptions + description: oras options to pass to Trusted Artifacts calls + type: string + default: "--insecure" + - name: trustedArtifactsDebug + description: Flag to enable debug logging in trusted artifacts. Set to a non-empty string to enable. + type: string + default: "" + - name: dataDir + description: The location where data will be stored + type: string + tasks: + - name: setup + taskSpec: + results: + - name: sourceDataArtifact + type: string + volumes: + - name: workdir + emptyDir: {} + stepTemplate: + volumeMounts: + - mountPath: /var/workdir + name: workdir + env: + - name: IMAGE_EXPIRES_AFTER + value: $(params.ociArtifactExpiresAfter) + - name: "ORAS_OPTIONS" + value: "$(params.orasOptions)" + - name: "DEBUG" + value: "$(params.trustedArtifactsDebug)" + steps: + - name: setup-values + image: quay.io/konflux-ci/release-service-utils@sha256:5546fa78d3c88d7b6a2e8cff8902f7757f00541d0bbaf113b9f293133894afa3 + script: | + #!/usr/bin/env sh + set -eux + + mkdir -p "$(params.dataDir)/$(context.pipelineRun.uid)" + cat > "$(params.dataDir)/$(context.pipelineRun.uid)/test_data.json" << EOF + { + "mapping": { + "components": [ + { + "name": "comp1", + "repositories": [ + { + "url": "repo-empty-tags-existing", + "tags": [ + "v1.0.0-{{ incrementer }}" + ] + } + ] + } + ] + } + } + EOF + + cat > "$(params.dataDir)/$(context.pipelineRun.uid)/test_snapshot_spec.json" << EOF + { + "application": "myapp", + "components": [ + { + "name": "comp1", + "containerImage": "registry.io/image1@sha256:123456", + "source": { + "git": { + "revision": "testrevision", + "url": "myurl" + } + } + } + ] + } + EOF + - name: create-trusted-artifact + ref: + name: create-trusted-artifact + params: + - name: ociStorage + value: $(params.ociStorage) + - name: workDir + value: $(params.dataDir) + - name: sourceDataArtifact + value: $(results.sourceDataArtifact.path) + - name: run-task + taskRef: + name: apply-mapping + params: + - name: snapshotPath + value: $(context.pipelineRun.uid)/test_snapshot_spec.json + - name: dataPath + value: $(context.pipelineRun.uid)/test_data.json + - name: ociStorage + value: $(params.ociStorage) + - name: orasOptions + value: $(params.orasOptions) + - name: sourceDataArtifact + value: "$(tasks.setup.results.sourceDataArtifact)=$(params.dataDir)" + - name: dataDir + value: $(params.dataDir) + - name: trustedArtifactsDebug + value: $(params.trustedArtifactsDebug) + - name: taskGitUrl + value: "http://localhost" + - name: taskGitRevision + value: "main" + runAfter: + - setup