update_manager: implement update rollback support

Signed-off-by:  Eric Callahan <arksine.code@gmail.com>
This commit is contained in:
Eric Callahan 2023-07-03 16:47:40 -04:00
parent 26975e055b
commit c903dd6af4
No known key found for this signature in database
GPG Key ID: 5A1EB336DFB4C71B
3 changed files with 184 additions and 53 deletions

View File

@ -69,6 +69,9 @@ class BaseDeploy:
async def update(self) -> bool:
return False
async def rollback(self) -> bool:
raise self.server.error(f"Rollback not available for {self.name}")
def get_update_status(self) -> Dict[str, Any]:
return {}

View File

@ -107,6 +107,7 @@ class GitDeploy(AppDeploy):
self.notify_status("Resetting Git Repo...")
await self.repo.reset()
await self._update_repo_state()
self.repo.set_rollback_state(None)
if self.repo.is_dirty() or not self._is_valid:
raise self.server.error(
@ -115,14 +116,18 @@ class GitDeploy(AppDeploy):
await self.restart_service()
self.notify_status("Reinstall Complete", is_complete=True)
async def reinstall(self):
# Clear the persistent storage prior to a channel swap.
# After the next update is complete new data will be
# restored.
umdb = self.cmd_helper.get_umdb()
await umdb.pop(self.name, None)
await self.initialize()
await self.recover(True, True)
async def rollback(self) -> bool:
dep_info = await self._collect_dependency_info()
ret = await self.repo.rollback()
if ret:
await self._update_dependencies(dep_info)
await self._update_repo_state(need_fetch=False)
await self.restart_service()
msg = "Rollback Complete"
else:
msg = "Rollback not performed"
self.notify_status(msg, is_complete=True)
return ret
def get_update_status(self) -> Dict[str, Any]:
status = super().get_update_status()
@ -136,6 +141,7 @@ class GitDeploy(AppDeploy):
async def _pull_repo(self) -> None:
self.notify_status("Updating Repo...")
rb_state = self.repo.capture_state_for_rollback()
try:
await self.repo.fetch()
if self.repo.is_detached():
@ -156,6 +162,8 @@ class GitDeploy(AppDeploy):
.2, self.cmd_helper.notify_update_refreshed
)
raise self.log_exc(str(e))
else:
self.repo.set_rollback_state(rb_state)
async def _collect_dependency_info(self) -> Dict[str, Any]:
pkg_deps = await self._read_system_dependencies()
@ -273,6 +281,11 @@ class GitRepo:
self.upstream_version: str = storage.get('upstream_version', "?")
self.current_commit: str = storage.get('current_commit', "?")
self.upstream_commit: str = storage.get('upstream_commit', "?")
self.rollback_commit: str = storage.get('rollback_commit', self.current_commit)
self.rollback_branch: str = storage.get('rollback_branch', self.git_branch)
self.rollback_version: str = storage.get(
'rollback_version', self.current_version
)
self.upstream_url: str = storage.get('upstream_url', "?")
self.recovery_url: str = storage.get(
'recovery_url',
@ -300,6 +313,9 @@ class GitRepo:
'upstream_version': self.upstream_version,
'current_commit': self.current_commit,
'upstream_commit': self.upstream_commit,
'rollback_commit': self.rollback_commit,
'rollback_branch': self.rollback_branch,
'rollback_version': self.rollback_version,
'upstream_url': self.upstream_url,
'recovery_url': self.recovery_url,
'full_version_string': self.full_version_string,
@ -400,7 +416,6 @@ class GitRepo:
commit['tag'] = tag
self.commits_behind.append(commit)
self._check_warnings()
self.log_repo_info()
except Exception:
logging.exception(f"Git Repo {self.alias}: Initialization failure")
raise
@ -408,6 +423,10 @@ class GitRepo:
self.initialized = True
# If no exception was raised assume the repo is not corrupt
self.repo_corrupt = False
if self.rollback_commit == "?" or self.rollback_branch == "?":
# Reset Rollback State
self.set_rollback_state(None)
self.log_repo_info()
finally:
self.init_evt.set()
self.init_evt = None
@ -621,6 +640,9 @@ class GitRepo:
f"Upstream Commit SHA: {self.upstream_commit}\n"
f"Current Version: {self.current_version}\n"
f"Upstream Version: {self.upstream_version}\n"
f"Rollback Commit: {self.rollback_commit}\n"
f"Rollback Branch: {self.rollback_branch}\n"
f"Rollback Version: {self.rollback_version}\n"
f"Is Dirty: {self.dirty}\n"
f"Is Detached: {self.head_detached}\n"
f"Commits Behind: {len(self.commits_behind)}\n"
@ -660,14 +682,16 @@ class GitRepo:
raise self.server.error(
f"Git Repo {self.alias}: No valid git remote detected")
async def reset(self) -> None:
if self.git_remote == "?" or self.git_branch == "?":
raise self.server.error("Cannot reset, unknown remote/branch")
async def reset(self, ref: Optional[str] = None) -> None:
async with self.git_operation_lock:
reset_cmd = f"reset --hard {self.git_remote}/{self.git_branch}"
if self.is_beta:
reset_cmd = f"reset --hard {self.upstream_commit}"
await self._run_git_cmd(reset_cmd, retries=2)
if ref is None:
if self.is_beta:
ref = self.upstream_commit
else:
if self.git_remote == "?" or self.git_branch == "?":
raise self.server.error("Cannot reset, unknown remote/branch")
ref = f"{self.git_remote}/{self.git_branch}"
await self._run_git_cmd(f"reset --hard {ref}", retries=2)
self.repo_corrupt = False
async def fetch(self) -> None:
@ -734,13 +758,16 @@ class GitRepo:
async def checkout(self, branch: Optional[str] = None) -> None:
self._verify_repo()
reset_commit: Optional[str] = None
async with self.git_operation_lock:
if branch is None:
# No branch is specifed so we are checking out detached
if self.is_beta:
branch = self.upstream_commit
else:
branch = f"{self.git_remote}/{self.git_branch}"
reset_commit = self.upstream_commit
branch = f"{self.git_remote}/{self.git_branch}"
await self._run_git_cmd(f"checkout -q {branch}")
if reset_commit is not None:
await self.reset(reset_commit)
async def run_fsck(self) -> None:
async with self.git_operation_lock:
@ -773,6 +800,39 @@ class GitRepo:
self.cmd_helper.notify_update_response(
f"Git Repo {self.alias}: Git Clone Complete")
async def rollback(self) -> bool:
if self.rollback_commit == "?" or self.rollback_branch == "?":
raise self.server.error("Incomplete rollback data stored, cannot rollback")
if self.rollback_branch != self.git_branch:
await self.checkout(self.rollback_branch)
elif self.rollback_commit == self.current_commit:
return False
await self.reset(self.rollback_commit)
return True
def capture_state_for_rollback(self) -> Dict[str, Any]:
branch = self.git_branch
if self.head_detached:
branch = f"{self.git_remote}/{self.git_branch}"
return {
"commit": self.current_commit,
"branch": branch,
"version": self.current_version
}
def set_rollback_state(self, rb_state: Optional[Dict[str, str]]) -> None:
if rb_state is None:
self.rollback_commit = self.current_commit
if self.head_detached:
self.rollback_branch = f"{self.git_remote}/{self.git_branch}"
else:
self.rollback_branch = self.git_branch
self.rollback_version = self.current_version
else:
self.rollback_commit = rb_state["commit"]
self.rollback_branch = rb_state["branch"]
self.rollback_version = rb_state["version"]
async def get_commits_behind(self) -> List[Dict[str, Any]]:
self._verify_repo()
if self.is_current():
@ -824,6 +884,7 @@ class GitRepo:
'recovery_url': self.recovery_url,
'version': self.current_version,
'remote_version': self.upstream_version,
'rollback_version': self.rollback_version,
'current_hash': self.current_commit,
'remote_hash': self.upstream_commit,
'is_dirty': self.dirty,

View File

@ -161,6 +161,9 @@ class UpdateManager:
self.server.register_endpoint(
"/machine/update/recover", ["POST"],
self._handle_repo_recovery)
self.server.register_endpoint(
"/machine/update/rollback", ["POST"],
self._handle_rollback)
self.server.register_notification("update_manager:update_response")
self.server.register_notification("update_manager:update_refreshed")
@ -431,9 +434,7 @@ class UpdateManager:
)
return ret
async def _handle_repo_recovery(self,
web_request: WebRequest
) -> str:
async def _handle_repo_recovery(self, web_request: WebRequest) -> str:
if self.kconn.is_printing():
raise self.server.error(
"Recovery Attempt Refused: Klippy is printing")
@ -459,6 +460,25 @@ class UpdateManager:
self.cmd_helper.clear_update_info()
return "ok"
async def _handle_rollback(self, web_request: WebRequest) -> str:
if self.kconn.is_printing():
raise self.server.error("Rollback Attempt Refused: Klippy is printing")
app: str = web_request.get_str('name')
updater = self.updaters.get(app, None)
if updater is None:
raise self.server.error(f"Updater {app} not available", 404)
async with self.cmd_request_lock:
self.cmd_helper.set_update_info(f"rollback_{app}", id(web_request))
try:
await updater.rollback()
except Exception as e:
self.cmd_helper.notify_update_response(f"Error Rolling Back {app}")
self.cmd_helper.notify_update_response(str(e), is_complete=True)
raise
finally:
self.cmd_helper.clear_update_info()
return "ok"
def close(self) -> None:
if self.refresh_timer is not None:
self.refresh_timer.stop()
@ -1274,6 +1294,10 @@ class WebClientDeploy(BaseDeploy):
if self.version == "?":
self.version = storage.get("version", "?")
self.remote_version: str = storage.get('remote_version', "?")
self.rollback_version: str = storage.get('rollback_version', self.version)
self.rollback_repo: str = storage.get(
'rollback_repo', self.repo if self._valid else "?"
)
self.last_error: str = storage.get('last_error', "")
dl_info: List[Any] = storage.get('dl_info', ["?", "?", 0])
self.dl_info: Tuple[str, str, int] = cast(
@ -1300,7 +1324,9 @@ class WebClientDeploy(BaseDeploy):
f"Pre-release: {self._is_prerelease}\n"
f"Download Url: {dl_url}\n"
f"Download Size: {size}\n"
f"Content Type: {content_type}"
f"Content Type: {content_type}\n"
f"Rollback Version: {self.rollback_version}\n"
f"Rollback Repo: {self.rollback_repo}"
f"{warn_str}"
)
@ -1314,33 +1340,37 @@ class WebClientDeploy(BaseDeploy):
self._log_client_info()
self._save_state()
async def _get_remote_version(self) -> None:
if not self._valid:
self.log_info("Invalid Web Installation, aborting remote refresh")
return
# Remote state
if self.channel == "stable":
resource = f"repos/{self.repo}/releases/latest"
async def _fetch_github_version(
self, repo: Optional[str] = None, tag: Optional[str] = None
) -> Dict[str, Any]:
if repo is None:
if not self._valid:
self.log_info("Invalid Web Installation, aborting remote refresh")
return {}
repo = self.repo
if tag is not None:
resource = f"repos/{repo}/releases/tags/{tag}"
elif self.channel == "stable":
resource = f"repos/{repo}/releases/latest"
else:
resource = f"repos/{self.repo}/releases?per_page=1"
resource = f"repos/{repo}/releases?per_page=1"
client = self.cmd_helper.get_http_client()
resp = await client.github_api_request(
resource, attempts=3, retry_pause_time=.5
)
release: Union[List[Any], Dict[str, Any]] = {}
if resp.status_code == 304:
if self.remote_version == "?" and resp.content:
if resp.content:
# Not modified, however we need to restore state from
# cached content
release = resp.json()
else:
# Either not necessary or not possible to restore from cache
return
return {}
elif resp.has_error():
logging.info(
f"Client {self.repo}: Github Request Error - {resp.error}")
self.log_info(f"Github Request Error - {resp.error}")
self.last_error = str(resp.error)
return
return {}
else:
release = resp.json()
result: Dict[str, Any] = {}
@ -1350,6 +1380,12 @@ class WebClientDeploy(BaseDeploy):
else:
result = release
self.last_error = ""
return result
async def _get_remote_version(self) -> None:
result = await self._fetch_github_version()
if not result:
return
self.remote_version = result.get('name', "?")
release_asset: Dict[str, Any] = result.get('assets', [{}])[0]
dl_url: str = release_asset.get('browser_download_url', "?")
@ -1362,32 +1398,40 @@ class WebClientDeploy(BaseDeploy):
storage = super().get_persistent_data()
storage['version'] = self.version
storage['remote_version'] = self.remote_version
storage['rollback_version'] = self.rollback_version
storage['rollback_repo'] = self.rollback_repo
storage['dl_info'] = list(self.dl_info)
storage['last_error'] = self.last_error
return storage
async def update(self) -> bool:
async def update(
self, rollback_info: Optional[Tuple[str, str, int]] = None
) -> bool:
if not self._valid:
raise self.server.error(
f"Web Client {self.name}: Invalid install detected, aborting update"
)
if self.remote_version == "?":
await self._get_remote_version()
if rollback_info is not None:
dl_url, content_type, size = rollback_info
start_msg = f"Rolling Back Web Client {self.name}..."
else:
if self.remote_version == "?":
raise self.server.error(
f"Client {self.repo}: Unable to locate update")
dl_url, content_type, size = self.dl_info
await self._get_remote_version()
if self.remote_version == "?":
raise self.server.error(
f"Client {self.repo}: Unable to locate update"
)
dl_url, content_type, size = self.dl_info
if self.version == self.remote_version:
# Already up to date
return False
start_msg = f"Updating Web Client {self.name}..."
if dl_url == "?":
raise self.server.error(
f"Client {self.repo}: Invalid download url")
if self.version == self.remote_version:
# Already up to date
return False
raise self.server.error(f"Client {self.repo}: Invalid download url")
current_version = self.version
event_loop = self.server.get_event_loop()
self.cmd_helper.notify_update_response(
f"Updating Web Client {self.name}...")
self.cmd_helper.notify_update_response(
f"Downloading Client: {self.name}")
self.cmd_helper.notify_update_response(start_msg)
self.cmd_helper.notify_update_response(f"Downloading Client: {self.name}")
td = await self.cmd_helper.create_tempdir(self.name, "client")
try:
tempdir = pathlib.Path(td.name)
@ -1406,12 +1450,34 @@ class WebClientDeploy(BaseDeploy):
await event_loop.run_in_thread(td.cleanup)
self.version = self.remote_version
await self._validate_client_info()
self.cmd_helper.notify_update_response(
f"Client Update Finished: {self.name}", is_complete=True)
if self._valid and rollback_info is None:
self.rollback_version = current_version
self.rollback_repo = self.repo
msg = f"Client Update Finished: {self.name}"
if rollback_info is not None:
msg = f"Rollback Complete: {self.name}"
self.cmd_helper.notify_update_response(msg, is_complete=True)
self._log_client_info()
self._save_state()
return True
async def rollback(self) -> bool:
if self.rollback_version == "?" or self.rollback_repo == "?":
raise self.server.error("Incomplete Rollback Data")
if self.rollback_version == self.version:
return False
result = await self._fetch_github_version(
self.rollback_repo, self.rollback_version
)
if not result:
raise self.server.error("Failed to retrieve release asset data")
release_asset: Dict[str, Any] = result.get('assets', [{}])[0]
dl_url: str = release_asset.get('browser_download_url', "?")
content_type: str = release_asset.get('content_type', "?")
size: int = release_asset.get('size', 0)
dl_info = (dl_url, content_type, size)
return await self.update(dl_info)
def _extract_release(self,
persist_dir: pathlib.Path,
release_file: pathlib.Path
@ -1443,6 +1509,7 @@ class WebClientDeploy(BaseDeploy):
'owner': self.owner,
'version': self.version,
'remote_version': self.remote_version,
'rollback_version': self.rollback_version,
'configured_type': self.type,
'channel': self.channel,
'info_tags': self.info_tags,