diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 46f02cb..ee2f4fb 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -58,9 +58,10 @@ jobs: - name: Execute tests env: QUAY_IO_TOKEN: ${{ secrets.QUAY_IO_TOKEN }} + GEMFURY_API_TOKEN: ${{ secrets.GEMFURY_API_TOKEN }} run: | - pip install -U pip wheel setuptools + pip install -U pip wheel "setuptools<82" pip install -r devel.txt # report to Kiwi TCMS only if we have access to secrets @@ -104,7 +105,7 @@ jobs: - name: Install Python dependencies run: | - pip install -U pip wheel setuptools + pip install -U pip wheel "setuptools<82" pip install -r devel.txt - name: Login to Private Container Registry diff --git a/README.rst b/README.rst index b063c48..a9ad2cb 100644 --- a/README.rst +++ b/README.rst @@ -41,6 +41,7 @@ Configuration Required settings: +- ``GEMFURY_API_TOKEN`` - string - ``KIWI_GITHUB_PAT_FOR_CHECKING_ORGS_AND_USERNAMES`` - string - ``KIWI_GITHUB_MARKETPLACE_SECRET`` - binary string - ``KIWI_FASTSPRING_SECRET`` - binary string diff --git a/requirements.txt b/requirements.txt index 52a27b7..665c8ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +httplink==0.2.0 kiwitcms-tenants>=2.8.3 mailchimp3==3.0.21 requests diff --git a/tcms_github_marketplace/admin.py b/tcms_github_marketplace/admin.py index 3f97915..5dcb54b 100644 --- a/tcms_github_marketplace/admin.py +++ b/tcms_github_marketplace/admin.py @@ -12,7 +12,7 @@ from django.contrib import admin from django.http import HttpResponseForbidden, HttpResponseRedirect -from tcms_github_marketplace.models import ManualPurchase, Purchase +from tcms_github_marketplace.models import ManualPurchase, PrivateRepoToken, Purchase class PurchaseAdmin(admin.ModelAdmin): @@ -178,3 +178,4 @@ def response_add( admin.site.register(ManualPurchase, ManualPurchaseAdmin) admin.site.register(Purchase, PurchaseAdmin) +admin.site.register(PrivateRepoToken) diff --git a/tcms_github_marketplace/fury.py b/tcms_github_marketplace/fury.py new file mode 100644 index 0000000..84856d9 --- /dev/null +++ b/tcms_github_marketplace/fury.py @@ -0,0 +1,97 @@ +# Copyright (c) 2026 Alexander Todorov +# +# Licensed under GNU Affero General Public License v3 or later (AGPLv3+) +# https://www.gnu.org/licenses/agpl-3.0.html + +import requests +from requests.auth import AuthBase +from httplink import parse_link_header + + +class TokenAuth(AuthBase): + def __init__(self, token): + self.token = token + + def __eq__(self, other): + return self.token == getattr(other, "token", None) + + def __ne__(self, other): + return not self == other + + def __call__(self, r): + r.headers["Authorization"] = f"Bearer {self.token}" + return r + + +class GemfuryAPI: + base_url = "https://api.fury.io/1" + + def __init__(self, password=None): + """ + WARNING: we must be using the Full Access Token on the organization account! + """ + self.auth = TokenAuth(password) + + def find_token(self, subscription_id): + json_data, link = self._request("GET", "/tokens?kind_key=pull") + + while json_data: + for token in json_data: + if token.get("description") == subscription_id: + return token + + if link: + page = parse_link_header(link) + # keep asking for content until there's no-more + if "next" in page: + next_url = page["next"].target + json_data, link = self._request("GET", f"/tokens{next_url}") + else: + break + else: + break + + return None + + def create_token(self, subscription_id): + """ + Returns: + + { + 'token': { + 'id': 'tok_MBFav', + 'kind_key': 'pull' + }, + 'token_value': 'actual-value' + } + """ + json_response, _ = self._request( + "POST", f"/tokens?kind_key=pull&description={subscription_id}" + ) + return json_response + + def delete_token(self, subscription_id): + token = self.find_token(subscription_id) + + if token and token.get("id"): + token_id = token["id"] + # returns None, None + self._request("DELETE", f"/tokens/{token_id}") + + def _request(self, method, path, **kwargs): + """ + https://gemfury.com/guide/api/errors/ + """ + response = requests.request( + method, + f"{self.base_url}{path}", + auth=self.auth, + timeout=30, + **kwargs, + ) + + # Successful operation with no body + if response.status_code == 204: + return None, None + + return response.json(), response.headers.get("Link") diff --git a/tcms_github_marketplace/locale/en/LC_MESSAGES/django.po b/tcms_github_marketplace/locale/en/LC_MESSAGES/django.po index 5788eb6..506e137 100644 --- a/tcms_github_marketplace/locale/en/LC_MESSAGES/django.po +++ b/tcms_github_marketplace/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-12-14 11:48+0000\n" +"POT-Creation-Date: 2026-03-31 11:50+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -51,13 +51,13 @@ msgid "Owner" msgstr "" #: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:39 -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:140 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:112 #: tcms_github_marketplace/templates/tcms_tenants/override_new.html:38 msgid "Organization" msgstr "" #: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:52 -msgid "Private repository credentials" +msgid "Private credentials" msgstr "" #: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:57 @@ -69,73 +69,71 @@ msgid "Password" msgstr "" #: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:68 -msgid "Click 'Password' to reveal!" +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:81 +msgid "Click field to reveal!" msgstr "" #: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:70 -msgid "Private containers instructions" +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:83 +msgid "Instructions" msgstr "" -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:81 -msgid "kiwitcms/gitops prefix" +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:75 +msgid "Token" msgstr "" -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:102 -msgid "Save" -msgstr "" - -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:125 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:97 msgid "You own the following tenants" msgstr "" -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:147 -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:148 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:119 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:120 msgid "Price" msgstr "" -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:152 -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:153 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:124 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:125 msgid "Subscription type" msgstr "" -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:157 -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:158 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:129 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:130 #: tcms_github_marketplace/templates/tcms_tenants/override_new.html:16 msgid "Paid until" msgstr "" -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:164 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:136 msgid "Cancel subscription" msgstr "" -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:166 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:138 msgid "Cancel" msgstr "" -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:177 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:149 msgid "You don't own any tenants" msgstr "" -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:181 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:153 msgid "Subscribe via FastSpring" msgstr "" -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:197 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:169 msgid "Transaction history" msgstr "" -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:219 -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:220 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:191 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:192 msgid "Sender" msgstr "" -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:224 -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:225 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:196 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:197 msgid "Vendor" msgstr "" -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:229 -#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:230 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:201 +#: tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html:202 msgid "Received on" msgstr "" @@ -145,8 +143,8 @@ msgstr "" #: tcms_github_marketplace/templates/tcms_tenants/include/tenant_extra_emails.html:21 msgid "" -"Kiwi TCMS will try to match recurring billing events against tenant.owner." -"email + tenant.extra_emails" +"Kiwi TCMS will try to match recurring billing events against " +"tenant.owner.email + tenant.extra_emails" msgstr "" #: tcms_github_marketplace/templates/tcms_tenants/include/tenant_extra_emails.html:24 @@ -169,18 +167,18 @@ msgid "" "kiwitcms@mrsenko.com immediately!" msgstr "" -#: tcms_github_marketplace/utils.py:58 +#: tcms_github_marketplace/utils.py:64 msgid "Kiwi TCMS Subscription Exit Poll" msgstr "" -#: tcms_github_marketplace/views.py:617 +#: tcms_github_marketplace/views.py:628 msgid "Kiwi TCMS subscription notification" msgstr "" -#: tcms_github_marketplace/views.py:833 +#: tcms_github_marketplace/views.py:846 msgid "mo" msgstr "" -#: tcms_github_marketplace/views.py:835 +#: tcms_github_marketplace/views.py:848 msgid "yr" msgstr "" diff --git a/tcms_github_marketplace/migrations/0012_privaterepotoken.py b/tcms_github_marketplace/migrations/0012_privaterepotoken.py new file mode 100644 index 0000000..b146424 --- /dev/null +++ b/tcms_github_marketplace/migrations/0012_privaterepotoken.py @@ -0,0 +1,48 @@ +# pylint: disable=avoid-auto-field +# +# Copyright (c) 2026 Alexander Todorov +# +# Licensed under GNU Affero General Public License v3 or later (AGPLv3+) +# https://www.gnu.org/licenses/agpl-3.0.html + +import django.contrib.postgres.indexes +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tcms_github_marketplace", "0011_quay_accounts"), + ] + + operations = [ + migrations.CreateModel( + name="PrivateRepoToken", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("vendor", models.CharField(db_index=True, max_length=16)), + ( + "subscription", + models.CharField( + blank=True, db_index=True, max_length=32, null=True + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("payload", models.JSONField()), + ], + ), + migrations.AddIndex( + model_name="privaterepotoken", + index=django.contrib.postgres.indexes.GinIndex( + fastupdate=False, fields=["payload"], name="ghmp_privaterepotoken_gin" + ), + ), + ] diff --git a/tcms_github_marketplace/models.py b/tcms_github_marketplace/models.py index 06ee434..789b6de 100644 --- a/tcms_github_marketplace/models.py +++ b/tcms_github_marketplace/models.py @@ -130,3 +130,21 @@ def unit_count(self): return value return 0 + + +class PrivateRepoToken(models.Model): + vendor = models.CharField(max_length=16, db_index=True) + subscription = models.CharField(max_length=32, db_index=True, blank=True, null=True) + created_at = models.DateTimeField(db_index=True, auto_now_add=True) + payload = models.JSONField() + + class Meta: + indexes = [ + GinIndex( + fastupdate=False, fields=["payload"], name="ghmp_privaterepotoken_gin" + ), + ] + + @property + def token(self): + return self.payload["token_value"] diff --git a/tcms_github_marketplace/static/tcms_github_marketplace/js/subscription.js b/tcms_github_marketplace/static/tcms_github_marketplace/js/subscription.js index 7016073..60d3874 100644 --- a/tcms_github_marketplace/static/tcms_github_marketplace/js/subscription.js +++ b/tcms_github_marketplace/static/tcms_github_marketplace/js/subscription.js @@ -29,4 +29,15 @@ $(document).ready(function () { $('#docker_password').attr('type', 'password') } }) + + // toggle private repo password + $('#show-repo-token').click(function() { + var input_type = $('#repo_token').attr('type') + + if (input_type === 'password') { + $('#repo_token').attr('type', 'text') + } else { + $('#repo_token').attr('type', 'password') + } + }) }) diff --git a/tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html b/tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html index 8ae5dc4..bd0cde4 100644 --- a/tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html +++ b/tcms_github_marketplace/templates/tcms_github_marketplace/subscription.html @@ -49,7 +49,7 @@

