python_deploy: support for updating python apps
Signed-off-by: Eric Callahan <arksine.code@gmail.com>
This commit is contained in:
parent
17dc05c9b7
commit
b8921ca593
|
@ -0,0 +1,416 @@
|
||||||
|
# Python Package Update Deployment
|
||||||
|
#
|
||||||
|
# Copyright (C) 2024 Eric Callahan <arksine.code@gmail.com>
|
||||||
|
#
|
||||||
|
# This file may be distributed under the terms of the GNU GPLv3 license.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from enum import Enum
|
||||||
|
from ...utils.source_info import normalize_project_name, load_distribution_info
|
||||||
|
from ...utils.versions import PyVersion, GitVersion
|
||||||
|
from ...utils import pip_utils, json_wrapper
|
||||||
|
from .app_deploy import AppDeploy, Channel, DISTRO_ALIASES
|
||||||
|
|
||||||
|
# Annotation imports
|
||||||
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
Optional,
|
||||||
|
Dict,
|
||||||
|
List,
|
||||||
|
cast
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...confighelper import ConfigHelper
|
||||||
|
from ...utils.source_info import PackageInfo
|
||||||
|
from ...components.file_manager.file_manager import FileManager
|
||||||
|
from .update_manager import CommandHelper
|
||||||
|
|
||||||
|
class PackageSource(Enum):
|
||||||
|
PIP = 0
|
||||||
|
GITHUB = 1
|
||||||
|
UNKNOWN = 2
|
||||||
|
|
||||||
|
|
||||||
|
class PythonDeploy(AppDeploy):
|
||||||
|
def __init__(self, config: ConfigHelper, cmd_helper: CommandHelper) -> None:
|
||||||
|
super().__init__(config, cmd_helper, "Python Package")
|
||||||
|
self._configure_virtualenv(config)
|
||||||
|
if self.virtualenv is None:
|
||||||
|
raise config.error(
|
||||||
|
f"[{config.get_name()}]: Option 'virtualenv' must specify a valid "
|
||||||
|
"the path to a Python virtualenv"
|
||||||
|
)
|
||||||
|
fm: FileManager = self.server.lookup_component("file_manager")
|
||||||
|
fm.add_reserved_path(f"update_manager {self.name}", self.virtualenv)
|
||||||
|
self._configure_managed_services(config)
|
||||||
|
self.primary_branch = config.get("primary_branch", None)
|
||||||
|
self.project_name = config.get("project_name", self.name)
|
||||||
|
self.source: PackageSource = PackageSource.UNKNOWN
|
||||||
|
self.repo_url: str = ""
|
||||||
|
self.repo_owner: str = ""
|
||||||
|
self.repo_name: str = ""
|
||||||
|
self.current_version: PyVersion = PyVersion("?")
|
||||||
|
self.git_version: GitVersion = GitVersion("?")
|
||||||
|
self.current_sha: str = "?"
|
||||||
|
self.upstream_version: PyVersion = self.current_version
|
||||||
|
self.upstream_sha: str = "?"
|
||||||
|
self.rollback_ref: str = "?"
|
||||||
|
self.warnings: List[str] = []
|
||||||
|
package_info = load_distribution_info(self.virtualenv, self.project_name)
|
||||||
|
self._detect_update_source(package_info)
|
||||||
|
self._update_current_version(package_info)
|
||||||
|
self.changelog: str = self._get_url(["changelog"], package_info)
|
||||||
|
self.system_deps = self._parse_system_dependencies(package_info)
|
||||||
|
self._is_valid = len(self.warnings) == 0
|
||||||
|
|
||||||
|
async def initialize(self) -> Dict[str, Any]:
|
||||||
|
storage = await super().initialize()
|
||||||
|
self.upstream_sha = storage.get("upstream_commit", "?")
|
||||||
|
self.upstream_version = PyVersion(storage.get("upstream_version", "?"))
|
||||||
|
self.rollback_ref = storage.get("rollback_ref", "?")
|
||||||
|
if not self.needs_refresh():
|
||||||
|
self._log_package_info()
|
||||||
|
return storage
|
||||||
|
|
||||||
|
def get_persistent_data(self) -> Dict[str, Any]:
|
||||||
|
storage = super().get_persistent_data()
|
||||||
|
storage["upstream_commit"] = self.upstream_sha
|
||||||
|
storage["upstream_version"] = self.upstream_version.full_version
|
||||||
|
storage["rollback_ref"] = self.rollback_ref
|
||||||
|
return storage
|
||||||
|
|
||||||
|
def get_update_status(self) -> Dict[str, Any]:
|
||||||
|
status = super().get_update_status()
|
||||||
|
status.update({
|
||||||
|
"detected_type": "python_package",
|
||||||
|
"branch": self.primary_branch,
|
||||||
|
"owner": self.repo_owner,
|
||||||
|
"repo_name": self.repo_name,
|
||||||
|
"version": self.current_version.short_version,
|
||||||
|
"remote_version": self.upstream_version.short_version,
|
||||||
|
"current_hash": self.current_sha,
|
||||||
|
"remote_hash": self.upstream_sha,
|
||||||
|
"is_dirty": self.git_version.dirty,
|
||||||
|
"changelog_url": self.changelog,
|
||||||
|
"full_version_string": self.current_version.full_version,
|
||||||
|
"pristine": not self.git_version.dirty,
|
||||||
|
"warnings": self.warnings
|
||||||
|
})
|
||||||
|
return status
|
||||||
|
|
||||||
|
def _add_warning(self, msg: str) -> None:
|
||||||
|
self.warnings.append(msg)
|
||||||
|
self.log_info(msg)
|
||||||
|
|
||||||
|
def _detect_update_source(self, package_info: PackageInfo) -> None:
|
||||||
|
self.source = PackageSource.UNKNOWN
|
||||||
|
direct_url_data = package_info.direct_url_data
|
||||||
|
if direct_url_data is None:
|
||||||
|
self.source = PackageSource.PIP
|
||||||
|
self.repo_url = self._get_url(["repository", "repo"], package_info)
|
||||||
|
return
|
||||||
|
self.log_debug(f"Direct URL info: {direct_url_data}")
|
||||||
|
vcs_info: Dict[str, str] = direct_url_data.get("vcs_info", {})
|
||||||
|
if vcs_info.get("vcs", "") != "git":
|
||||||
|
self._add_warning(
|
||||||
|
"Package installed from source other than pypi or git: "
|
||||||
|
f"{direct_url_data}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.current_sha = vcs_info["commit_id"]
|
||||||
|
self.repo_url = direct_url_data["url"]
|
||||||
|
except KeyError:
|
||||||
|
self._add_warning("Failed to retrive direct_url vcs info")
|
||||||
|
return
|
||||||
|
url_match = re.match(
|
||||||
|
r"https://(?:www\.)?github\.com/(?P<owner>.+?)/(?P<proj>.+?)(?:\.git|$)",
|
||||||
|
self.repo_url, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if url_match is None:
|
||||||
|
self._add_warning(f"Invalid repo url: {self.repo_url}")
|
||||||
|
return
|
||||||
|
self.source = PackageSource.GITHUB
|
||||||
|
self.repo_owner = url_match["owner"] or "?"
|
||||||
|
self.repo_name = url_match["proj"] or "?"
|
||||||
|
|
||||||
|
def _get_url(self, keys: List[str], package_info: PackageInfo) -> str:
|
||||||
|
release_info = package_info.release_info
|
||||||
|
primary = keys[0]
|
||||||
|
if release_info is not None:
|
||||||
|
urls: Dict[str, Any] = release_info.get("urls", {})
|
||||||
|
for name, url in urls.items():
|
||||||
|
if name.lower() in keys:
|
||||||
|
return url
|
||||||
|
self.log_debug(f"Unable to find {primary} url in release info")
|
||||||
|
# Fallback to Metadata
|
||||||
|
metadata = package_info.metadata
|
||||||
|
md_urls: Optional[List[str]] = metadata.get_all("Project-URL", None)
|
||||||
|
if md_urls is not None:
|
||||||
|
for url in md_urls:
|
||||||
|
key, url = url.split(",", maxsplit=1)
|
||||||
|
key = key.lower().strip()
|
||||||
|
if key in keys:
|
||||||
|
return url.strip()
|
||||||
|
self.log_info(f"Unable to find {primary} url in metadata")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _update_current_version(self, package_info: PackageInfo) -> bool:
|
||||||
|
pkg_verson = ""
|
||||||
|
release_info = package_info.release_info
|
||||||
|
metadata = package_info.metadata
|
||||||
|
if release_info is not None:
|
||||||
|
self.current_sha = release_info.get("commit_sha", self.current_sha)
|
||||||
|
self.git_version = GitVersion(release_info.get("git_version", "?"))
|
||||||
|
pkg_verson = release_info.get("package_version", "")
|
||||||
|
if "Version" in metadata:
|
||||||
|
pkg_verson = metadata["Version"]
|
||||||
|
if not pkg_verson:
|
||||||
|
self._add_warning("Failed to detect package version")
|
||||||
|
return False
|
||||||
|
self.current_version = PyVersion(pkg_verson)
|
||||||
|
if not self.current_version.is_valid_version():
|
||||||
|
self._add_warning("Failed to parse package version")
|
||||||
|
return False
|
||||||
|
local = self.current_version.local
|
||||||
|
if self.current_sha == "?":
|
||||||
|
if self.source != PackageSource.GITHUB:
|
||||||
|
self.current_sha = "not-specified"
|
||||||
|
elif local:
|
||||||
|
self.current_sha = local[1:].split(".", 1)[0]
|
||||||
|
if not self.git_version.is_valid_version():
|
||||||
|
self.git_version = self.current_version.convert_to_git()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _parse_system_dependencies(self, package_info: PackageInfo) -> List[str]:
|
||||||
|
rinfo = package_info.release_info
|
||||||
|
if rinfo is None:
|
||||||
|
return []
|
||||||
|
dep_info = rinfo.get("system_dependencies", {})
|
||||||
|
for distro_id in DISTRO_ALIASES:
|
||||||
|
if distro_id in dep_info:
|
||||||
|
if not dep_info[distro_id]:
|
||||||
|
self.log_info(
|
||||||
|
f"Package release_info contains an empty system "
|
||||||
|
f"package definition for linux distro '{distro_id}'"
|
||||||
|
)
|
||||||
|
return dep_info[distro_id]
|
||||||
|
else:
|
||||||
|
self.log_info(
|
||||||
|
"Package release_info has no package definition "
|
||||||
|
f" for linux distro '{DISTRO_ALIASES[0]}'"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _update_local_state(self) -> None:
|
||||||
|
self.warnings.clear()
|
||||||
|
eventloop = self.server.get_event_loop()
|
||||||
|
try:
|
||||||
|
assert self.virtualenv is not None
|
||||||
|
package_info = await eventloop.run_in_thread(
|
||||||
|
load_distribution_info, self.virtualenv, self.project_name
|
||||||
|
)
|
||||||
|
except self.server.error:
|
||||||
|
self._add_warning("Failed to parse package info")
|
||||||
|
else:
|
||||||
|
self.git_version = GitVersion("?")
|
||||||
|
self.current_sha = "?"
|
||||||
|
self.current_version = PyVersion("?")
|
||||||
|
self._detect_update_source(package_info)
|
||||||
|
self._update_current_version(package_info)
|
||||||
|
self.changelog = self._get_url(["changelog"], package_info)
|
||||||
|
self.system_deps = self._parse_system_dependencies(package_info)
|
||||||
|
self._is_valid = len(self.warnings) == 0
|
||||||
|
|
||||||
|
async def refresh(self) -> None:
|
||||||
|
try:
|
||||||
|
if self.source == PackageSource.PIP:
|
||||||
|
await self._refresh_pip()
|
||||||
|
elif self.source == PackageSource.GITHUB:
|
||||||
|
await self._refresh_github()
|
||||||
|
else:
|
||||||
|
self.log_info("Cannot refresh, package source is unknown")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
self.log_exc(f"Error Refreshing Python Package: {self.name}")
|
||||||
|
self._log_package_info()
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
|
async def _refresh_pip(self) -> None:
|
||||||
|
# Perform a dry-run install to see if an update is available.
|
||||||
|
# Curently this is the most reliable way to fetch the latest
|
||||||
|
# version from an index, as we can't assume configurations
|
||||||
|
# will use PyPI.
|
||||||
|
self.log_info("Requesting package info via PIP...")
|
||||||
|
norm_name = normalize_project_name(self.project_name)
|
||||||
|
assert self.pip_cmd is not None
|
||||||
|
pip_args = f"install -U --quiet --dry-run --no-deps --report - {norm_name}"
|
||||||
|
pip_exec = pip_utils.AsyncPipExecutor(self.pip_cmd, self.server)
|
||||||
|
resp = await pip_exec.call_pip_with_response(pip_args)
|
||||||
|
data: Dict[str, Any] = json_wrapper.loads(resp)
|
||||||
|
install_data: List[Dict[str, Any]] = data.get("install", [])
|
||||||
|
if not install_data:
|
||||||
|
# No update available
|
||||||
|
return
|
||||||
|
metadata: Dict[str, Any] = install_data[0].get("metadata", {})
|
||||||
|
name: str = normalize_project_name(metadata.get("name", ""))
|
||||||
|
if len(install_data) > 1 and name != norm_name:
|
||||||
|
for inst in install_data[1:]:
|
||||||
|
md: Dict[str, Any] = inst.get("metadata", {})
|
||||||
|
name = normalize_project_name(md.get("name", ""))
|
||||||
|
if name == norm_name:
|
||||||
|
metadata = md
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise self.server.error("Failed to find metadata for package")
|
||||||
|
version: str = metadata.get("version", "?")
|
||||||
|
self.upstream_version = PyVersion(version)
|
||||||
|
if self.current_version < self.upstream_version:
|
||||||
|
self.upstream_sha = "update-available"
|
||||||
|
else:
|
||||||
|
self.upstream_sha = self.current_sha
|
||||||
|
|
||||||
|
async def _refresh_github(self) -> None:
|
||||||
|
repo = f"{self.repo_owner}/{self.repo_name}"
|
||||||
|
client = self.cmd_helper.get_http_client()
|
||||||
|
if self.channel == Channel.DEV:
|
||||||
|
resource = f"/repos/{repo}/commits?per_page=1"
|
||||||
|
if self.primary_branch is not None:
|
||||||
|
resource += f"&sha={self.primary_branch}"
|
||||||
|
resp = await client.github_api_request(
|
||||||
|
resource, attempts=3, retry_pause_time=.5
|
||||||
|
)
|
||||||
|
if resp.status_code != 304 and resp.has_error():
|
||||||
|
self.log_info(f"Github Request Error - {resp.error}")
|
||||||
|
return
|
||||||
|
commit_list: List[Dict[str, Any]] = cast(list, resp.json())
|
||||||
|
if not commit_list:
|
||||||
|
self.log_info("No commits found")
|
||||||
|
return
|
||||||
|
self.upstream_sha = commit_list[0]["sha"]
|
||||||
|
self.upstream_version = self.current_version
|
||||||
|
if self.upstream_sha != self.current_sha:
|
||||||
|
local_part = f"g{self.upstream_sha[:8]}"
|
||||||
|
bumped = self.current_version.bump_local_version(local_part)
|
||||||
|
self.upstream_version = bumped
|
||||||
|
return
|
||||||
|
if self.channel == Channel.STABLE:
|
||||||
|
resource = f"repos/{repo}/releases/latest"
|
||||||
|
else:
|
||||||
|
resource = f"repos/{repo}/releases?per_page=1"
|
||||||
|
resp = await client.github_api_request(
|
||||||
|
resource, attempts=3, retry_pause_time=.5
|
||||||
|
)
|
||||||
|
if resp.status_code != 304 and resp.has_error():
|
||||||
|
self.log_info(f"Github Request Error - {resp.error}")
|
||||||
|
return
|
||||||
|
release = resp.json()
|
||||||
|
result: Dict[str, Any] = {}
|
||||||
|
if isinstance(release, list):
|
||||||
|
if release:
|
||||||
|
result = release[0]
|
||||||
|
else:
|
||||||
|
result = release
|
||||||
|
if not result:
|
||||||
|
self.log_info("No releases found")
|
||||||
|
self.upstream_sha = self.current_sha
|
||||||
|
self.upstream_version = self.current_version
|
||||||
|
return
|
||||||
|
self.upstream_version = PyVersion(result["tag_name"])
|
||||||
|
if self.upstream_version > self.current_version:
|
||||||
|
self.upstream_sha = "update-available"
|
||||||
|
|
||||||
|
async def update(self, rollback: bool = False) -> bool:
|
||||||
|
project_name = normalize_project_name(self.project_name)
|
||||||
|
assert self.pip_cmd is not None
|
||||||
|
pip_args: str
|
||||||
|
if not self.upstream_version.is_valid_version():
|
||||||
|
# Can't update without a valid upstream
|
||||||
|
return False
|
||||||
|
pip_exec = pip_utils.AsyncPipExecutor(
|
||||||
|
self.pip_cmd, self.server, self.cmd_helper.notify_update_response
|
||||||
|
)
|
||||||
|
current_ref = self.current_version.tag
|
||||||
|
if self.source == PackageSource.PIP:
|
||||||
|
# We can't depend on the SHA being available for PyPI packages,
|
||||||
|
# so we must compare versions
|
||||||
|
if (
|
||||||
|
self.current_version.is_valid_version() and
|
||||||
|
self.upstream_version <= self.current_version
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
pip_args = f"install -U {project_name}"
|
||||||
|
if rollback:
|
||||||
|
pip_args += f"=={self.rollback_ref}"
|
||||||
|
elif self.source == PackageSource.GITHUB:
|
||||||
|
if self.current_sha == self.upstream_sha:
|
||||||
|
return False
|
||||||
|
repo = f"{self.repo_owner}/{self.repo_name}"
|
||||||
|
pip_args = f"install -U git+https://github.com/{repo}"
|
||||||
|
if rollback:
|
||||||
|
pip_args += f"@{self.rollback_ref}"
|
||||||
|
elif self.channel == "dev":
|
||||||
|
current_ref = self.current_sha
|
||||||
|
if self.primary_branch is not None:
|
||||||
|
pip_args += f"@{self.primary_branch}"
|
||||||
|
else:
|
||||||
|
pip_args += f"@{self.upstream_version.tag}"
|
||||||
|
else:
|
||||||
|
raise self.server.error("Cannot update, package source is unknown")
|
||||||
|
await self._update_pip(pip_exec)
|
||||||
|
sys_deps = self.system_deps
|
||||||
|
source = self.source.name
|
||||||
|
self.notify_status(f"Updating Python Package {self.name} from {source}...")
|
||||||
|
await pip_exec.call_pip(pip_args, 3600, sys_env_vars=self.pip_env_vars)
|
||||||
|
await self._update_local_state()
|
||||||
|
if not rollback:
|
||||||
|
self.rollback_ref = current_ref
|
||||||
|
self.upstream_sha = self.current_sha
|
||||||
|
self.upstream_version = self.current_version
|
||||||
|
await self._update_sys_deps(sys_deps)
|
||||||
|
self._log_package_info()
|
||||||
|
self._save_state()
|
||||||
|
await self.restart_service()
|
||||||
|
self.notify_status("Update Finished...", is_complete=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def recover(
|
||||||
|
self, hard: bool = False, force_dep_update: bool = False
|
||||||
|
) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def rollback(self) -> bool:
|
||||||
|
if self.rollback_ref == "?":
|
||||||
|
return False
|
||||||
|
await self.update(rollback=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _update_sys_deps(self, prev_deps: List[str]) -> None:
|
||||||
|
new_deps = self.system_deps
|
||||||
|
deps_diff = list(set(new_deps) - set(prev_deps))
|
||||||
|
if deps_diff:
|
||||||
|
await self._install_packages(deps_diff)
|
||||||
|
|
||||||
|
def _log_package_info(self) -> None:
|
||||||
|
logging.info(
|
||||||
|
f"Python Package {self.name} detected:\n"
|
||||||
|
f"Channel: {self.channel}\n"
|
||||||
|
f"Package Source: {self.source.name}\n"
|
||||||
|
f"Repo Owner: {self.repo_owner}\n"
|
||||||
|
f"Repo Name: {self.repo_name}\n"
|
||||||
|
f"Repo URL: {self.repo_url}\n"
|
||||||
|
f"Changelog URL: {self.changelog}\n"
|
||||||
|
f"Full Version String: {self.current_version.full_version}\n"
|
||||||
|
f"Current Version: {self.current_version.short_version}\n"
|
||||||
|
f"Current Commit SHA: {self.current_sha}\n"
|
||||||
|
f"Upstream Version: {self.upstream_version.short_version}\n"
|
||||||
|
f"Upstream Commit SHA: {self.upstream_sha}\n"
|
||||||
|
f"Converted Git Version: {self.git_version}\n"
|
||||||
|
f"Rollback Ref: {self.rollback_ref}\n"
|
||||||
|
)
|
Loading…
Reference in New Issue