Manage private container image repositories, and the credentials that push to and pull from them. Images themselves move with ordinary tools — docker, podman, buildkit or any CI system — at <region>.plany.cr; this API creates repositories, lists and deletes what is in them, and controls who can reach them. GetRegistryCredential mints a short-lived login for a build machine, so CI can push without holding an API key.
Creates a repository.
A repository has to exist before anything can be pushed to it. That is a deliberate departure from registries that create one on first push: a repository is what quota is counted in and what a role is bound on, so create-on-push would mean a typo in a tag silently provisioning a resource and consuming a limit.
parentstringRequiredprojects/{project}/locations/{location}.
namestringIdentifierprojects/{project}/locations/{location}/repositories/{repository}.
The last segment is what appears in an image reference, so it is
constrained by the OCI specification rather than by AIP-122 alone: lower
case, digits, and ., _ or - between them. So is the project id, for
the same reason and with no way to fix it afterwards — see
CreateRepositoryRequest.repository_id.
display_namestringWhat a person calls it. Free text; not part of any reference.
labelsmap<string, string>Arbitrary key/value pairs, for the customer's own accounting.
immutable_tagsboolRefuse a push that would move a tag that already exists.
Off by default, because :latest moving is what most people mean by a tag.
On, a tag is a permanent name for one digest — which is what a deployment
that has to be reproducible needs, and what makes "the same tag" a fact
rather than a hope.
Changing it does not rewrite history: tags already moved stay where they are, and the guarantee starts now.
repository_idstringRequiredThe last segment of the name, and the second half of every image reference that will ever point here.
Constrained by the OCI specification rather than by AIP-122: one or more
lower-case alphanumeric runs separated by a single ., _, __ or -.
An id that AIP would accept and OCI would not — anything with a capital
letter — produces a repository nothing can address, so it is refused here
rather than discovered at the customer's first push as a parse error from
their own client.
The project's id is under the same constraint and cannot be fixed afterwards, because it is the first path segment. A project whose id no container client can parse is told so at this call, which is the last moment anybody can do anything about it.
grpcurl -H "authorization: Bearer $PLANY_API_KEY" \ -d '{ "parent": "projects//locations/", "repository": { "display_name": "My server" }, "repository_id": "my-repository" }' \ api.plany.cloud:443 plany.containerregistry.v1.RegistryService/CreateRepository{ "name": "projects//locations//repositories/my-repository", "uid": "0b6f3c1e-8a2d-4f6e-9c1a-2f4b7d9e0a11", "display_name": "My server", "labels": { "key": "" }, "state": "STATE_ACTIVE", "immutable_tags": false, "size_bytes": "0", "image_count": "0", "last_push_time": "2026-10-04T12:00:00Z", "last_pull_time": "2026-10-04T12:00:00Z", "create_time": "2026-10-04T12:00:00Z", "update_time": "2026-10-04T12:00:00Z"}namestringRequiredgrpcurl -H "authorization: Bearer $PLANY_API_KEY" \ -d '{ "name": "projects//locations//repositories/my-repository" }' \ api.plany.cloud:443 plany.containerregistry.v1.RegistryService/GetRepository{ "name": "projects//locations//repositories/my-repository", "uid": "0b6f3c1e-8a2d-4f6e-9c1a-2f4b7d9e0a11", "display_name": "My server", "labels": { "key": "" }, "state": "STATE_ACTIVE", "immutable_tags": false, "size_bytes": "0", "image_count": "0", "last_push_time": "2026-10-04T12:00:00Z", "last_pull_time": "2026-10-04T12:00:00Z", "create_time": "2026-10-04T12:00:00Z", "update_time": "2026-10-04T12:00:00Z"}parentstringRequiredpage_sizeint32page_tokenstringfilterstringAIP-160, over display_name, labels and state.
grpcurl -H "authorization: Bearer $PLANY_API_KEY" \ -d '{ "parent": "projects//locations/", "page_size": 50 }' \ api.plany.cloud:443 plany.containerregistry.v1.RegistryService/ListRepositories{ "repositories": [ { "name": "projects//locations//repositories/my-repository", "uid": "0b6f3c1e-8a2d-4f6e-9c1a-2f4b7d9e0a11", "display_name": "My server", "labels": { "key": "" }, "state": "STATE_ACTIVE", "immutable_tags": false, "size_bytes": "0", "image_count": "0", "last_push_time": "2026-10-04T12:00:00Z", "last_pull_time": "2026-10-04T12:00:00Z", "create_time": "2026-10-04T12:00:00Z", "update_time": "2026-10-04T12:00:00Z" } ], "next_page_token": ""}namestringIdentifierprojects/{project}/locations/{location}/repositories/{repository}.
The last segment is what appears in an image reference, so it is
constrained by the OCI specification rather than by AIP-122 alone: lower
case, digits, and ., _ or - between them. So is the project id, for
the same reason and with no way to fix it afterwards — see
CreateRepositoryRequest.repository_id.
display_namestringWhat a person calls it. Free text; not part of any reference.
labelsmap<string, string>Arbitrary key/value pairs, for the customer's own accounting.
immutable_tagsboolRefuse a push that would move a tag that already exists.
Off by default, because :latest moving is what most people mean by a tag.
On, a tag is a permanent name for one digest — which is what a deployment
that has to be reproducible needs, and what makes "the same tag" a fact
rather than a hope.
Changing it does not rewrite history: tags already moved stay where they are, and the guarantee starts now.
Which fields to write. Only display_name, labels and immutable_tags
can be updated; everything else on the message is either the identity or is
measured rather than set.
grpcurl -H "authorization: Bearer $PLANY_API_KEY" \ -d '{ "repository": { "display_name": "My server" }, "update_mask": "display_name" }' \ api.plany.cloud:443 plany.containerregistry.v1.RegistryService/UpdateRepository{ "name": "projects//locations//repositories/my-repository", "uid": "0b6f3c1e-8a2d-4f6e-9c1a-2f4b7d9e0a11", "display_name": "My server", "labels": { "key": "" }, "state": "STATE_ACTIVE", "immutable_tags": false, "size_bytes": "0", "image_count": "0", "last_push_time": "2026-10-04T12:00:00Z", "last_pull_time": "2026-10-04T12:00:00Z", "create_time": "2026-10-04T12:00:00Z", "update_time": "2026-10-04T12:00:00Z"}Deletes a repository and everything in it.
Refused while the repository holds images unless force is set. A
repository is addressed by a name somebody's deployment may still be
pulling, and the cost of the two mistakes is not symmetric: a delete that
needed one extra flag is an inconvenience, and a delete that took a running
fleet's image out from under it is an outage nothing can undo.
namestringRequiredforceboolDelete it even though it still holds images.
grpcurl -H "authorization: Bearer $PLANY_API_KEY" \ -d '{ "name": "projects//locations//repositories/my-repository" }' \ api.plany.cloud:443 plany.containerregistry.v1.RegistryService/DeleteRepository{}Lists what is actually in a repository: exactly what a pull would find.
parentstringRequiredprojects/{project}/locations/{location}/repositories/{repository}.
page_sizeint32page_tokenstringtagged_onlyboolLeave out the manifests no tag points at.
Off by default, deliberately: an untagged image is still storage that is still being charged for, and a list that hid it would make the bill and the console disagree with no way to see why.
grpcurl -H "authorization: Bearer $PLANY_API_KEY" \ -d '{ "parent": "projects//locations//repositories/my-repository", "page_size": 50 }' \ api.plany.cloud:443 plany.containerregistry.v1.RegistryService/ListImages{ "images": [ { "name": "projects//locations//repositories/my-repository/images/my-image", "digest": "", "tags": [ "" ], "media_type": "", "size_bytes": "0", "platforms": [ "" ], "create_time": "2026-10-04T12:00:00Z" } ], "next_page_token": ""}namestringRequiredThe image's full name. The final segment may be a digest or a tag — a tag is resolved to the digest it currently points at, and the answer names the digest, so a caller can tell what they actually got.
grpcurl -H "authorization: Bearer $PLANY_API_KEY" \ -d '{ "name": "projects//locations//repositories/my-repository/images/my-image" }' \ api.plany.cloud:443 plany.containerregistry.v1.RegistryService/GetImage{ "name": "projects//locations//repositories/my-repository/images/my-image", "digest": "", "tags": [ "" ], "media_type": "", "size_bytes": "0", "platforms": [ "" ], "create_time": "2026-10-04T12:00:00Z"}Deletes one image, and every tag pointing at it.
Layers shared with other images are kept for as long as anything still uses them.
namestringRequiredgrpcurl -H "authorization: Bearer $PLANY_API_KEY" \ -d '{ "name": "projects//locations//repositories/my-repository/images/my-image" }' \ api.plany.cloud:443 plany.containerregistry.v1.RegistryService/DeleteImage{}Mints a credential a container client can log in with.
This is plany registry get-token, and what it is for is the case where
there is no person: a build machine that needs to push and should not be
holding an API key with the rest of an account's permissions behind it.
The credential it returns reaches the registry and nothing else on the
platform, expires on its own, and carries only the repository verbs the
caller already holds.
parentstringRequiredprojects/{project}/locations/{location}.
The credential is scoped to one project in one region, because that is the registry hostname it is good at.
lifetimeDurationHow long it should last. Capped by the server; a request for longer is clamped rather than refused, and the response says what was actually granted.
Left unset it is an hour, which is longer than an interactive push and shorter than a working day.
repository_idsrepeated stringRestrict it to these repositories, by id.
Empty means every repository in the project the caller can already reach. Naming them is how a build for one service gets a credential that cannot touch the others, and it can only ever narrow — a repository the caller holds no permission on is not granted by being listed here.
read_onlyboolRestrict it to pulling.
What a running workload wants, and the right default for anything that is deploying rather than building. A credential that can push is one that can replace the image a fleet is about to start.
grpcurl -H "authorization: Bearer $PLANY_API_KEY" \ -d '{ "parent": "projects//locations/" }' \ api.plany.cloud:443 plany.containerregistry.v1.RegistryService/GetRegistryCredential{ "registry": "", "username": "", "password": "", "expire_time": "2026-10-04T12:00:00Z", "granted_access": [ { "repository_id": "my-repository", "actions": [ "" ] } ]}GrantedAccess is one repository a credential reaches.
repository_idstringThe repository id, as it appears in an image reference.
actionsrepeated stringpull, push, delete — the OCI distribution actions, named as the
registry names them rather than as Plany's permissions do, because these
are what a client will be refused against.
{ "repository_id": "my-repository", "actions": [ "" ]}registrystringThe registry this is good at, as a client would write it:
<region>.plany.cr.
usernamestringWhat to pass as the username.
Ignored by the server — the credential is entirely in the password — but container clients require one to be present, so the platform names one rather than letting everybody invent their own.
passwordstringThe credential. Returned exactly once and recoverable from nowhere.
expire_timeTimestampgranted_accessrepeated GrantedAccessThe repositories this credential actually reaches, and what it may do to each — after intersecting what was asked for with what the caller holds.
Present because those two differ silently otherwise: a caller who asked for push on four repositories and holds it on three gets a working credential and finds out about the fourth from a 401 in the middle of a deploy.
repository_idstringThe repository id, as it appears in an image reference.
actionsrepeated stringpull, push, delete — the OCI distribution actions, named as the
registry names them rather than as Plany's permissions do, because these
are what a client will be refused against.
Image is one manifest in a repository.
Named by digest, because that is what an image is: tags are labels attached to it that can move, and two tags pointing at one digest are one image with two names.
namestringIdentifier.../repositories/{repository}/images/{digest}, where the digest is the
manifest's, sha256: prefix and all.
digeststringOutput onlyThe manifest digest, on its own, as a client would write it.
tagsrepeated stringOutput onlyEvery tag currently pointing at this digest. Often none: an image pushed by digest, or one whose tag has since been moved onto a newer build, is untagged and still perfectly pullable.
media_typestringOutput onlyThe manifest's own media type — an image manifest, or an index pointing at one manifest per platform.
size_bytesint64Output onlyThe manifest plus every layer it references.
platformsrepeated stringOutput onlyThe platforms this image can run on, as os/arch[/variant].
One entry for an ordinary manifest, several for an index. Worth surfacing
because "it pulled and would not start" is almost always this: an image
built linux/amd64 on a laptop, pulled onto Graviton.
imagesrepeated ImagenamestringIdentifier.../repositories/{repository}/images/{digest}, where the digest is the
manifest's, sha256: prefix and all.
digeststringOutput onlyThe manifest digest, on its own, as a client would write it.
tagsrepeated stringOutput onlyEvery tag currently pointing at this digest. Often none: an image pushed by digest, or one whose tag has since been moved onto a newer build, is untagged and still perfectly pullable.
media_typestringOutput onlyThe manifest's own media type — an image manifest, or an index pointing at one manifest per platform.
size_bytesint64Output onlyThe manifest plus every layer it references.
platformsrepeated stringOutput onlyThe platforms this image can run on, as os/arch[/variant].
One entry for an ordinary manifest, several for an index. Worth surfacing
because "it pulled and would not start" is almost always this: an image
built linux/amd64 on a laptop, pulled onto Graviton.
next_page_tokenstringrepositoriesrepeated RepositorynamestringIdentifierprojects/{project}/locations/{location}/repositories/{repository}.
The last segment is what appears in an image reference, so it is
constrained by the OCI specification rather than by AIP-122 alone: lower
case, digits, and ., _ or - between them. So is the project id, for
the same reason and with no way to fix it afterwards — see
CreateRepositoryRequest.repository_id.
uidstringOutput onlyServer-assigned, immutable, unique for the life of the platform.
Reused rather than the name wherever something has to outlive a rename.
display_namestringWhat a person calls it. Free text; not part of any reference.
labelsmap<string, string>Arbitrary key/value pairs, for the customer's own accounting.
STATE_ACTIVESTATE_DELETINGimmutable_tagsboolRefuse a push that would move a tag that already exists.
Off by default, because :latest moving is what most people mean by a tag.
On, a tag is a permanent name for one digest — which is what a deployment
that has to be reproducible needs, and what makes "the same tag" a fact
rather than a hope.
Changing it does not rewrite history: tags already moved stay where they are, and the guarantee starts now.
size_bytesint64Output onlyTotal bytes attributable to this repository.
An attribution rather than a count of unique bytes. Layers are shared, so pushing a common base image counts your reference to it — and your figure never changes because somebody else pushed the same layer.
image_countint64Output onlyHow many manifests are in it, tagged or not.
When something was last written here, and last read.
last_pull_time is the one worth having: it is how a customer finds the
repository nothing has fetched since March, and it is the only signal that
separates storage somebody is paying for on purpose from storage they
forgot about.
next_page_tokenstringRepository is one named collection of images.
It maps to a path under the registry hostname — <project>/<repository> —
and to a prefix in the region's object store. A project holds many, and the
ordinary shape is one per deployable thing rather than one per project.
namestringIdentifierprojects/{project}/locations/{location}/repositories/{repository}.
The last segment is what appears in an image reference, so it is
constrained by the OCI specification rather than by AIP-122 alone: lower
case, digits, and ., _ or - between them. So is the project id, for
the same reason and with no way to fix it afterwards — see
CreateRepositoryRequest.repository_id.
uidstringOutput onlyServer-assigned, immutable, unique for the life of the platform.
Reused rather than the name wherever something has to outlive a rename.
display_namestringWhat a person calls it. Free text; not part of any reference.
labelsmap<string, string>Arbitrary key/value pairs, for the customer's own accounting.
STATE_ACTIVESTATE_DELETINGimmutable_tagsboolRefuse a push that would move a tag that already exists.
Off by default, because :latest moving is what most people mean by a tag.
On, a tag is a permanent name for one digest — which is what a deployment
that has to be reproducible needs, and what makes "the same tag" a fact
rather than a hope.
Changing it does not rewrite history: tags already moved stay where they are, and the guarantee starts now.
size_bytesint64Output onlyTotal bytes attributable to this repository.
An attribution rather than a count of unique bytes. Layers are shared, so pushing a common base image counts your reference to it — and your figure never changes because somebody else pushed the same layer.
image_countint64Output onlyHow many manifests are in it, tagged or not.
When something was last written here, and last read.
last_pull_time is the one worth having: it is how a customer finds the
repository nothing has fetched since March, and it is the only signal that
separates storage somebody is paying for on purpose from storage they
forgot about.
What the platform is doing with this repository.
| Value | Description |
|---|---|
| STATE_UNSPECIFIED | |
| STATE_ACTIVE | Accepting pushes and serving pulls. |
| STATE_DELETING | The row is gone from every list and the blobs are being swept. A delete is not instant: the manifests are unreachable immediately, and reclaiming the storage behind them is a separate pass that has to run when nothing is writing. A repository in this state is still billed for what it still occupies, which is the honest answer rather than the flattering one. |