tests
exposed as a standalone Dagger function so it can be invoked individuallyduring TDD; All wires them up for parallel execution under `dagger check`.
Installation
dagger install github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4afEntrypoint
Return Type
Tests Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
func (m *MyModule) Example() *dagger.Tests {
return dag.
Tests()
}@function
def example() -> dagger.Tests:
return (
dag.tests()
)@func()
example(): Tests {
return dag
.tests()
}Types
Tests 🔗
all() 🔗
All runs every oci test. parallel caps concurrency; it defaults to 0
(unbounded fan-out — GH Actions schedules each dagger check job onto its
own runner, so in-runner parallelism is bounded by the VM).
Return Type
Void !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| parallel | Integer ! | 0 | No description provided |
Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
all --parallel integerfunc (m *MyModule) Example(ctx context.Context, parallel int) {
return dag.
Tests().
All(ctx, parallel)
}@function
async def example(parallel: int) -> None:
return await (
dag.tests()
.all(parallel)
)@func()
async example(parallel: number): Promise<void> {
return dag
.tests()
.all(parallel)
}annotationsSurvivePush() 🔗
AnnotationsSurvivePush asserts that an annotation set on the container with WithAnnotation is readable through Manifest after the push. Annotations are how provenance, source links and SBOM pointers travel with an image, and a push that quietly drops them is indistinguishable from one that works.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
annotations-survive-pushfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Annotationssurvivepush(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.annotationssurvivepush()
)@func()
async example(): Promise<void> {
return dag
.tests()
.annotationsSurvivePush()
}anonymousAccessNeedsNoCredentials() 🔗
AnonymousAccessNeedsNoCredentials asserts the bottom of the order still works, in the two shapes it comes in: no credentials at all, and a docker config that simply says nothing about this host.
The second shape is the one that breaks by accident. A developer’s config carries a credsStore for Docker Desktop and entries for two or three registries; reading it as “this file governs every host” would make every public pull fail with an unrunnable-helper error. It has to mean “nothing here is about that registry”, and fall through to anonymous.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
anonymous-access-needs-no-credentialsfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Anonymousaccessneedsnocredentials(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.anonymousaccessneedsnocredentials()
)@func()
async example(): Promise<void> {
return dag
.tests()
.anonymousAccessNeedsNoCredentials()
}attachFailsForUnknownSubject() 🔗
AttachFailsForUnknownSubject asserts that attaching to a manifest that is not in the repository fails, and that the error names the subject digest. Without the up-front resolve the registry would accept the referrer and leave it dangling, which nothing downstream can detect.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
attach-fails-for-unknown-subjectfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Attachfailsforunknownsubject(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.attachfailsforunknownsubject()
)@func()
async example(): Promise<void> {
return dag
.tests()
.attachFailsForUnknownSubject()
}attachSucceedsWhereManifestDeleteIsUnsupported() 🔗
AttachSucceedsWhereManifestDeleteIsUnsupported asserts that attaching a second referrer to one subject works on a registry that serves no referrers API and refuses to delete a manifest — which is GHCR.
Both halves are needed to reproduce it, and the test checks it is really getting both before it attaches anything. Without the referrers API oras falls back to the referrers tag schema, where the second attachment replaces the index the first one wrote; oras then deletes the index it replaced, the registry answers 405, and the whole push fails after the referrer and the updated index have already landed. The module skips that collection, so this is one dangling index and no error.
The rest of the referrer tests run against zot and are evidence about the native path. This is the only one that exercises the path GHCR takes.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
attach-succeeds-where-manifest-delete-is-unsupportedfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Attachsucceedswheremanifestdeleteisunsupported(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.attachsucceedswheremanifestdeleteisunsupported()
)@func()
async example(): Promise<void> {
return dag
.tests()
.attachSucceedsWhereManifestDeleteIsUnsupported()
}attachThenFetchRoundTripsContent() 🔗
AttachThenFetchRoundTripsContent asserts an attached file’s bytes survive the round trip through the referrers API.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
attach-then-fetch-round-trips-contentfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Attachthenfetchroundtripscontent(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.attachthenfetchroundtripscontent()
)@func()
async example(): Promise<void> {
return dag
.tests()
.attachThenFetchRoundTripsContent()
}authenticatesFromDockerConfig() 🔗
AuthenticatesFromDockerConfig asserts a caller can hand over a
~/.docker/config.json and have this module find the host’s credentials in
it — no username, no password, just the file a docker login already
wrote.
Both client libraries are exercised: PushImage goes through go-containerregistry and Resolve through oras, and the credential resolution feeding them is shared. A test using only one would leave the other silently anonymous.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
authenticates-from-docker-configfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Authenticatesfromdockerconfig(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.authenticatesfromdockerconfig()
)@func()
async example(): Promise<void> {
return dag
.tests()
.authenticatesFromDockerConfig()
}authenticatesWithBearerToken() 🔗
AuthenticatesWithBearerToken asserts a token supplied on its own reaches the registry as an Authorization: Bearer header, and that a wrong one is refused without either token appearing in the error.
The registry behind the gate is anonymous, so nothing here can pass by accident on some other credential: the only thing separating the two halves of this test is the token.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
authenticates-with-bearer-tokenfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Authenticateswithbearertoken(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.authenticateswithbearertoken()
)@func()
async example(): Promise<void> {
return dag
.tests()
.authenticatesWithBearerToken()
}authenticatesWithClientCertificate() 🔗
AuthenticatesWithClientCertificate asserts that a client certificate and its key reach a registry that demands mutual TLS, and that one signed by another authority is refused with an error that names the failure and carries no key material.
The registry has no password authentication at all, so nothing here can pass on some other credential: the certificate is the only thing separating the two halves of this test.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
authenticates-with-client-certificatefunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Authenticateswithclientcertificate(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.authenticateswithclientcertificate()
)@func()
async example(): Promise<void> {
return dag
.tests()
.authenticatesWithClientCertificate()
}clientCertificateNeedsBothHalves() 🔗
ClientCertificateNeedsBothHalves asserts that half a client certificate is refused, and that the refusal names the half that is missing.
The alternative — falling back to anonymous TLS — is the failure this guards: a caller who believed they were authenticating would discover otherwise from a 401 much later, in a message that says nothing about the certificate they thought they had supplied.
No registry is needed. The halves are checked while the connection is being resolved, before an address is dialled, which is exactly where a misconfiguration of the call should be caught.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
client-certificate-needs-both-halvesfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Clientcertificateneedsbothhalves(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.clientcertificateneedsbothhalves()
)@func()
async example(): Promise<void> {
return dag
.tests()
.clientCertificateNeedsBothHalves()
}copyPreservesAllManifests() 🔗
CopyPreservesAllManifests asserts that copying a multi-platform image keeps every platform. skopeo needed –all for this; a copy that silently reduced a manifest list to the running platform would break every non-amd64 consumer of a copied image.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
copy-preserves-all-manifestsfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Copypreservesallmanifests(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.copypreservesallmanifests()
)@func()
async example(): Promise<void> {
return dag
.tests()
.copyPreservesAllManifests()
}dockerConfigCredentialHelperIsNotSupported() 🔗
DockerConfigCredentialHelperIsNotSupported asserts that a config resolving this host through a credential helper fails, and that the failure names the helper binary it asked for.
Helpers are not honoured and cannot be: one is an external docker-credential-* binary the Docker CLI executes, and the module runtime holds no gcloud, no ecr-login and no keychain. The alternative to failing is falling through to anonymous, which turns “your credential lives somewhere I cannot reach” into an unrelated 401 from the registry — a failure whose cause is invisible in the message.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
docker-config-credential-helper-is-not-supportedfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Dockerconfigcredentialhelperisnotsupported(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.dockerconfigcredentialhelperisnotsupported()
)@func()
async example(): Promise<void> {
return dag
.tests()
.dockerConfigCredentialHelperIsNotSupported()
}dockerConfigCredentialsDoNotLeak() 🔗
DockerConfigCredentialsDoNotLeak asserts that a docker config carrying the wrong password fails with a 401 whose text holds neither that password nor the base64 blob it was packed into.
The blob matters as much as the password. It is base64, not encryption, so
a auth value in a CI log is a password in a CI log — and it is the form
the credential actually travels in, which makes it the one a client library
echoing its own request would print.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
docker-config-credentials-do-not-leakfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Dockerconfigcredentialsdonotleak(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.dockerconfigcredentialsdonotleak()
)@func()
async example(): Promise<void> {
return dag
.tests()
.dockerConfigCredentialsDoNotLeak()
}insecureStaysIndependentOfCertificates() 🔗
InsecureStaysIndependentOfCertificates asserts that insecure and the TLS material do not imply anything about each other.
VerifiesAgainstPrivateCa covers the direction that matters most — a CA does not switch verification off. This covers the other one: a caller who has asked for plain HTTP still gets it with a CA supplied beside it. A module that treated the two as one setting would fail one of these two tests whichever way it resolved them.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
insecure-stays-independent-of-certificatesfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Insecurestaysindependentofcertificates(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.insecurestaysindependentofcertificates()
)@func()
async example(): Promise<void> {
return dag
.tests()
.insecureStaysIndependentOfCertificates()
}passwordBeatsTokenAndDockerConfig() 🔗
PasswordBeatsTokenAndDockerConfig pins the top of the precedence order: a username and password win over a bearer token and over a docker config, both of which are wrong here and would fail the push if either were used.
Precedence has to be tested from the winning side. A test that supplied only correct credentials would pass whichever source the module happened to read, and the bug this guards against — a later source quietly overwriting an earlier one — would be invisible.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
password-beats-token-and-docker-configfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Passwordbeatstokenanddockerconfig(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.passwordbeatstokenanddockerconfig()
)@func()
async example(): Promise<void> {
return dag
.tests()
.passwordBeatsTokenAndDockerConfig()
}pushArtifactThenFetchRoundTripsContent() 🔗
PushArtifactThenFetchRoundTripsContent asserts that the bytes of a file in a pushed artifact come back identical: one layer per file, the file’s own bytes, no archive wrapper the caller did not ask for.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
push-artifact-then-fetch-round-trips-contentfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Pushartifactthenfetchroundtripscontent(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.pushartifactthenfetchroundtripscontent()
)@func()
async example(): Promise<void> {
return dag
.tests()
.pushArtifactThenFetchRoundTripsContent()
}pushFailsAgainstPlaintextRegistryByDefault() 🔗
PushFailsAgainstPlaintextRegistryByDefault asserts a client that was not told to accept plain HTTP refuses to push to one.
This is the behaviour the old inline skopeo path did not have: it inferred “skip TLS verification” from a test-only registry service being present, so a production push over a hijacked plaintext connection would have gone through silently.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
push-fails-against-plaintext-registry-by-defaultfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Pushfailsagainstplaintextregistrybydefault(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.pushfailsagainstplaintextregistrybydefault()
)@func()
async example(): Promise<void> {
return dag
.tests()
.pushFailsAgainstPlaintextRegistryByDefault()
}pushFailsWithBadCredentials() 🔗
PushFailsWithBadCredentials asserts the registry’s 401 reaches the caller as an error, and that neither the wrong password nor the right one appears in its text.
An error crossing the Dagger boundary is rendered into a trace and a CI log, both of which outlive the run. A client library that echoed its request would put the credential in both, so the module scrubs the password out and this is what holds it to that.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
push-fails-with-bad-credentialsfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Pushfailswithbadcredentials(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.pushfailswithbadcredentials()
)@func()
async example(): Promise<void> {
return dag
.tests()
.pushFailsWithBadCredentials()
}pushImageIsNotCached() 🔗
PushImageIsNotCached asserts a second push really uploads.
The two pushes have identical content and therefore identical digests, so the returned value cannot tell a real upload from a replayed one. Deleting the manifest between them is what makes the difference observable: after the delete the tag is gone, and only a push that actually reached the registry can bring it back.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
push-image-is-not-cachedfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Pushimageisnotcached(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.pushimageisnotcached()
)@func()
async example(): Promise<void> {
return dag
.tests()
.pushImageIsNotCached()
}pushImagePushesAllVariants() 🔗
PushImagePushesAllVariants asserts that more than one variant becomes one manifest list naming every platform, rather than the last push winning the tag.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
push-image-pushes-all-variantsfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Pushimagepushesallvariants(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.pushimagepushesallvariants()
)@func()
async example(): Promise<void> {
return dag
.tests()
.pushImagePushesAllVariants()
}pushSucceedsAgainstPlaintextRegistryWhenInsecure() 🔗
PushSucceedsAgainstPlaintextRegistryWhenInsecure is the other half: the same registry, the same image, one explicit opt-in.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
push-succeeds-against-plaintext-registry-when-insecurefunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Pushsucceedsagainstplaintextregistrywheninsecure(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.pushsucceedsagainstplaintextregistrywheninsecure()
)@func()
async example(): Promise<void> {
return dag
.tests()
.pushSucceedsAgainstPlaintextRegistryWhenInsecure()
}referrersFiltersByArtifactType() 🔗
ReferrersFiltersByArtifactType asserts the artifactType filter narrows the listing to one type. oras applies the filter client-side when the registry does not report having applied it, so this holds either way.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
referrers-filters-by-artifact-typefunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Referrersfiltersbyartifacttype(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.referrersfiltersbyartifacttype()
)@func()
async example(): Promise<void> {
return dag
.tests()
.referrersFiltersByArtifactType()
}referrersListsAttachedArtifacts() 🔗
ReferrersListsAttachedArtifacts asserts that both artifacts attached to a subject come back from Referrers, and — first — that the registry answering is serving the native OCI 1.1 referrers API.
That second assertion is the point of the test. oras falls back to the tag schema against a registry without /v2//referrers/, so without it a green run would not say which of the two paths it exercised. The fallback is GHCR’s path and has a test of its own, AttachSucceedsWhereManifestDeleteIsUnsupported.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
referrers-lists-attached-artifactsfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Referrerslistsattachedartifacts(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.referrerslistsattachedartifacts()
)@func()
async example(): Promise<void> {
return dag
.tests()
.referrersListsAttachedArtifacts()
}resolveFailsForMissingTag() 🔗
ResolveFailsForMissingTag asserts that resolving a tag nothing was ever pushed to fails, and that the error names the tag — an error that only says “not found” leaves the caller guessing which of its references was wrong.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
resolve-fails-for-missing-tagfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Resolvefailsformissingtag(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.resolvefailsformissingtag()
)@func()
async example(): Promise<void> {
return dag
.tests()
.resolveFailsForMissingTag()
}resolveIsNotCached() 🔗
ResolveIsNotCached asserts that Resolve reports what the registry holds now, not what it held the first time it was asked.
Registry state is mutable and Dagger caches function results for a week by default, so without a never-cache directive the second Resolve would replay the first one’s answer — and every caller reading a moving tag would act on a digest that had already been superseded.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
resolve-is-not-cachedfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Resolveisnotcached(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.resolveisnotcached()
)@func()
async example(): Promise<void> {
return dag
.tests()
.resolveIsNotCached()
}tokenBeatsDockerConfig() 🔗
TokenBeatsDockerConfig pins the middle rung: with no username or password, a bearer token is used and the docker config beside it is not.
The config names the same host and holds credentials the gate would refuse, so a module that preferred the file — or that fell back to it after the token — would fail here rather than pass quietly.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
token-beats-docker-configfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Tokenbeatsdockerconfig(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.tokenbeatsdockerconfig()
)@func()
async example(): Promise<void> {
return dag
.tests()
.tokenBeatsDockerConfig()
}verifiesAgainstPrivateCa() 🔗
VerifiesAgainstPrivateCa asserts that a registry fronted by a private CA is reachable by naming that CA, with verification still on and insecure unset — and that the CA is what made the difference.
The three handles are the whole point. Only the first is expected to work; the second shows verification was never off, and the third shows that supplying a CA does not switch verification off either, which is the failure mode a trust anchor implemented as a flag would have. Without them a green first handle would be equally consistent with a module that had quietly stopped verifying.
Both client libraries are exercised: PushImage runs through go-containerregistry and Resolve through oras, and the two build their transports separately.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/oci/tests@1605a0beb62ff451135cd7c694d889ecc414c4af call \
verifies-against-private-cafunc (m *MyModule) Example(ctx context.Context) {
return dag.
Tests().
Verifiesagainstprivateca(ctx)
}@function
async def example() -> None:
return await (
dag.tests()
.verifiesagainstprivateca()
)@func()
async example(): Promise<void> {
return dag
.tests()
.verifiesAgainstPrivateCa()
}