Skip to content

Commit de6925e

Browse files
Sync Nextcloud releases with GitHub (#544)
* add command for fetching nextcloud releases from github * simplify loop * pull out client * update allauth * add test for parsing github json * add docs, finish command * fix style * remove extra file * delete file * narrow down fixtures * update changelog * update changelog
1 parent 4ca424d commit de6925e

File tree

17 files changed

+884
-5
lines changed

17 files changed

+884
-5
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ venv
1515
.idea/tasks.xml
1616
.idea/codeStyleSettings.xml
1717
.idea/inspectionProfiles
18+
.idea/codeStyles
1819
nextcloudappstore/local_settings.py
1920
*.sqlite3
2021
*.wp[ru]

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
# Changelog
22

3-
## [Unreleased]
3+
## [3.2.0] - 2018-02-04
4+
5+
### Added
6+
7+
- **syncnextcloudreleases** to sync Nextcloud releases directly from GitHub
8+
9+
### Removed
10+
11+
- Nextcloud releases are not imported via fixtures anymore, use the **syncnextcloudreleases** command
12+
13+
### Security
14+
15+
- Narrow down fixtures to not accidentally import test data on production systems. Check if a user with the user name **admin** was created. If so delete that user from your system.
416

517
## [3.1.2] - 2018-02-02
618

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ endif
5858
.PHONY: initdb
5959
initdb:
6060
$(manage) migrate --settings nextcloudappstore.settings.development
61-
$(manage) loaddata $(CURDIR)/nextcloudappstore/**/fixtures/*.json --settings nextcloudappstore.settings.development
61+
$(manage) loaddata $(CURDIR)/nextcloudappstore/core/fixtures/*.json --settings nextcloudappstore.settings.development
6262
$(manage) createsuperuser --username admin --email admin@admin.com --noinput --settings nextcloudappstore.settings.development
6363
$(manage) verifyemail --username admin --email admin@admin.com --settings nextcloudappstore.settings.development
6464
$(manage) setdefaultadminpassword --settings nextcloudappstore.settings.development

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Look here if you want to install the store on your server and keep it up to date
4242

4343
prodinstall
4444
prodinstalldocker
45+
upgradenotices
4546

4647
App Store Developer Documentation
4748
---------------------------------

docs/prodinstall.rst

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ Loading Initial Data
231231
--------------------
232232
To pre-populate the database with categories and other data run the following command::
233233

234-
python manage.py loaddata nextcloudappstore/**/fixtures/*.json
234+
python manage.py loaddata nextcloudappstore/core/fixtures/*.json
235235

236236
Initializing Translations
237237
-------------------------
@@ -365,6 +365,47 @@ Afterwards your **client id** and **client secret** are displayed. These need to
365365

366366
.. note:: For local testing use localhost:8000 as domain name. Furthermore the confirmation mail will also be printed in your shell that was used to start the development server.
367367

368+
369+
.. _prod_install_release_sync:
370+
371+
Sync Nextcloud Releases from GitHub
372+
-----------------------------------
373+
374+
The App Store needs to know about Nextcloud versions because:
375+
376+
* app releases are grouped by app version on the app detail page
377+
* you can :ref:`access a REST API to get all available versions <api-all-platforms>`
378+
379+
Before **3.2.0** releases were imported either manually or via the a shipped JSON file. This process proved to be very tedious. In **3.2.0** a command was introduced to sync releases (git tags) directly from GitHub.
380+
381+
You can run the command by giving it the oldest supported Nextcloud version::
382+
383+
python manage.py syncnextcloudreleases --oldest-supported="12.0.0"
384+
385+
All existing versions prior to this release will be marked as not having a release, new versions will be imported and the latest version will be marked as current version.
386+
387+
You can also do a test run and see what kind of versions would be imported::
388+
389+
python manage.py syncnextcloudreleases --oldest-supported="12.0.0" --print
390+
391+
The GitHub API is rate limited to 60 requests per second. Depending on how far back your **oldest-supported** version goes a single command might fetch multiple pages of releases. If you want to run the command more than 10 times per hour it is recommended to `obtain and configure a GitHub OAuth2 token <https://help.github.com/articles/git-automation-with-oauth-tokens/>`_.
392+
393+
After obtaining the token from GitHub, add it anywhere in your settings file (**nextcloudappstore/settings/production.py**), e.g.:
394+
395+
.. code-block:: python
396+
397+
GITHUB_API_TOKEN = '4bab6b3dfeds8857371a48855d3e87d38d4b7e65'
398+
399+
To automate syncing you might want to add the command as a cronjob and schedule it every hour.
400+
401+
.. note:: Only one sync command should be run at a time, otherwise race conditions might cause unpredictable results. To ensure this use a proper cronjob daemon that supports running only one command at a time, for instance `SystemD timers <https://wiki.archlinux.org/index.php/Systemd/Timers>`_
402+
403+
.. note:: If run the command outside of your virtual environment you need to prefix the full path to the desired Python executable, e.g.
404+
405+
::
406+
407+
venv/bin/python manage.py syncnextcloudreleases --oldest-supported="12.0.0"
408+
368409
Keeping Up To Date
369410
------------------
370411
Updating an instance is scripted in **scripts/maintenance/update.sh**. Depending on your distribution you will have to adjust the scripts contents.

docs/prodinstalldocker.rst

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,39 @@ Afterwards your **client id** and **client secret** are displayed. These need to
388388
.. note:: The above mentioned domains need to be changed if you want to run the App Store on a different server.
389389

390390
.. note:: For local testing use localhost:8000 as domain name. Furthermore the confirmation mail will also be printed in your shell that was used to start the development server.
391+
392+
393+
.. _prod_install_release_sync_docker:
394+
395+
Sync Nextcloud Releases from GitHub
396+
-----------------------------------
397+
398+
The App Store needs to know about Nextcloud versions because:
399+
400+
* app releases are grouped by app version on the app detail page
401+
* you can :ref:`access a REST API to get all available versions <api-all-platforms>`
402+
403+
Before **3.2.0** releases were imported either manually or via the a shipped JSON file. This process proved to be very tedious. In **3.2.0** a command was introduced to sync releases (git tags) directly from GitHub.
404+
405+
You can run the command by giving it the oldest supported Nextcloud version::
406+
407+
sudo docker-compose exec python manage.py syncnextcloudreleases --oldest-supported="12.0.0"
408+
409+
All existing versions prior to this release will be marked as not having a release, new versions will be imported and the latest version will be marked as current version.
410+
411+
You can also do a test run and see what kind of versions would be imported::
412+
413+
sudo docker-compose exec python manage.py syncnextcloudreleases --oldest-supported="12.0.0" --print
414+
415+
The GitHub API is rate limited to 60 requests per second. Depending on how far back your **oldest-supported** version goes a single command might fetch multiple pages of releases. If you want to run the command more than 10 times per hour it is recommended to `obtain and configure a GitHub OAuth2 token <https://help.github.com/articles/git-automation-with-oauth-tokens/>`_.
416+
417+
After obtaining the token from GitHub, add it anywhere in your settings file (**production.py**), e.g.:
418+
419+
.. code-block:: python
420+
421+
GITHUB_API_TOKEN = '4bab6b3dfeds8857371a48855d3e87d38d4b7e65'
422+
423+
To automate syncing you might want to add the command as a cronjob and schedule it every hour.
424+
425+
.. note:: Only one sync command should be run at a time, otherwise race conditions might cause unpredictable results. To ensure this use a proper cronjob daemon that supports running only one command at a time, for instance `SystemD timers <https://wiki.archlinux.org/index.php/Systemd/Timers>`_
426+

docs/upgradenotices.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
=====================
2+
Store Upgrade Notices
3+
=====================
4+
5+
3.2.0
6+
=====
7+
8+
Configuring Nextcloud Release Sync
9+
----------------------------------
10+
11+
**3.2.0** changed the way Nextcloud releases are managed in the App Store. Instead of manually adjusting releases in the admin interface or importing updated fixtures via JSON files releases are now synced directly from GitHub.
12+
13+
Consult :ref:`prod_install_release_sync` or if you are using Docker :ref:`prod_install_release_sync_docker` in order to run or configure the release sync command.
14+
15+

nextcloudappstore/core/github.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from itertools import chain, takewhile
2+
from typing import Iterable, List
3+
4+
import requests
5+
from semantic_version import Version
6+
7+
from nextcloudappstore.core.models import NextcloudRelease
8+
9+
10+
class GitHubClient:
11+
def __init__(self, base_url: str, api_token: str = None) -> None:
12+
self.base_url = base_url.rstrip('/')
13+
self.api_token = api_token
14+
self.headers = None if self.api_token else {
15+
'Authorization': 'token %s' % self.api_token
16+
}
17+
18+
def get_tags(self, page: int, size: int = 100):
19+
url = '%s/repos/nextcloud/server/tags' % self.base_url
20+
params = (('per_page', size), ('page', page))
21+
response = requests.get(url, params=params, headers=self.headers)
22+
response.raise_for_status()
23+
return response.json()
24+
25+
26+
def sync_releases(versions: Iterable[str]) -> None:
27+
"""
28+
All given versions have a release. If a release is absent, persisted
29+
releases are out of date and need to have their release flag removed.
30+
Finally the latest version must be marked as current.
31+
:param versions: an iterable yielding all retrieved GitHub tags
32+
:return:
33+
"""
34+
current_releases = NextcloudRelease.objects.all()
35+
imported_releases = [
36+
NextcloudRelease.objects.get_or_create(version=version)[0]
37+
for version in versions
38+
]
39+
if imported_releases:
40+
# all imported releases have a release, existing ones don't
41+
for release in imported_releases:
42+
release.has_release = True
43+
release.save()
44+
for release in get_old_releases(current_releases, imported_releases):
45+
release.has_release = False
46+
release.save()
47+
# set latest release
48+
NextcloudRelease.objects.update(is_current=False)
49+
latest = max(imported_releases, key=lambda v: Version(v.version))
50+
latest.is_current = True
51+
latest.save()
52+
53+
54+
NextcloudReleases = List[NextcloudRelease]
55+
56+
57+
def get_old_releases(current: NextcloudReleases,
58+
imported: NextcloudReleases) -> NextcloudReleases:
59+
imported_versions = {release.version for release in imported}
60+
return [release for release in current
61+
if release.version not in imported_versions]
62+
63+
64+
def get_supported_releases(client: GitHubClient,
65+
oldest_supported: str) -> Iterable[str]:
66+
releases = get_stable_releases(client)
67+
return takewhile(lambda v: is_supported(v, oldest_supported), releases)
68+
69+
70+
def get_stable_releases(client: GitHubClient) -> Iterable[str]:
71+
json = chain.from_iterable(TagPages(client))
72+
return (tag for tag in (release['name'].lstrip('v')
73+
for release in json
74+
if 'name' in release)
75+
if is_stable(tag))
76+
77+
78+
def is_supported(oldest_supported: str, version: str) -> bool:
79+
return Version(oldest_supported) >= Version(version)
80+
81+
82+
def is_stable(release: str) -> bool:
83+
try:
84+
version = Version(release)
85+
return not version.prerelease
86+
except ValueError:
87+
return False
88+
89+
90+
class TagPages:
91+
"""
92+
The GitHub API is paginated which makes it a pain to fetch all results.
93+
This iterable returns a stream of json arrays until no further results
94+
are found. To iterate over all releases you need to flatten the results
95+
returned from this iterator first
96+
"""
97+
98+
def __init__(self, client: GitHubClient, size: int = 100) -> None:
99+
self.client = client
100+
self.size = size
101+
self.page = 1 # pages are 1 indexed
102+
103+
def __iter__(self) -> 'TagPages':
104+
return self
105+
106+
def __next__(self):
107+
json = self.client.get_tags(self.page, self.size)
108+
if len(json) > 0:
109+
self.page += 1
110+
return json
111+
else:
112+
raise StopIteration
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import requests
2+
from django.conf import settings
3+
from django.core.management import BaseCommand
4+
from django.core.management import CommandError
5+
6+
from nextcloudappstore.core.github import get_supported_releases, \
7+
GitHubClient, sync_releases
8+
9+
10+
class Command(BaseCommand):
11+
help = 'Queries Nextcloud\'s GitHub releases API to update all locally ' \
12+
'stored Nextcloud versions'
13+
14+
def add_arguments(self, parser):
15+
parser.add_argument('--print', required=False, action='store_true',
16+
help='Prints to stdout instead of importing '
17+
'releases into the database')
18+
parser.add_argument('--oldest-supported', required=True,
19+
help='Oldest supported Nextcloud version')
20+
21+
def handle(self, *args, **options):
22+
oldest_supported = options.get('oldest_supported')
23+
token = settings.GITHUB_API_TOKEN
24+
base_url = settings.GITHUB_API_BASE_URL
25+
client = GitHubClient(base_url, token)
26+
27+
try:
28+
releases = get_supported_releases(client, oldest_supported)
29+
except requests.HTTPError as e:
30+
raise CommandError('Could not get releases: ' + str(e))
31+
32+
if options['print']:
33+
for release in releases:
34+
self.stdout.write(release)
35+
else:
36+
sync_releases(releases)

0 commit comments

Comments
 (0)