Uploaded image for project: 'Project Quay'
  1. Project Quay
  2. PROJQUAY-10827

Quay 3.17 quay.immutable=true manifest label doesn't make tags immutable - handler never called during push

XMLWordPrintable

    • False
    • Hide

      None

      Show
      None
    • False

      Overview

      When pushing an image with LABEL quay.immutable=true in the Dockerfile or --label "quay.immutable=true" during build, the label is stored in the database and visible in the UI, but the tag does NOT become immutable. The immutability handler function exists and is properly registered but is never invoked during the manifest push flow.

      Expected Behavior: 

      • quay.immutable=true manifest label support during push

      Discovery: User report and code review (2026-03-04)
      Severity: MAJOR (Feature Broken)
      Impact: quay.immutable label feature completely non-functional for container push workflow

      Issue Details

      User Command (Doesn't Work)

      # Build with immutable label
      podman build -t my-image:latest --label "quay.immutable=true" -f ./Dockerfile .
      
      # Or in Dockerfile:
      # FROM ubi9
      # LABEL quay.immutable="true"
      # ...
      
      # Push to Quay
      podman push quay.io/myorg/demo:latest
      

      Expected Behavior

      1. Manifest created with quay.immutable=true label
      2. Label stored in database
      3. Label handler invoked to set tag immutability
      4. Tag marked as immutable=true in database
      5. Tag shows as immutable (locked) in UI
      6. Tag cannot be overwritten or deleted

      Actual Behavior

      1. Manifest created with quay.immutable=true label ✓
      2. Label stored in manifestlabel table ✓
      3. Label handler NEVER invoked
      4. Tag created with immutable=false (default) ✗
      5. Tag shows as mutable in UI ✗
      6. Tag can be overwritten and deleted ✗

      Database Evidence

      After push with quay.immutable=true label:

      -- Label exists in databaseSELECT ml.key, ml.value, m.digest
      FROM manifestlabel ml
      JOIN manifest m ON ml.manifest_id = m.id
      WHERE ml.key = 'quay.immutable';
      -- Returns: quay.immutable | true | sha256:abc123...
      
      -- But tag is NOT immutableSELECT t.name, t.immutable, r.name as repo
      FROM tag t
      JOIN repository r ON t.repository_id = r.id
      WHERE t.name = 'latest' AND r.name = 'demo';
      -- Returns: latest | f (false) | demo
      

      Label is stored but tag immutability is not set.

      Root Cause Analysis

      Label Creation Without Handler Invocation

      File: data/model/oci/manifest.py:430-437

      During manifest creation, labels from the container image are extracted and stored:

      for key, value in labels.items():
          # NOTE: There can technically be empty label keys via Dockerfile's.
          if not key:
              continue
      
          media_type = "application/json" if is_json(value) else "text/plain"
          create_manifest_label(manifest, key, value, "manifest", media_type)  # Line 437
          # ✗ BUG: Handler is NOT called here!
      

      The create_manifest_label() function only writes the label to the database. It does NOT invoke the label handler from label_handlers.py.

      Handler Function Exists But Is Unused

      File: data/registry_model/label_handlers.py:28-38

      The immutable label handler is properly implemented and registered:

      LABEL_IMMUTABLE_KEY = "quay.immutable"
      
      def _immutable(label_dict, manifest, model):
          """Sets immutability on manifest tags based on the quay.immutable label."""
          if not features.IMMUTABLE_TAGS:
              return
      
          value = label_dict.get("value", "").strip().lower()
          if value == "true":
              logger.debug("Labeling manifest %s as immutable", manifest)
              model.set_tags_immutability_for_manifest(manifest, True)  # ✗ NEVER EXECUTED
      
      _LABEL_HANDLERS = {
          LABEL_EXPIRY_KEY: _expires_after,
          LABEL_IMMUTABLE_KEY: _immutable,  # Registered but never called
      }
      

      This function exists, is registered, but is never invoked during podman push.

      Where Handler IS Called (Not Used During Push)

      File: data/registry_model/registry_oci_model.py:315-344

      There is a context manager that properly calls handlers:

      def batch_create_manifest_labels(self, manifest):
          labels_to_add = []
      
          def add_label(key, value, source_type_name, media_type_name=None):
              labels_to_add.append(...)
      
          yield add_label
      
          # Process all labels
          for label_data in labels_to_add:
              with db_transaction():
                  # Create the label itself
                  oci.label.create_manifest_label(manifest._db_id, **label_data)
      
                  # ✓ Handler IS called here (line 344)
                  apply_label_to_manifest(label_data, manifest, self)
      

      Problem: This batch_create_manifest_labels() context manager is NEVER USED in the manifest push endpoint (endpoints/v2/manifest.py).

      Why This Bug Exists - Historical Context

      File: data/registry_model/registry_oci_model.py:502-506

      Code comment explains the design decision:

      # NOTE: Since there is currently only one special label on a manifest that has
      # an effect on its tags (expiration), it is just simpler to set that value at
      # tag creation time (plus it saves an additional query).
      # If we were to define more of these "special" labels in the future, we should
      # use the handlers from data/registry_model/label_handlers.py
      

      Timeline:
      1. Initially only quay.expires-after label existed
      2. Expiration was hard-coded into tag creation (works correctly)
      3. Comment states future labels should use handler system
      4. quay.immutable feature added later (PROJQUAY-10157)
      5. Handler created and registered in label_handlers.py
      6. But handler was never integrated into manifest push flow

      Comparison: Expiration Works, Immutability Doesn't

      File: data/registry_model/registry_oci_model.py:502-527

      # ✓ Expiration label - SPECIAL HANDLING (WORKS)
      if not created_manifest.newly_created:
          label_dict = self._get_expiry_label_for_manifest(wrapped_manifest)
      else:
          # Extract quay.expires-after from labels_to_apply
          label_dict = next(
              (dict(key=label_key, value=label_value)
               for label_key, label_value in created_manifest.labels_to_apply.items()
               if label_key == LABEL_EXPIRY_KEY),
              None,
          )
      
      expiration_seconds = None
      if label_dict is not None:
          expiration_td = convert_to_timedelta(label_dict["value"])
          expiration_seconds = expiration_td.total_seconds()
      
      # Expiration applied during tag creation
      tag = oci.tag.retarget_tag(
          tag_name, created_manifest.manifest,
          expiration_seconds=expiration_seconds  # ✓ Works
      )
      

      quay.expires-after works because it has special hard-coded handling.

      quay.immutable doesn't work because it has NO special handling and the handler system is not used.

      Impact Assessment

      Severity: MAJOR

      Feature Impact

      • quay.immutable manifest label feature is completely non-functional
      • Users building images with LABEL quay.immutable=true get no protection
      • CI/CD pipelines relying on this feature are not protected
      • Documentation states this feature works, but it doesn't

      Affected Workflows

      1. Dockerfile builds:

      Unable to find source-code formatter for language: dockerfile. Available languages are: actionscript, ada, applescript, bash, c, c#, c++, cpp, css, erlang, go, groovy, haskell, html, java, javascript, js, json, lua, none, nyan, objc, perl, php, python, r, rainbow, ruby, scala, sh, sql, swift, visualbasic, xml, yamlFROM registry.access.redhat.com/ubi9/ubi-minimal:latest
      LABEL quay.immutable="true"
      COPY app /app
      CMD ["/app/start"]
      

      2. Podman/Docker builds:

      podman build --label quay.immutable=true -t quay.io/myorg/app:v1.0 .
      docker build --label quay.immutable=true -t quay.io/myorg/app:v1.0 .
      

      3. Multi-stage builds:

      Unable to find source-code formatter for language: dockerfile. Available languages are: actionscript, ada, applescript, bash, c, c#, c++, cpp, css, erlang, go, groovy, haskell, html, java, javascript, js, json, lua, none, nyan, objc, perl, php, python, r, rainbow, ruby, scala, sh, sql, swift, visualbasic, xml, yamlFROM golang:1.21 AS builder
      WORKDIR /app
      COPY . .
      RUN go build -o myapp
      
      FROM scratch
      LABEL quay.immutable="true"
      COPY --from=builder /app/myapp /
      CMD ["/myapp"]
      

      All of these workflows are affected - label is stored but tag remains mutable.

      User Impact

      • Users expect tags to be immutable but they are not
      • Production tags can be accidentally overwritten
      • Release tags (v1.0.0, v2.0.0) not protected as intended
      • Silent failure - no error message, appears to work

      Documentation Gap

      Quay documentation likely states quay.immutable label works for container builds, creating false expectations.

      Affected Code Files

      Primary:

      • data/model/oci/manifest.py:430-437 - Label creation without handler
      • data/registry_model/registry_oci_model.py:502-549 - Manifest and tag creation
      • data/registry_model/label_handlers.py:28-38 - Unused handler function

      Related:

      • endpoints/v2/manifest.py:308-311 - Push endpoint (calls create_manifest_and_retarget_tag)
      • data/model/oci/tag.py:633-646 - set_tags_immutability_for_manifest() function

      Recommended Fixes

      Option 1: Add Special Handling Like Expiration (Easiest)

      File: data/registry_model/registry_oci_model.py:527+

      Add immutability handling after expiration handling:

      # After line 527 (after expiration handling), add:
      
      # Handle quay.immutable label
      if features.IMMUTABLE_TAGS and created_manifest.newly_created:
          from data.registry_model.label_handlers import LABEL_IMMUTABLE_KEY
          
          immutable_label = created_manifest.labels_to_apply.get(LABEL_IMMUTABLE_KEY)
          if immutable_label and immutable_label.strip().lower() == "true":
              logger.debug("Setting tags for manifest %s as immutable from label", 
                          created_manifest.manifest.digest)
      
      # Re-target the tag to it
      tag = oci.tag.retarget_tag(...)
      
      # NEW: Apply immutability AFTER tag creation
      if features.IMMUTABLE_TAGS and created_manifest.newly_created:
          immutable_label = created_manifest.labels_to_apply.get(LABEL_IMMUTABLE_KEY)
          if immutable_label and immutable_label.strip().lower() == "true":
              oci.tag.set_tags_immutability_for_manifest(
                  created_manifest.manifest.id, True
              )
      

      Pros:

      • Minimal code change (10 lines)
      • Follows existing pattern (same as expiration)
      • Low risk
      • Easy to test

      Cons:

      • Continues technical debt of hard-coded special labels
      • Doesn't use the handler system as originally intended

      Option 2: Use Handler System Properly (Correct But Complex)

      File: data/model/oci/manifest.py:437+

      Call handler during label creation:

      from data.registry_model.label_handlers import apply_label_to_manifest
      
      for key, value in labels.items():
          if not key:
              continue
      
          media_type = "application/json" if is_json(value) else "text/plain"
          create_manifest_label(manifest, key, value, "manifest", media_type)
          
          # NEW: Apply label handler if one exists
          label_dict = {"key": key, "value": value}
          # Need to pass wrapped manifest and registry model instance
          # This requires refactoring to make these available in this context
          apply_label_to_manifest(label_dict, wrapped_manifest, registry_model)
      

      Pros:

      • Uses handler system as designed
      • Extensible for future special labels
      • Aligns with code comment's intent

      Cons:

      • Requires refactoring to pass manifest wrapper and registry model
      • More complex change
      • Higher risk
      • Requires careful testing

      Option 3: Hybrid Approach (Recommended)

      Phase 1 (Quick Fix):

      • Use Option 1 to fix immutability immediately
      • Add to backlog: refactor to use handler system properly

      Phase 2 (Technical Debt):

      • Refactor manifest creation to use batch_create_manifest_labels()
      • Remove hard-coded expiration and immutability handling
      • Use handler system for all special labels

      Testing Requirements

      Integration Test (Must Add)

      def test_immutable_label_from_manifest(initialized_db):
          # Create repository
          repository = create_repository('devtable', 'immutabletest', None)
          
          # Create manifest with quay.immutable label
          manifest_json = {...}  # Manifest with Config containing Labels
          manifest = parse_manifest_from_bytes(Bytes.for_string_or_unicode(manifest_json), ...)
          
          # Push manifest
          created_manifest = get_or_create_manifest(repository, manifest, storage)
          
          # Create tag
          tag = retarget_tag('v1.0', created_manifest.manifest, ...)
          
          # Verify label was stored
          labels = list_manifest_labels(created_manifest.manifest)
          assert any(l.key == 'quay.immutable' and l.value == 'true' for l in labels)
          
          # ✓ CRITICAL: Verify tag is immutable
          assert tag.immutable == True  # This should pass but currently FAILS
          
          # Verify tag cannot be overwritten
          with pytest.raises(ImmutableTagException):
              retarget_tag('v1.0', another_manifest, ...)
      

      End-to-End Test

      def test_podman_push_with_immutable_label(client, app):
          # Build image with quay.immutable label
          # Push to Quay
          # Verify tag is immutable via API
          
          response = client.get(
              '/api/v1/repository/devtable/immutabletest/tag/?specificTag=v1.0'
          )
          
          assert response.json['tags'][0]['immutable'] == True
      

      Regression Tests

      • Verify quay.expires-after still works
      • Verify manual label creation still works
      • Verify API-based immutability still works
      • Verify immutability policies still work

      Workarounds

      Until fixed, users must use API to set immutability after push:

      Workaround 1: API Call After Push

      # 1. Push image (label stored but tag not immutable)
      podman push quay.io/myorg/demo:v1.0
      
      # 2. Set immutability via API
      TOKEN="your-oauth-token"
      
      curl -X PUT \
        -H "Authorization: Bearer ${TOKEN}" \
        -H "Content-Type: application/json" \
        -d '{"immutable": true}' \
        "https://quay.io/api/v1/repository/myorg/demo/tag/v1.0"
      

      Workaround 2: Immutability Policy

      Create organization or repository policy instead of using label:

      # Create policy to make all release tags immutable
      curl -X POST \
        -H "Authorization: Bearer ${TOKEN}" \
        -H "Content-Type: application/json" \
        -d '{"tagPattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$", "tagPatternMatches": true}' \
        "https://quay.io/api/v1/organization/myorg/immutabilitypolicy/"
      

      Workaround 3: CI/CD Automation

      # .github/workflows/release.yml
      - name: Build and push
        run: |
          podman build -t quay.io/myorg/app:${VERSION} .
          podman push quay.io/myorg/app:${VERSION}
      
      - name: Make tag immutable
        run: |
          curl -X PUT \
            -H "Authorization: Bearer ${{ secrets.QUAY_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d '{"immutable": true}' \
            "https://quay.io/api/v1/repository/myorg/app/tag/${VERSION}"
      

      Verification Steps

      After fix is deployed:

      1. Build image with label:

      podman build -t quay.io/testorg/testapp:v1.0 \
        --label "quay.immutable=true" \
        -f - <<EOF
      FROM ubi9
      LABEL quay.immutable="true"
      CMD ["/bin/bash"]
      EOF
      

      2. Push to Quay:

      podman push quay.io/testorg/testapp:v1.0
      

      3. Verify tag is immutable:

      curl -H "Authorization: Bearer ${TOKEN}" \
        "https://quay.io/api/v1/repository/testorg/testapp/tag/?specificTag=v1.0" \
        | jq '.tags[0].immutable'
      # Expected: true ✓
      

      4. Verify tag cannot be overwritten:

      podman tag alpine:latest quay.io/testorg/testapp:v1.0
      podman push quay.io/testorg/testapp:v1.0
      # Expected: TAG_IMMUTABLE error ✓
      

      5. Check database:

      SELECT t.name, t.immutable, ml.key, ml.value
      FROM tag t
      JOIN manifest m ON t.manifest_id = m.id
      JOIN manifestlabel ml ON ml.manifest_id = m.id
      WHERE t.name = 'v1.0' AND ml.key = 'quay.immutable';
      -- Expected: v1.0 | true | quay.immutable | true
      

      Related Issues

      References

      Reported by: User testing (lzha1981)
      Analysis by: Claude Code

              rhn-support-bpratt Brady Pratt
              lzha1981 luffy zhang
              Votes:
              0 Vote for this issue
              Watchers:
              1 Start watching this issue

                Created:
                Updated: