AR delete surface/Step 7: Manifest and container tag delete mutations

What does this MR do and why?

This is Step 7 of 19 in the plan for the GitLab monolith's Artifact Registry (AR) artifact delete surface. The plan file is docs/plans/monolith/2026-09-01-artifact-delete-surface.md in the gitlab-org/ops/artifact-registry repo, merged via gitlab-org/ops/artifact-registry!2201 (merged).

Work item for this step: #627397 (closed) Parent tracking issue: #627172

It adds two GraphQL mutations, both mounted with experiment: { milestone: '19.4' } and gated behind the dark artifact_registry_ui feature flag (type wip, default off):

  • ArtifactRegistryManifestDelete (Mutations::ArtifactRegistry::Manifests::Delete), which deletes one container manifest addressed by imageId plus digest.
  • ArtifactRegistryContainerTagDelete (Mutations::ArtifactRegistry::ContainerTags::Delete), which deletes one container tag addressed by imageId plus tagName.

Both compose the existing Mutations::ArtifactRegistry::Base, and both call the client methods #delete_manifest and #delete_container_tag that Step 2 (!253264 (merged)) adds.

Design notes

  1. Kind neutral. Neither mutation takes or checks a repository kind. AR reads the kind from its own repository row and decides whether the request permanently deletes a hosted artifact or evicts a remote cached copy. Both mutations copy the S05 precedent authorize_granular_token skip_reason: :external_service_authorizes, because AR is the authorization boundary and neither mutation owns a group or project to scope a token against. A request spec asserts a hosted repository and a remote repository issue the identical route.

    The repository kind enum has three values, HOSTED, VIRTUAL and REMOTE, and both mutation descriptions now cover all three rather than naming hosted and remote alone. A request spec now also covers kind virtual on each mutation, asserting the same route is issued, which pins the kind-neutral routing for the third kind.

    The descriptions do not claim an outcome for virtual, because AR's OpenAPI document says nothing about virtual on either of these two container delete routes. It documents virtual elsewhere (bulk package delete answers 404, repository delete answers 500, tracked at gitlab-org/ops/artifact-registry#819), but not here. That gap is being raised with the AR team rather than guessed at, since a spec asserting a specific AR answer for virtual would fabricate a contract that does not exist.

  2. Acceptance is not completion. AR answers 202 with no body. The payload carries errors and the targeted repository only. There is no count and no completion claim. The declarative specs assert the field list so a count field cannot be added unnoticed.

  3. The manifest 409. deleteContainerManifest is the one delete another artifact can refuse: AR answers 409 when another manifest indexes the target, deletes nothing, and names the blocking parent digests in error.details.parents. Step 2 (!253264 (merged)) widened the client's allowlisted_details so those digests survive. This MR maps that 409 to a mutation error naming the digests rather than to a not-found or a success. A 409 carrying no digests falls through to the ordinary error mapping, so nothing is invented.

    The message is now bounded. A new constant, MAX_RENDERED_PARENTS = 10, caps how many digests it lists. At 10 or fewer blocking digests the message is unchanged. Above 10 it switches to a second string that states the total and renders the first 10, so the caller always learns the true size even when the list is clipped. The limit is stated in the mutation's GraphQL description, so it appears in the public API reference.

    A limit rather than a structured payload field, because AR's contract declares parents with minItems: 1 and no maximum, so an unbounded string in errors[0] was the real exposure. A structured field would contradict design note 2 (the payload deliberately carries only errors and repository) and would widen the schema past this step's scope.

    Downstream, Step 13 (#627403 (closed)) disables Delete on a manifest a parent index references, reading parents_count and parents_preview from AR's container redesign fast-follow (gitlab-org/ops/artifact-registry#1150 (closed)) rather than parsing this message. So this message stays the fallback for the race where an index appears between the read and the delete.

  4. The format guard is duplicated. Both mutations add their own guard: each raises Gitlab::Graphql::Errors::ArgumentError with the message "Only Docker and OCI repositories have manifests and container tags." when repository.images? is false. The request specs assert that message, not the client's guard_format! message. The client's guard_format! against IMAGE_FORMATS still exists and sits behind the mutation guard as a second line of defence, but it is not the mechanism that fires for a package repository reaching these mutations. A request spec pins that a maven or npm repository is rejected with no delete issued.

  5. Mount placement. The two mount_mutation lines go after the Enable mount rather than next to Artifacts::Delete, leaving that gap for Step 6's two mounts so the two steps merge in any order.

  6. Argument bounds. Both mutations bound their free-string argument by length: MAX_DIGEST_LENGTH = 512 on digest, MAX_TAG_NAME_LENGTH = 1024 on tag_name, applied as GraphQL length validations and stated in the argument descriptions. A spec on each asserts rejection before any request leaves the monolith.

    Both limits sit far above anything AR can store. A canonical sha256 digest is 71 characters, and AR caps a stored tag name at 255, so nothing AR could serve is blocked.

    No shape validation is applied, deliberately. AR's OpenAPI declares no pattern on the manifestDigest path parameter, so a malformed digest has to stay expressible, reaching the handler's 404 rather than failing request validation, and the API exposes no digest-syntax oracle. tagName likewise asserts no upper bound so an over-long name reaches the 404.

    Applying the client's DIGEST_SHAPE_REGEX to the outgoing digest would make the monolith stricter than the service it proxies and would create that syntax oracle, letting a caller tell "malformed" apart from "not found". The length bounds remove the large-payload round trip without taking on that divergence.

Scope

Single deletes only. The four bulk delete mutations are Step 10 and deferred to a second slice, per the plan. Nothing here touches the frontend; the affordances are Steps 13 and 15.

Files

New: two mutation classes, two declarative mutation specs, two GraphQL request specs.

Modified: ee/app/graphql/ee/types/mutation_type.rb (two mount lines), ee/lib/artifact_registry/client.rb (logs the parent digest drop), doc/api/graphql/reference/_index.md and public/-/graphql/introspection_result.json (both regenerated), locale/gitlab.pot (three new strings).

Deviation from the plan

The plan's Step 7 entry says "i18n / changelog: none", but the acceptance criterion requires the 409 to surface the blocking digests, which needs user-facing strings. This MR adds three msgids and regenerates locale/gitlab.pot:

  1. ArtifactRegistry|Manifest %{digest} was not deleted because it is indexed by other manifests: %{parents}.
  2. ArtifactRegistry|Manifest %{digest} was not deleted because it is indexed by %{total} other manifests, including: %{parents}.
  3. ArtifactRegistry|Only Docker and OCI repositories have manifests and container tags.

The second string exists because of the rendered-list cap in design note 3, and the third because of the mutation's own format guard in design note 4. No changelog entry, because the flag is default off.

Gating

The plan's Dependencies section gates Steps 6 through 10 on a spec promotion MR that has not been raised. Neither the S14 nor the S06 monolith spec in the artifact-registry repo names a mutation, an argument, or an error mapping for any delete, so this MR fixes schema names that the promotion MR will have to adopt or contradict. The mutations themselves are safe to ship because they sit behind a dark flag and AR authorizes every request server side.

Screenshots or screen recordings

This is a GraphQL-only change with no UI. The recording below drives the two mutations through the GraphQL explorer (GraphiQL) against a live local Artifact Registry.

The recording runs the eight scenarios listed in the validation section below, in order.

How to set up and validate locally

Confirmed results:

  • ee/spec/requests/api/graphql/mutations/artifact_registry/manifests/delete_spec.rb: 12 examples, 0 failures.
  • The container tag request spec plus both declarative mutation specs: 16 examples, 0 failures.
  • spec/graphql/types/mutation_type_spec.rb and ee/spec/graphql/ee/types/mutation_type_spec.rb: 14 examples, 0 failures.
  • RuboCop clean on all seven touched Ruby files.
  • bundle exec rake gitlab:graphql:validate: OK.
  • bundle exec rake gitlab:graphql:check_docs: "GraphQL documentation is up to date".
  • bundle exec rake gitlab:graphql:check_introspection_sync: "All GraphQL introspection schemas are up to date".
  • bundle exec rake gitlab:graphql:schema:dump: schema IDL and JSON dumped.
  • node scripts/frontend/graphql_possible_types_extraction.js --check: no changes needed.

Note: request specs in this repo need CI=true set locally. config/environments/test.rb only sets config.assets.compile = false when ENV['CI'] is present, and without it every request spec dies in Gitlab::GonHelper#add_gon_variables with LoadError: cannot load such file -- sass.

Exercising the delete mutations against a live Artifact Registry

These scenarios ran against a live local Artifact Registry, built from origin/main at commit b00db7c33 of https://gitlab.com/gitlab-org/ops/artifact-registry, listening on http://localhost:8080, with its own Postgres and Redis, MinIO for blob storage, and per-user JWTs that the monolith mints and Artifact Registry verifies against the GDK's JWKS. The organization's Artifact Registry namespace slug is gdk-local-test. The fixture was pushed with real OCI Distribution v2 requests (blob uploads then manifest PUTs), not fabricated rows: a hosted docker repository s7-container-1788747499, image demo (image ID 01a079a9-44e7-7079-bec9-0fb8c20d7974), an OCI image index at digest sha256:44cf1b1e8fb18b1bc4eadbc2ac3e606e60f553f3915971014f59d4d0a78745e8 tagged v1, and two child manifests the index references, amd64 at sha256:7b2515d99f608682345e5cdf603ad8b7b812404f23b59e3becd4fc14febcad8a and arm64 at sha256:628c36b9e9c2324d135b3342c5cc427a4dcbe716ab5a2a735a23000e011ea40b. A second tag, standalone-tag, points at the arm64 child. Before the run, Artifact Registry held 3 manifests and 2 tags for this image.

  1. Read the repository, its image, and the image's manifests. This establishes the image ID and the three digests used below.

    {
      "data": {
        "organization": {
          "artifactRegistryRepository": {
            "name": "s7-container-1788747499",
            "format": "DOCKER",
            "kind": "HOSTED",
            "images": {
              "nodes": [
                {
                  "id": "01a079a9-44e7-7079-bec9-0fb8c20d7974",
                  "name": "demo",
                  "manifests": {
                    "nodes": [
                      {
                        "digest": "sha256:44cf1b1e8fb18b1bc4eadbc2ac3e606e60f553f3915971014f59d4d0a78745e8",
                        "mediaType": "application/vnd.oci.image.index.v1+json"
                      },
                      {
                        "digest": "sha256:628c36b9e9c2324d135b3342c5cc427a4dcbe716ab5a2a735a23000e011ea40b",
                        "mediaType": "application/vnd.oci.image.manifest.v1+json"
                      },
                      {
                        "digest": "sha256:7b2515d99f608682345e5cdf603ad8b7b812404f23b59e3becd4fc14febcad8a",
                        "mediaType": "application/vnd.oci.image.manifest.v1+json"
                      }
                    ]
                  }
                }
              ]
            }
          }
        }
      }
    }
  2. artifactRegistryManifestDelete on the amd64 child while the index still references it. Artifact Registry answers 409, and the mutation surfaces it as a payload error naming both the target digest and the blocking digest, with repository null.

    {
      "data": {
        "artifactRegistryManifestDelete": {
          "errors": [
            "Manifest sha256:7b2515d99f608682345e5cdf603ad8b7b812404f23b59e3becd4fc14febcad8a was not deleted because it is indexed by other manifests: sha256:44cf1b1e8fb18b1bc4eadbc2ac3e606e60f553f3915971014f59d4d0a78745e8."
          ],
          "repository": null
        }
      }
    }
  3. artifactRegistryManifestDelete on the index itself. Accepted: errors is empty and repository is returned.

    {
      "data": {
        "artifactRegistryManifestDelete": {
          "errors": [],
          "repository": {
            "name": "s7-container-1788747499",
            "format": "DOCKER",
            "kind": "HOSTED"
          }
        }
      }
    }
  4. Re-read the manifests. The index is gone and the two children remain, proof that the 202 from the previous scenario became a real delete rather than just an acceptance.

    {
      "data": {
        "organization": {
          "artifactRegistryRepository": {
            "name": "s7-container-1788747499",
            "format": "DOCKER",
            "kind": "HOSTED",
            "images": {
              "nodes": [
                {
                  "id": "01a079a9-44e7-7079-bec9-0fb8c20d7974",
                  "name": "demo",
                  "manifests": {
                    "nodes": [
                      {
                        "digest": "sha256:628c36b9e9c2324d135b3342c5cc427a4dcbe716ab5a2a735a23000e011ea40b",
                        "mediaType": "application/vnd.oci.image.manifest.v1+json"
                      },
                      {
                        "digest": "sha256:7b2515d99f608682345e5cdf603ad8b7b812404f23b59e3becd4fc14febcad8a",
                        "mediaType": "application/vnd.oci.image.manifest.v1+json"
                      }
                    ]
                  }
                }
              ]
            }
          }
        }
      }
    }
  5. artifactRegistryManifestDelete on the amd64 child again, now that nothing indexes it. Accepted this time, which shows the 409 in the earlier scenario was about the index reference and not about the digest or the route.

    {
      "data": {
        "artifactRegistryManifestDelete": {
          "errors": [],
          "repository": {
            "name": "s7-container-1788747499",
            "format": "DOCKER",
            "kind": "HOSTED"
          }
        }
      }
    }
  6. artifactRegistryContainerTagDelete on standalone-tag. Accepted.

    {
      "data": {
        "artifactRegistryContainerTagDelete": {
          "errors": [],
          "repository": {
            "name": "s7-container-1788747499",
            "format": "DOCKER",
            "kind": "HOSTED"
          }
        }
      }
    }
  7. The same tag mutation against an npm repository. Refused before any request leaves the monolith, with a top-level error naming the reason.

    {
      "errors": [
        {
          "message": "Only Docker and OCI repositories have manifests and tags.",
          "path": ["artifactRegistryContainerTagDelete"]
        }
      ]
    }
  8. artifactRegistryManifestDelete against a repository name that does not exist. The absence is hidden behind the generic resource-not-available error, and nothing is deleted.

    {
      "errors": [
        {
          "message": "The resource that you are attempting to access does not exist or you don't have permission to perform this action",
          "path": ["artifactRegistryManifestDelete"]
        }
      ]
    }

The top-level errors in the last two scenarios are shown here trimmed to the message and path that GraphiQL displays, because the explorer's response pane wraps them in an Apollo client error object with a long JavaScript stack.

Read back from Artifact Registry's REST API directly after the run: 1 manifest remains (the arm64 child) and 0 tags remain, against 3 manifests and 2 tags before. Two things are worth stating plainly here. The v1 tag disappeared even though no tag mutation touched it: Artifact Registry's contract for deleteContainerManifest says the delete takes the tags pointing at the manifest with it, so deleting the index took v1 along with it, and standalone-tag is the one the tag mutation actually removed. GraphQL also exposes no way to read container tags yet, so the result of the tag delete scenario is confirmed against Artifact Registry's REST tag list rather than through the API under test. The consumer side is tracked in #627405, Step 15 of the same plan (container tag removal), which records the read gate as the blocker and calls it the only gate in the plan that is an Artifact Registry contract extension rather than a monolith merge. The Artifact Registry side is gitlab-org/ops/artifact-registry#1150 (closed), "Container manifest reads for the UI", covering manifest detail by digest, per-manifest tags, a referrers count, and the platform triple. The contract for it merged into the S17 spec via gitlab-org/ops/artifact-registry!2317 (merged), shaping per-manifest tags as tags_preview and tags_count on a manifests-list row and the complete tags on manifest detail, with that entry marked "contract only" since the OpenAPI document does not declare those fields yet and nothing serves them, so the implementation is still owed by that issue.

Merge order

Step 7 consumes the two client methods Step 2 adds, so it opened stacked on Step 2's branch. Step 2 (!253264 (merged)) has now merged, so this branch was rebased onto master and retargeted there. It carries three commits and no longer depends on any open MR. It has since been rebased onto master again so the branch is current.

Related to #627397 (closed)

Edited by Fiona McCawley

Merge request reports

Loading
Loading