launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #25969
[Merge] ~twom/launchpad:oci-policy-use-distribution-credentials-in-upload into launchpad:master
Tom Wardill has proposed merging ~twom/launchpad:oci-policy-use-distribution-credentials-in-upload into launchpad:master with ~twom/launchpad:oci-policy-distribute-the-credentials as a prerequisite.
Commit message:
Use distribution credentials in OCI upload
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/395984
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:oci-policy-use-distribution-credentials-in-upload into launchpad:master.
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index f18779d..1e04c97 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -4,6 +4,8 @@
"""OCI recipe views."""
from __future__ import absolute_import, print_function, unicode_literals
+import ipdb
+
__metaclass__ = type
__all__ = [
@@ -739,6 +741,15 @@ class OCIRecipeFormMixin:
build_args[k] = v
data['build_args'] = build_args
+ @property
+ def use_distribution_credentials(self):
+ if hasattr(self.context, 'oci_project'):
+ project = self.context.oci_project
+ else:
+ project = self.context
+ distro = project.distribution
+ return bool(distro and distro.oci_registry_credentials)
+
class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
OCIRecipeFormMixin):
@@ -772,6 +783,14 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
"The architectures that this OCI recipe builds for. Some "
"architectures are restricted and may only be enabled or "
"disabled by administrators.")
+ if self.use_distribution_credentials:
+ self.form_fields += FormFields(TextLine(
+ __name__='image_name',
+ title=u"Image name",
+ description=(
+ "Name to use for registry upload. "
+ "Defaults to the name of the recipe."),
+ required=False, readonly=False))
def setUpWidgets(self):
"""See `LaunchpadFormView`."""
@@ -816,7 +835,9 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
oci_project=self.context, git_ref=data["git_ref"],
build_file=data["build_file"], description=data["description"],
build_daily=data["build_daily"], build_args=data["build_args"],
- build_path=data["build_path"], processors=data["processors"])
+ build_path=data["build_path"], processors=data["processors"],
+ # image_name is only available if using distribution credentials.
+ image_name=data.get("image_name"))
self.next_url = canonical_url(recipe)
@@ -888,6 +909,14 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin,
"The architectures that this OCI recipe builds for. Some "
"architectures are restricted and may only be enabled or "
"disabled by administrators.")
+ if self.use_distribution_credentials:
+ self.form_fields += FormFields(TextLine(
+ __name__='image_name',
+ title=u"Image name",
+ description=(
+ "Name to use for registry upload. "
+ "Defaults to the name of the recipe."),
+ required=False, readonly=False))
def validate(self, data):
"""See `LaunchpadFormView`."""
@@ -924,6 +953,7 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin,
data["processors"].append(processor)
self.validateBuildArgs(data)
+
class OCIRecipeDeleteView(BaseOCIRecipeEditView):
"""View for deleting OCI recipes."""
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index e22665d..ce20835 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -6,6 +6,9 @@
from __future__ import absolute_import, print_function, unicode_literals
+from lp.soyuz.browser.archive import DistributionArchiveURL
+
+
__metaclass__ = type
from datetime import (
@@ -243,6 +246,29 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
"Build-time\nARG variables:\nVAR1=10\nVAR2=20",
MatchesTagText(content, "build-args"))
+ def test_create_new_recipe_with_image_name(self):
+ oci_project = self.factory.makeOCIProject()
+ credentials = self.factory.makeOCIRegistryCredentials()
+ with person_logged_in(oci_project.distribution.owner):
+ oci_project.distribution.oci_registry_credentials = credentials
+ [git_ref] = self.factory.makeGitRefs()
+ browser = self.getViewBrowser(
+ oci_project, view_name="+new-recipe", user=self.person)
+ browser.getControl(name="field.name").value = "recipe-name"
+ browser.getControl("Description").value = "Recipe description"
+ browser.getControl(name="field.git_ref.repository").value = (
+ git_ref.repository.identity)
+ browser.getControl(name="field.git_ref.path").value = git_ref.path
+
+ image_name = self.factory.getUniqueUnicode()
+ browser.getControl(name="field.image_name").value = image_name
+ browser.getControl("Create OCI recipe").click()
+
+ content = find_main_content(browser.contents)
+ self.assertThat(
+ "Registry image name\n{}".format(image_name),
+ MatchesTagText(content, "image-name"))
+
def test_create_new_recipe_users_teams_as_owner_options(self):
# Teams that the user is in are options for the OCI recipe owner.
self.factory.makeTeam(
@@ -539,6 +565,25 @@ class TestOCIRecipeEditView(OCIConfigHelperMixin, BaseTestOCIRecipeView):
IStore(recipe).reload(recipe)
self.assertEqual({"VAR1": "xxx", "VAR2": "uu"}, recipe.build_args)
+ def test_edit_image_name(self):
+ self.setUpDistroSeries()
+ credentials = self.factory.makeOCIRegistryCredentials()
+ with person_logged_in(self.distribution.owner):
+ self.distribution.oci_registry_credentials = credentials
+ oci_project = self.factory.makeOCIProject(pillar=self.distribution)
+ recipe = self.factory.makeOCIRecipe(
+ registrant=self.person, owner=self.person, oci_project=oci_project)
+ browser = self.getViewBrowser(
+ recipe, view_name="+edit", user=recipe.owner)
+ image_name = self.factory.getUniqueUnicode()
+ field = browser.getControl(name="field.image_name")
+ field.value = image_name
+ browser.getControl("Update OCI recipe").click()
+ content = find_main_content(browser.contents)
+ self.assertThat(
+ "Registry image name\n{}".format(image_name),
+ MatchesTagText(content, "image-name"))
+
def test_edit_with_invisible_processor(self):
# It's possible for existing recipes to have an enabled processor
# that's no longer usable with the current distroseries, which will
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index 1f7a6c7..86c235b 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -155,6 +155,18 @@
interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleSet"/>
</securedutility>
+ <!-- OCIDistributionPushRule -->
+ <class class="lp.oci.model.ocipushrule.OCIDistributionPushRule">
+ <require
+ permission="launchpad.View"
+ interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleView
+ lp.oci.interfaces.ocipushrule.IOCIPushRuleEditableAttributes" />
+ <require
+ permission="launchpad.Edit"
+ interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleEdit"
+ set_schema="lp.oci.interfaces.ocipushrule.IOCIPushRuleEditableAttributes" />
+ </class>
+
<!-- OCI related jobs -->
<securedutility
component="lp.oci.model.ocirecipebuildjob.OCIRegistryUploadJob"
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index 131c4d3..51cf370 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -280,6 +280,11 @@ class IOCIRecipeView(Interface):
"Whether everything is set up to allow uploading builds of "
"this OCI recipe to a registry."))
+ use_distribution_credentials = Bool(
+ title=_("Use Distribution credentials"), required=True, readonly=True,
+ description=_("Use the credentials on a Distribution for "
+ "registry upload"))
+
def requestBuild(requester, architecture):
"""Request that the OCI recipe is built.
@@ -434,6 +439,13 @@ class IOCIRecipeEditableAttributes(IHasOwner):
description=_("If True, this recipe should be built daily."),
readonly=False))
+ image_name = exported(TextLine(
+ title=_("Image name"),
+ description=_("Image name to use on upload to registry. "
+ "Defaults to recipe name if not set."),
+ required=False,
+ readonly=False))
+
class IOCIRecipeAdminAttributes(Interface):
"""`IOCIRecipe` attributes that can be edited by admins.
diff --git a/lib/lp/oci/model/ocipushrule.py b/lib/lp/oci/model/ocipushrule.py
index 24d5e27..f7736e6 100644
--- a/lib/lp/oci/model/ocipushrule.py
+++ b/lib/lp/oci/model/ocipushrule.py
@@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = [
+ 'OCIDistributionPushRule',
'OCIPushRule',
'OCIPushRuleSet',
]
@@ -72,6 +73,27 @@ class OCIPushRule(Storm):
IStore(OCIPushRule).remove(self)
+@implementer(IOCIPushRule)
+class OCIDistributionPushRule:
+ """A none-database instance that is synthesised from data elsewhere."""
+
+ registry_credentials = None
+
+ def __init__(self, recipe, registry_credentials, image_name):
+ self.id = -1
+ self.recipe = recipe
+ self.registry_credentials = registry_credentials
+ self.image_name = image_name
+
+ @property
+ def registry_url(self):
+ return self.registry_credentials.url
+
+ @property
+ def username(self):
+ return self.registry_credentials.username
+
+
@implementer(IOCIPushRuleSet)
class OCIPushRuleSet:
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index ab7f655..34fd348 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -74,7 +74,10 @@ from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
from lp.oci.interfaces.ociregistrycredentials import (
IOCIRegistryCredentialsSet,
)
-from lp.oci.model.ocipushrule import OCIPushRule
+from lp.oci.model.ocipushrule import (
+ OCIDistributionPushRule,
+ OCIPushRule,
+ )
from lp.oci.model.ocirecipebuild import OCIRecipeBuild
from lp.oci.model.ocirecipejob import OCIRecipeJob
from lp.registry.interfaces.distribution import IDistributionSet
@@ -160,10 +163,13 @@ class OCIRecipe(Storm, WebhookTargetMixin):
build_daily = Bool(name="build_daily", default=False)
+ _image_name = Unicode(name="image_name", allow_none=True)
+
def __init__(self, name, registrant, owner, oci_project, git_ref,
description=None, official=False, require_virtualized=True,
build_file=None, build_daily=False, date_created=DEFAULT,
- allow_internet=True, build_args=None, build_path=None):
+ allow_internet=True, build_args=None, build_path=None,
+ image_name=None):
if not getFeatureFlag(OCI_RECIPE_ALLOW_CREATE):
raise OCIRecipeFeatureDisabled()
super(OCIRecipe, self).__init__()
@@ -182,6 +188,7 @@ class OCIRecipe(Storm, WebhookTargetMixin):
self.allow_internet = allow_internet
self.build_args = build_args or {}
self.build_path = build_path
+ self.image_name = image_name
def __repr__(self):
return "<OCIRecipe ~%s/%s/+oci/%s/+recipe/%s>" % (
@@ -463,10 +470,18 @@ class OCIRecipe(Storm, WebhookTargetMixin):
@property
def push_rules(self):
+ # if we're in a distribution that has credentials set at that level
+ # create a push rule using those credentials
+ if self.use_distribution_credentials:
+ push_rule = OCIDistributionPushRule(
+ self,
+ self.oci_project.distribution.oci_registry_credentials,
+ self.image_name)
+ return [push_rule]
rules = IStore(self).find(
OCIPushRule,
OCIPushRule.recipe == self.id)
- return rules
+ return list(rules)
@property
def _pending_states(self):
@@ -527,7 +542,20 @@ class OCIRecipe(Storm, WebhookTargetMixin):
@property
def can_upload_to_registry(self):
- return not self.push_rules.is_empty()
+ return bool(self.push_rules)
+
+ @property
+ def use_distribution_credentials(self):
+ distribution = self.oci_project.distribution
+ return distribution and distribution.oci_registry_credentials
+
+ @property
+ def image_name(self):
+ return self._image_name or self.name
+
+ @image_name.setter
+ def image_name(self, value):
+ self._image_name = value
def newPushRule(self, registrant, registry_url, image_name, credentials,
credentials_owner=None):
@@ -569,7 +597,8 @@ class OCIRecipeSet:
def new(self, name, registrant, owner, oci_project, git_ref, build_file,
description=None, official=False, require_virtualized=True,
build_daily=False, processors=None, date_created=DEFAULT,
- allow_internet=True, build_args=None, build_path=None):
+ allow_internet=True, build_args=None, build_path=None,
+ image_name=None):
"""See `IOCIRecipeSet`."""
if not registrant.inTeam(owner):
if owner.is_team:
@@ -594,7 +623,7 @@ class OCIRecipeSet:
oci_recipe = OCIRecipe(
name, registrant, owner, oci_project, git_ref, description,
official, require_virtualized, build_file, build_daily,
- date_created, allow_internet, build_args, build_path)
+ date_created, allow_internet, build_args, build_path, image_name)
store.add(oci_recipe)
if processors is None:
diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
index 9a9aefc..662c742 100644
--- a/lib/lp/oci/templates/ocirecipe-index.pt
+++ b/lib/lp/oci/templates/ocirecipe-index.pt
@@ -81,6 +81,12 @@
<pre tal:content="build_args" />
</dd>
</dl>
+ <dl id="image-name" tal:condition="context/use_distribution_credentials">
+ <dt>Registry image name</dt>
+ <dd>
+ <span tal:content="context/image_name" />
+ </dd>
+ </dl>
</div>
<h2>Latest builds</h2>
@@ -144,35 +150,42 @@
</div>
- <h2>Recipe push rules</h2>
- <table id="push-rules-listing" tal:condition="view/has_push_rules" class="listing"
- style="margin-bottom: 1em; ">
- <thead>
- <tr>
- <th>Registry URL</th>
- <th>Username</th>
- <th>Image Name</th>
- </tr>
- </thead>
- <tbody>
- <tal:recipe-push-rules repeat="item view/push_rules">
- <tr tal:define="rule item;
- show_credentials rule/registry_credentials/required:launchpad.View"
- tal:attributes="id string:rule-${rule/id}">
- <td tal:content="python: rule.registry_credentials.url if show_credentials else ''"/>
- <td tal:content="python: rule.registry_credentials.username if show_credentials else ''"/>
- <td tal:content="rule/image_name"/>
+ <div tal:condition=context/use_distribution_credentials>
+ <h3>Registry upload</h3>
+ <p>This recipe will use the registry credentials set by the parent distribution</p>
+ </div>
+
+ <div tal:condition="not: context/use_distribution_credentials">
+ <h2>Recipe push rules</h2>
+ <table id="push-rules-listing" tal:condition="view/has_push_rules" class="listing"
+ style="margin-bottom: 1em; ">
+ <thead>
+ <tr>
+ <th>Registry URL</th>
+ <th>Username</th>
+ <th>Image Name</th>
</tr>
- </tal:recipe-push-rules>
- </tbody>
- </table>
- <p tal:condition="not: view/has_push_rules">
- This OCI recipe has no push rules defined yet.
- </p>
+ </thead>
+ <tbody>
+ <tal:recipe-push-rules repeat="item view/push_rules">
+ <tr tal:define="rule item;
+ show_credentials rule/registry_credentials/required:launchpad.View"
+ tal:attributes="id string:rule-${rule/id}">
+ <td tal:content="python: rule.registry_credentials.url if show_credentials else ''"/>
+ <td tal:content="python: rule.registry_credentials.username if show_credentials else ''"/>
+ <td tal:content="rule/image_name"/>
+ </tr>
+ </tal:recipe-push-rules>
+ </tbody>
+ </table>
+ <p tal:condition="not: view/has_push_rules">
+ This OCI recipe has no push rules defined yet.
+ </p>
- <div tal:define="link context/menu:context/edit_push_rules"
- tal:condition="link/enabled">
- <tal:edit-push-rules replace="structure link/fmt:link"/>
+ <div tal:define="link context/menu:context/edit_push_rules"
+ tal:condition="link/enabled">
+ <tal:edit-push-rules replace="structure link/fmt:link"/>
+ </div>
</div>
</div>
diff --git a/lib/lp/oci/templates/ocirecipe-new.pt b/lib/lp/oci/templates/ocirecipe-new.pt
index 1cae71e..00d9b08 100644
--- a/lib/lp/oci/templates/ocirecipe-new.pt
+++ b/lib/lp/oci/templates/ocirecipe-new.pt
@@ -41,6 +41,11 @@
<tal:widget define="widget nocall:view/widgets/processors">
<metal:block use-macro="context/@@launchpad_form/widget_row" />
</tal:widget>
+ <span tal:condition="view/use_distribution_credentials">
+ <tal:widget define="widget nocall:view/widgets/image_name" >
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ </span>
</table>
</metal:formbody>
</div>
diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
index a0b1c59..699954a 100644
--- a/lib/lp/oci/tests/test_ocirecipe.py
+++ b/lib/lp/oci/tests/test_ocirecipe.py
@@ -749,6 +749,36 @@ class TestOCIRecipe(OCIConfigHelperMixin, TestCaseWithFactory):
"VAR3": "A string",
}, recipe.build_args)
+ def test_use_distribution_credentials_set(self):
+ distribution = self.factory.makeDistribution()
+ credentials = self.factory.makeOCIRegistryCredentials()
+ with person_logged_in(distribution.owner):
+ distribution.oci_registry_credentials = credentials
+ project = self.factory.makeOCIProject(pillar=distribution)
+ recipe = self.factory.makeOCIRecipe(oci_project=project)
+ self.assertTrue(recipe.use_distribution_credentials)
+
+ def test_use_distribution_credentials_not_set(self):
+ distribution = self.factory.makeDistribution()
+ project = self.factory.makeOCIProject(pillar=distribution)
+ recipe = self.factory.makeOCIRecipe(oci_project=project)
+ self.assertFalse(recipe.use_distribution_credentials)
+
+ def test_image_name_set(self):
+ distribution = self.factory.makeDistribution()
+ project = self.factory.makeOCIProject(pillar=distribution)
+ recipe = self.factory.makeOCIRecipe(oci_project=project)
+ image_name = self.factory.getUniqueUnicode()
+ with person_logged_in(recipe.owner):
+ recipe.image_name = image_name
+ self.assertEqual(image_name, removeSecurityProxy(recipe)._image_name)
+
+ def test_image_name_not_set(self):
+ distribution = self.factory.makeDistribution()
+ project = self.factory.makeOCIProject(pillar=distribution)
+ recipe = self.factory.makeOCIRecipe(oci_project=project)
+ self.assertEqual(recipe.name, recipe.image_name)
+
class TestOCIRecipeProcessors(TestCaseWithFactory):
@@ -1284,6 +1314,29 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
self.assertEqual(
image_name, push_rules["entries"][0]["image_name"])
+ def test_api_set_image_name(self):
+ """Can you set and retrieve the image name via the API?"""
+ self.setConfig()
+
+ image_name = self.factory.getUniqueUnicode()
+
+ with person_logged_in(self.person):
+ oci_project = self.factory.makeOCIProject(
+ registrant=self.person)
+ recipe = self.factory.makeOCIRecipe(
+ oci_project=oci_project, owner=self.person,
+ registrant=self.person)
+ url = api_url(recipe)
+
+ resp = self.webservice.patch(
+ url, 'application/json',
+ json.dumps({'image_name': image_name}))
+
+ self.assertEqual(209, resp.status, resp.body)
+
+ ws_project = self.load_from_api(url)
+ self.assertEqual(image_name, ws_project['image_name'])
+
class TestOCIRecipeAsyncWebservice(TestCaseWithFactory):
layer = LaunchpadFunctionalLayer
diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
index ecfaee3..403662d 100644
--- a/lib/lp/oci/tests/test_ociregistryclient.py
+++ b/lib/lp/oci/tests/test_ociregistryclient.py
@@ -69,6 +69,7 @@ from lp.services.compat import mock
from lp.services.features.testing import FeatureFixture
from lp.testing import (
admin_logged_in,
+ person_logged_in,
TestCaseWithFactory,
)
from lp.testing.fixture import ZopeUtilityFixture
@@ -226,6 +227,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
}))
@responses.activate
+<<<<<<< lib/lp/oci/tests/test_ociregistryclient.py
def test_upload_ignores_superseded_builds(self):
self.build.updateStatus(BuildStatus.FULLYBUILT)
recipe = self.build.recipe
@@ -249,6 +251,56 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
OCIRecipeBuildRegistryUploadStatus.SUPERSEDED,
self.build.registry_upload_status)
self.assertEqual(0, len(responses.calls))
+=======
+ def test_upload_with_distribution_credentials(self):
+ self._makeFiles()
+ self.useFixture(MockPatch(
+ "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))
+ self.useFixture(MockPatch(
+ "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer",
+ return_value=999))
+ credentials = self.factory.makeOCIRegistryCredentials()
+ image_name = self.factory.getUniqueUnicode()
+ self.build.recipe.image_name = image_name
+ distro = self.build.recipe.oci_project.distribution
+ with person_logged_in(distro.owner):
+ distro.oci_registry_credentials = credentials
+ # we have distribution credentials, we should have a 'push rule'
+ push_rule = self.build.recipe.push_rules[0]
+ responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200)
+ self.addManifestResponses(push_rule)
+
+ self.client.upload(self.build)
+
+ request = json.loads(responses.calls[1].request.body)
+
+ self.assertThat(request, MatchesDict({
+ "layers": MatchesListwise([
+ MatchesDict({
+ "mediaType": Equals(
+ "application/vnd.docker.image.rootfs.diff.tar.gzip"),
+ "digest": Equals("diff_id_1"),
+ "size": Equals(999)}),
+ MatchesDict({
+ "mediaType": Equals(
+ "application/vnd.docker.image.rootfs.diff.tar.gzip"),
+ "digest": Equals("diff_id_2"),
+ "size": Equals(999)})
+ ]),
+ "schemaVersion": Equals(2),
+ "config": MatchesDict({
+ "mediaType": Equals(
+ "application/vnd.docker.container.image.v1+json"),
+ "digest": Equals(
+ "sha256:33b69b4b6e106f9fc7a8b93409"
+ "36c85cf7f84b2d017e7b55bee6ab214761f6ab"),
+ "size": Equals(52)
+ }),
+ "mediaType": Equals(
+ "application/vnd.docker.distribution.manifest.v2+json")
+ }))
+
+>>>>>>> lib/lp/oci/tests/test_ociregistryclient.py
@responses.activate
def test_upload_formats_credentials(self):