New components cache management.

The cache is no longer generated at runtime when the application runs -
which may take quite a while, especially on fresh installations.

A `components.json.gz` file is instead generated by the CI/CD process on
every commit to the main branch, and distributed with the package.

The application will read this file when the `inspect` plugin is first
initialized.

This also means that we can no longer cache the `install_cmds`, as they
change depending on the target OS. A new
`application.get_install_commands` action has now been made available to
the UI, so it can get the correct installation commands at runtime.
This commit is contained in:
Fabio Manganiello 2024-01-06 22:21:59 +01:00
parent ef885e096f
commit 6e1ab92298
Signed by: blacklight
GPG key ID: D90FBA7F76362774
9 changed files with 203 additions and 54 deletions

View file

@ -62,7 +62,8 @@ steps:
# Backup the original git configuration before changing attributes # Backup the original git configuration before changing attributes
- export GIT_CONF=$PWD/.git/config - export GIT_CONF=$PWD/.git/config
- cp $GIT_CONF /tmp/git.config.orig - export TMP_GIT_CONF=/tmp/git.config.orig
- cp $GIT_CONF $TMP_GIT_CONF
- git config --global --add safe.directory $PWD - git config --global --add safe.directory $PWD
# Install the SSH and PGP keys # Install the SSH and PGP keys
@ -95,7 +96,7 @@ steps:
- git checkout master - git checkout master
# Restore the original git configuration # Restore the original git configuration
- mv /tmp/git.config.orig $GIT_CONF - mv $TMP_GIT_CONF $GIT_CONF
when: when:
event: event:
@ -187,7 +188,8 @@ steps:
# Backup the original git configuration before changing attributes # Backup the original git configuration before changing attributes
- export GIT_CONF=$PWD/.git/config - export GIT_CONF=$PWD/.git/config
- cp $GIT_CONF /tmp/git.config.orig - export TMP_GIT_CONF=/tmp/git.config.orig
- cp $GIT_CONF $TMP_GIT_CONF
- git config --global --add safe.directory $PWD - git config --global --add safe.directory $PWD
- cd platypush/backend/http/webapp - cd platypush/backend/http/webapp
@ -242,7 +244,93 @@ steps:
- git push -f origin master - git push -f origin master
# Restore the original git configuration # Restore the original git configuration
- mv /tmp/git.config.orig $GIT_CONF - mv $TMP_GIT_CONF $GIT_CONF
###
### Regenerate the components.json cache
###
- name: update-components-cache
image: alpine
environment:
SSH_PUBKEY:
from_secret: ssh_pubkey
SSH_PRIVKEY:
from_secret: ssh_privkey
PGP_KEY:
from_secret: pgp_key
PGP_KEY_ID:
from_secret: pgp_key_id
when:
branch:
- master
event:
- push
depends_on:
- build-ui
commands:
- export SKIPCI="$PWD/.skipci"
- export CACHEFILE="$PWD/platypush/components.json.gz"
- |
[ -f "$SKIPCI" ] && exit 0
- apk add --update --no-cache $(cat platypush/install/requirements/alpine.txt)
- pip install . --break-system-packages
- |
python - <<EOF
from platypush import get_plugin
get_plugin('inspect').refresh_cache(force=True)
EOF
- |
if [ -z "$(git status "$CACHEFILE" --porcelain)" ]; then
echo 'No changes to the components file'
exit 0
fi
# Backup the original git configuration before changing attributes
- apk add --update --no-cache git
- export GIT_CONF=$PWD/.git/config
- export TMP_GIT_CONF=/tmp/git.config.orig
- cp $GIT_CONF $TMP_GIT_CONF
- git config --global --add safe.directory $PWD
# Create a .skipci file to mark the fact that the next steps should be skipped
# (we're going to do another push anyway, so another pipeline will be triggered)
- touch "$SKIPCI"
- apk add --update --no-cache bash gnupg openssh
- mkdir -p ~/.ssh
- |
cat <<EOF | gpg --import --armor
$PGP_KEY
EOF
- echo $SSH_PUBKEY > ~/.ssh/id_rsa.pub
- |
cat <<EOF > ~/.ssh/id_rsa
$SSH_PRIVKEY
EOF
- chmod 0600 ~/.ssh/id_rsa
- ssh-keyscan git.platypush.tech >> ~/.ssh/known_hosts 2>/dev/null
- git config user.name "Platypush CI/CD Automation"
- git config user.email "admin@platypush.tech"
- git config commit.gpgsign true
- git config user.signingkey $PGP_KEY_ID
- git add "$CACHEFILE"
- git commit dist -S -m "[Automatic] Updated components cache" --no-verify
- git remote rm origin
- git remote add origin git@git.platypush.tech:platypush/platypush.git
- git push -f origin master
# Restore the original git configuration
- mv $TMP_GIT_CONF $GIT_CONF
### ###
### Update the Arch packages ### Update the Arch packages
@ -264,7 +352,7 @@ steps:
- push - push
depends_on: depends_on:
- build-ui - update-components-cache
commands: commands:
- | - |
@ -364,7 +452,7 @@ steps:
- push - push
depends_on: depends_on:
- build-ui - update-components-cache
commands: commands:
- | - |
@ -463,7 +551,7 @@ steps:
- push - push
depends_on: depends_on:
- build-ui - update-components-cache
commands: commands:
- | - |
@ -774,7 +862,7 @@ steps:
- push - push
depends_on: depends_on:
- build-ui - update-components-cache
commands: commands:
- | - |