- {% trans 'Private repository credentials' %} + {% trans 'Private credentials' %}

@@ -65,11 +65,24 @@

- {% trans "Click 'Password' to reveal!" %} + {% trans "Click field to reveal!" %} - {% trans 'Private containers instructions' %}: + {% trans 'Instructions' %}: https://kiwitcms.org/containers/

+ +
+ {% trans 'Token' %} + +
+ +

+ + {% trans "Click field to reveal!" %} + + {% trans 'Instructions' %}: + https://kiwitcms.org/packages/ +

diff --git a/tcms_github_marketplace/tests/test_view_subscription_plan.py b/tcms_github_marketplace/tests/test_view_subscription_plan.py index 6ec2730..c98a4ae 100644 --- a/tcms_github_marketplace/tests/test_view_subscription_plan.py +++ b/tcms_github_marketplace/tests/test_view_subscription_plan.py @@ -38,7 +38,7 @@ def assert_on_page(self, response): "You can access the following tenants", "You own the following tenants", "Transaction history", - "Private repository credentials", + "Private credentials", "Username", "Password", ): diff --git a/tcms_github_marketplace/utils.py b/tcms_github_marketplace/utils.py index e185225..ea36b70 100644 --- a/tcms_github_marketplace/utils.py +++ b/tcms_github_marketplace/utils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2025 Alexander Todorov +# Copyright (c) 2019-2026 Alexander Todorov # # Licensed under GNU Affero General Public License v3 or later (AGPLv3+) # https://www.gnu.org/licenses/agpl-3.0.html @@ -13,7 +13,8 @@ from django.utils.translation import gettext_lazy as _ from tcms.core.utils.mailto import mailto -from tcms_github_marketplace import docker +from tcms_github_marketplace import docker, fury +from tcms_github_marketplace.models import PrivateRepoToken def verify_hmac(request): @@ -51,6 +52,11 @@ def cancel_plan(purchase): except: # noqa:E722, pylint: disable=bare-except pass + try: + remove_repo_token(purchase.subscription) + except: # noqa:E722, pylint: disable=bare-except + pass + # send exit poll email mailto( template_name="tcms_github_marketplace/email/exit_poll.txt", @@ -98,3 +104,20 @@ def configure_product_access(quay_account, sku): for repo_name in sku.split("+"): if repo_name and not repo_name.startswith("x-"): quay_account.allow_read_access(repo_name) + + +def create_repo_token(subscription_id): + api = fury.GemfuryAPI(settings.GEMFURY_API_TOKEN) + + return PrivateRepoToken.objects.create( + vendor="gemfury", + subscription=subscription_id, + payload=api.create_token(subscription_id), + ) + + +def remove_repo_token(subscription_id): + PrivateRepoToken.objects.filter(subscription=subscription_id).delete() + + api = fury.GemfuryAPI(settings.GEMFURY_API_TOKEN) + api.delete_token(subscription_id) diff --git a/tcms_github_marketplace/views.py b/tcms_github_marketplace/views.py index 8597a0b..fbb1c83 100644 --- a/tcms_github_marketplace/views.py +++ b/tcms_github_marketplace/views.py @@ -27,7 +27,6 @@ from django_tenants.utils import get_public_schema_name from tcms_tenants.models import Tenant -from tcms_tenants.utils import create_user_account from tcms_tenants.views import NewTenantView from tcms_tenants import utils as tcms_tenants_utils @@ -37,7 +36,7 @@ from tcms_github_marketplace.github import find_sku as github_find_sku from tcms_github_marketplace import mailchimp from tcms_github_marketplace import utils -from tcms_github_marketplace.models import Purchase +from tcms_github_marketplace.models import PrivateRepoToken, Purchase UserModel = get_user_model() @@ -66,7 +65,7 @@ def create_user_account(self, email): Will create an account for whomever is purchasing this subscription! """ if not UserModel.objects.filter(email=email).first(): - create_user_account(email) + tcms_tenants_utils.create_user_account(email) def find_paid_tenant(self, purchase): # pylint: disable=unused-argument """ @@ -186,6 +185,9 @@ def post(self, request, *args, **kwargs): # pylint: disable=unused-argument account.create() utils.configure_product_access(account, sku) + # create private repository token + utils.create_repo_token(purchase.subscription) + # ask them to subscribe to newsletter mailchimp.subscribe(purchase.sender) @@ -812,6 +814,7 @@ def get_context_data(self, **kwargs): cancel_url = None quay_io_account = None + private_repo_token = None subscription_price = "-" subscription_period = "-" @@ -819,6 +822,11 @@ def get_context_data(self, **kwargs): if self.object is not None: subscription_price = "0" quay_io_account = docker.QuayIOAccount(self.object.subscription) + private_repo_token = ( + PrivateRepoToken.objects.filter(subscription=self.object.subscription) + .order_by("pk") + .last() + ) if self.object.vendor.lower() == "github": cancel_url = "https://github.com/settings/billing" @@ -854,6 +862,7 @@ def get_context_data(self, **kwargs): "subscription_period": subscription_period, "cancel_url": cancel_url, "quay_io_account": quay_io_account, + "private_repo_token": private_repo_token, } ) diff --git a/test_project/settings.py b/test_project/settings.py index e523204..2679615 100644 --- a/test_project/settings.py +++ b/test_project/settings.py @@ -93,6 +93,9 @@ # used for creating new accounts QUAY_IO_TOKEN = os.getenv("QUAY_IO_TOKEN") +# used for creating pull tokens +GEMFURY_API_TOKEN = os.getenv("GEMFURY_API_TOKEN") + # Allows us to hook-up kiwitcms-django-plugin at will TEST_RUNNER = os.environ.get("DJANGO_TEST_RUNNER", "django.test.runner.DiscoverRunner")