View file

@ -3,3 +3,4 @@ recursive-include platypush/install *
include platypush/plugins/http/webpage/mercury-parser.js include platypush/plugins/http/webpage/mercury-parser.js
include platypush/config/*.yaml include platypush/config/*.yaml
global-include manifest.yaml global-include manifest.yaml
global-include components.json.gz

View file

@ -7,11 +7,11 @@
<div class="body"> <div class="body">
<div class="container install-cmd-container"> <div class="container install-cmd-container">
<CopyButton :text="installCmd" /> <CopyButton :text="installCmd" v-if="installCmd" />
<pre><code v-html="highlightedInstallCmd" /></pre> <pre><Loading v-if="loading" /><code v-html="highlightedInstallCmd" v-else /></pre>
</div> </div>
<div class="buttons install-btn" v-if="installCmd?.length"> <div class="buttons install-btn" v-if="installCmd">
<RestartButton v-if="installDone" /> <RestartButton v-if="installDone" />
<button type="button" <button type="button"
class="btn btn-default" class="btn btn-default"
@ -73,22 +73,26 @@ export default {
installRunning: false, installRunning: false,
installDone: false, installDone: false,
installOutput: null, installOutput: null,
installCmds: [],
pendingCommands: 0, pendingCommands: 0,
error: null, error: null,
loading: false,
} }
}, },
computed: { computed: {
installCmd() { installCmd() {
const cmd = this.extension.deps.install_cmd.join('\n').trim() if (this.installCmds.length)
return cmd?.length ? cmd : null return this.installCmds.join('\n').trim()
return null
}, },
highlightedInstallCmd() { highlightedInstallCmd() {
return ( return (
hljs.highlight( hljs.highlight(
this.installCmd ? this.installCmd ?
this.extension.deps.install_cmd this.installCmds
.map((cmd) => `$ ${cmd}`) .map((cmd) => `$ ${cmd}`)
.join('\n') .join('\n')
.trim() : .trim() :
@ -159,12 +163,15 @@ export default {
}, },
installExtension() { installExtension() {
if (!this.installCmd)
return
this.error = null this.error = null
this.installRunning = true this.installRunning = true
this.installOutput = '' this.installOutput = ''
this.$emit('install-start', this.extension) this.$emit('install-start', this.extension)
const cmd = (this.extension.deps.install_cmd || []).join(';\n') const cmd = this.installCmds.join(';\n')
this.request('shell.exec', { this.request('shell.exec', {
cmd: cmd, cmd: cmd,
ws: true, ws: true,
@ -176,9 +183,25 @@ export default {
this.$emit('install-end', this.extension) this.$emit('install-end', this.extension)
}) })
}, },
async refreshInstallCmds() {
this.loading = true
try {
this.installCmds = await this.request('application.get_install_commands', {
extension: this.extension.name,
})
} finally {
this.loading = false
}
},
}, },
mounted() { mounted() {
this.refreshInstallCmds()
this.$watch('extension.name', () => {
this.refreshInstallCmds()
})
this.$watch('installOutput', () => { this.$watch('installOutput', () => {
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.installOutput.focus() this.$refs.installOutput.focus()

Binary file not shown.

View file

@ -1,7 +1,8 @@
import inspect import inspect
import pathlib import pathlib
import subprocess import subprocess
from typing import Optional from functools import lru_cache
from typing import List, Optional
from platypush.commands import CommandStream, RestartCommand, StopCommand from platypush.commands import CommandStream, RestartCommand, StopCommand
from platypush.common.db import override_definitions from platypush.common.db import override_definitions
@ -48,6 +49,29 @@ class ApplicationPlugin(Plugin):
will be prefixed by ``backend.`` (e.g. ``backend.http`` or will be prefixed by ``backend.`` (e.g. ``backend.http`` or
``backend.tcp``). ``backend.tcp``).
""" """
install_cmds = self._get_install_cmds(extension)
if not install_cmds:
self.logger.info('No extra requirements found for extension %s', extension)
return
for cmd in install_cmds:
self.logger.info('> %s', cmd)
subprocess.check_call(cmd, shell=True, stderr=subprocess.STDOUT)
@action
def get_install_commands(self, extension: str) -> List[str]:
"""
Get the installation commands for an extension.
:param extension: Extension name. For plugins, it will be the plugin
name (e.g. ``light.hue`` or ``music.mpd``); for backend, the name
will be prefixed by ``backend.`` (e.g. ``backend.http`` or
``backend.tcp``).
"""
return self._get_install_cmds(extension)
@lru_cache(maxsize=256) # noqa
def _get_install_cmds(self, extension: str) -> List[str]:
getter = get_plugin_class_by_name getter = get_plugin_class_by_name
if extension.startswith('backend.'): if extension.startswith('backend.'):
extension = extension[len('backend.') :] extension = extension[len('backend.') :]
@ -58,14 +82,4 @@ class ApplicationPlugin(Plugin):
assert ext, f'Could not find extension {extension}' assert ext, f'Could not find extension {extension}'
manifest_file = str(pathlib.Path(inspect.getfile(ext)).parent / 'manifest.yaml') manifest_file = str(pathlib.Path(inspect.getfile(ext)).parent / 'manifest.yaml')
install_cmds = list( return list(Manifest.from_file(manifest_file).install.to_install_commands())
Manifest.from_file(manifest_file).install.to_install_commands()
)
if not install_cmds:
self.logger.info('No extra requirements found for extension %s', extension)
return
for cmd in install_cmds:
self.logger.info('> %s', cmd)
subprocess.check_call(cmd, shell=True, stderr=subprocess.STDOUT)

View file

@ -33,34 +33,45 @@ class InspectPlugin(Plugin):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self._cache_file = os.path.join(Config.get_cachedir(), 'components.json')
self._cache = Cache() self._cache = Cache()
self._load_cache() self._load_cache()
@property
def cache_file(self) -> str:
"""
:return: The path to the components cache file.
"""
import platypush
return os.path.join(
os.path.dirname(inspect.getfile(platypush)),
'components.json.gz',
)
def _load_cache(self): def _load_cache(self):
""" """
Loads the components cache from disk. Loads the components cache from disk.
""" """
with self._cache.lock(), auto_mocks(), override_definitions(): with self._cache.lock(), auto_mocks(), override_definitions():
try: try:
self._cache = Cache.load(self._cache_file) self._cache = Cache.load(self.cache_file)
except Exception as e: except Exception as e:
self.logger.warning( self.logger.warning(
'Could not initialize the components cache from %s: %s', 'Could not initialize the components cache from %s: %s',
self._cache_file, self.cache_file,
e, e,
) )
self._cache = Cache()
self._refresh_cache() def refresh_cache(self, force: bool = False):
def _refresh_cache(self):
""" """
Refreshes the components cache. Refreshes the components cache.
""" """
cache_version_differs = self._cache.version != Cache.cur_version cache_version_differs = self._cache.version != Cache.cur_version
force = force or cache_version_differs
with ThreadPoolExecutor(self._num_workers) as pool: with self._cache.lock(), auto_mocks(), override_definitions(), ThreadPoolExecutor(
self._num_workers
) as pool:
futures = [] futures = []
for base_type in [Plugin, Backend]: for base_type in [Plugin, Backend]:
@ -69,7 +80,7 @@ class InspectPlugin(Plugin):
self._scan_integrations, self._scan_integrations,
base_type, base_type,
pool=pool, pool=pool,
force_refresh=cache_version_differs, force_refresh=force,
futures=futures, futures=futures,
) )
) )
@ -80,7 +91,7 @@ class InspectPlugin(Plugin):
self._scan_modules, self._scan_modules,
base_type, base_type,
pool=pool, pool=pool,
force_refresh=cache_version_differs, force_refresh=force,
futures=futures, futures=futures,
) )
) )
@ -89,10 +100,12 @@ class InspectPlugin(Plugin):
futures.pop().result() futures.pop().result()
if self._cache.has_changes: if self._cache.has_changes:
self.logger.info('Saving new components cache to %s', self._cache_file) self.logger.info('Saving new components cache to %s', self.cache_file)
self._cache.dump(self._cache_file) self._cache.dump(self.cache_file)
self._cache.loaded_at = self._cache.saved_at self._cache.loaded_at = self._cache.saved_at
return self._cache
def _scan_integration(self, manifest: Manifest): def _scan_integration(self, manifest: Manifest):
""" """
Scans a single integration from the manifest and adds it to the cache. Scans a single integration from the manifest and adds it to the cache.
@ -188,9 +201,11 @@ class InspectPlugin(Plugin):
""" """
:return: True if the given file needs to be refreshed in the cache. :return: True if the given file needs to be refreshed in the cache.
""" """
return os.lstat(os.path.dirname(filename)).st_mtime > ( dirname = os.path.dirname(filename)
self._cache.saved_at or 0 if not os.path.isdir(dirname):
) return True
return os.lstat(dirname).st_mtime > (self._cache.saved_at or 0)
@staticmethod @staticmethod
def _module_filename(path: str, modname: str) -> str: def _module_filename(path: str, modname: str) -> str:

View file

@ -1,4 +1,5 @@
from contextlib import contextmanager from contextlib import contextmanager
import gzip
import json import json
import logging import logging
from collections import defaultdict from collections import defaultdict
@ -69,7 +70,7 @@ class Cache:
""" """
cur_version = 1 cur_version = 1.1
""" """
Cache version, used to detect breaking changes in the cache logic that require a cache refresh. Cache version, used to detect breaking changes in the cache logic that require a cache refresh.
""" """
@ -79,7 +80,7 @@ class Cache:
items: Optional[Dict[type, Dict[type, dict]]] = None, items: Optional[Dict[type, Dict[type, dict]]] = None,
saved_at: Optional[float] = None, saved_at: Optional[float] = None,
loaded_at: Optional[float] = None, loaded_at: Optional[float] = None,
version: int = cur_version, version: float = cur_version,
): ):
self.saved_at = saved_at self.saved_at = saved_at
self.loaded_at = loaded_at self.loaded_at = loaded_at
@ -99,9 +100,10 @@ class Cache:
:param cache_file: Cache file path. :param cache_file: Cache file path.
""" """
with open(cache_file, 'r') as f: with gzip.open(cache_file, 'rb') as f:
data = json.load(f) data = f.read()
return cls.from_dict(data)
return cls.from_dict(json.loads(data.decode()))
def dump(self, cache_file: str): def dump(self, cache_file: str):
""" """
@ -112,18 +114,20 @@ class Cache:
from platypush.message import Message from platypush.message import Message
self.version = self.cur_version self.version = self.cur_version
self.saved_at = time()
with open(cache_file, 'w') as f: compressed_cache = gzip.compress(
self.saved_at = time() json.dumps(
json.dump(
{ {
'saved_at': self.saved_at, 'saved_at': self.saved_at,
'version': self.version, 'version': self.version,
'items': self.to_dict(), 'items': self.to_dict(),
}, },
f,
cls=Message.Encoder, cls=Message.Encoder,
) ).encode()
)
with open(cache_file, 'wb') as f:
f.write(compressed_cache)
self.has_changes = False self.has_changes = False

View file

@ -279,7 +279,10 @@ class Dependencies:
'packages': list(self.packages), 'packages': list(self.packages),
'pip': self.pip, 'pip': self.pip,
'after': self.after, 'after': self.after,
'install_cmd': list(self.to_install_commands()), 'by_pkg_manager': {
pkg_manager.value.executable: list(pkgs)
for pkg_manager, pkgs in self.by_pkg_manager.items()
},
} }
@property @property

View file

@ -48,6 +48,7 @@ setup(
'install/scripts/**/*', 'install/scripts/**/*',
'install/requirements/*', 'install/requirements/*',
'install/docker/*', 'install/docker/*',
'components.json.gz',
], ],
}, },
entry_points={ entry_points={