Skip to content

Commit

Permalink
git_deploy: add support for pinned commits
Browse files Browse the repository at this point in the history
Signed-off-by:  Eric Callahan <[email protected]>
  • Loading branch information
Arksine committed May 25, 2024
1 parent bc34ebd commit fa1dc43
Show file tree
Hide file tree
Showing 2 changed files with 100 additions and 43 deletions.
6 changes: 6 additions & 0 deletions moonraker/components/update_manager/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,13 @@ def get_base_configuration(config: ConfigHelper) -> ConfigHelper:
if config.has_section("update_manager moonraker"):
mcfg = config["update_manager moonraker"]
base_cfg["moonraker"]["channel"] = mcfg.get("channel", channel)
commit = mcfg.get("pinned_commit", None)
if commit is not None:
base_cfg["moonraker"]["pinned_commit"] = commit
if config.has_section("update_manager klipper"):
kcfg = config["update_manager klipper"]
base_cfg["klipper"]["channel"] = kcfg.get("channel", channel)
commit = kcfg.get("pinned_commit", None)
if commit is not None:
base_cfg["klipper"]["pinned_commit"] = commit
return config.read_supplemental_dict(base_cfg)
137 changes: 94 additions & 43 deletions moonraker/components/update_manager/git_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,18 @@ def __init__(self, config: ConfigHelper, cmd_helper: CommandHelper) -> None:
self.origin: str = config.get('origin')
self.moved_origin: Optional[str] = config.get('moved_origin', None)
self.primary_branch = config.get("primary_branch", "master")
pinned_commit = config.get("pinned_commit", None)
if pinned_commit is not None:
pinned_commit = pinned_commit.lower()
# validate the hash length
if len(pinned_commit) < 8:
raise config.error(
f"[{config.get_name()}]: Value for option 'commit' must be "
"a minimum of 8 characters."
)
self.repo = GitRepo(
cmd_helper, self.path, self.name, self.origin,
self.moved_origin, self.primary_branch, self.channel
cmd_helper, self.path, self.name, self.origin, self.moved_origin,
self.primary_branch, self.channel, pinned_commit
)

async def initialize(self) -> Dict[str, Any]:
Expand Down Expand Up @@ -201,7 +210,8 @@ def __init__(
origin_url: str,
moved_origin_url: Optional[str],
primary_branch: str,
channel: Channel
channel: Channel,
pinned_commit: Optional[str]
) -> None:
self.server = cmd_helper.get_server()
self.cmd_helper = cmd_helper
Expand Down Expand Up @@ -234,6 +244,7 @@ def __init__(
self.fetch_timeout_handle: Optional[asyncio.Handle] = None
self.fetch_input_recd: bool = False
self.channel = channel
self.pinned_commit = pinned_commit
self.is_shallow = False

async def restore_state(self, storage: Dict[str, Any]) -> None:
Expand Down Expand Up @@ -268,6 +279,7 @@ async def restore_state(self, storage: Dict[str, Any]) -> None:
self.rollback_branch: str = storage.get('rollback_branch', def_rbs["branch"])
rbv = storage.get('rollback_version', self.current_version)
self.rollback_version = GitVersion(str(rbv))
self.pinned_commit_valid: bool = storage.get('pinned_commit_valid', True)
if not await self._detect_git_dir():
self.valid_git_repo = False
self._check_warnings()
Expand Down Expand Up @@ -296,7 +308,8 @@ def get_persistent_data(self) -> Dict[str, Any]:
'diverged': self.diverged,
'corrupt': self.repo_corrupt,
'modified_files': self.modified_files,
'untracked_files': self.untracked_files
'untracked_files': self.untracked_files,
'pinned_commit_valid': self.pinned_commit_valid
}

async def refresh_repo_state(self, need_fetch: bool = True) -> None:
Expand All @@ -306,6 +319,7 @@ async def refresh_repo_state(self, need_fetch: bool = True) -> None:
if self.initialized:
return
self.initialized = False
self.pinned_commit_valid = True
self.init_evt = asyncio.Event()
self.git_messages.clear()
try:
Expand Down Expand Up @@ -393,11 +407,12 @@ async def _check_repo_status(self) -> bool:
return False
await self._wait_for_lock_release()
attempts = 3
resp: Optional[str] = None
while attempts:
self.git_messages.clear()
try:
cmd = "status --porcelain -b"
resp: Optional[str] = await self._run_git_cmd(cmd, attempts=1)
resp = await self._run_git_cmd(cmd, attempts=1)
except Exception:
attempts -= 1
resp = None
Expand Down Expand Up @@ -536,7 +551,17 @@ async def _check_moved_origin(self) -> bool:

async def _get_upstream_version(self) -> GitVersion:
self.commits_behind_count = 0
if self.channel == Channel.DEV:
if self.pinned_commit is not None:
self.upstream_commit = self.current_commit
if not self.current_commit.lower().startswith(self.pinned_commit):
if not await self.check_commit_exists(self.pinned_commit):
self.pinned_commit_valid = False
elif await self.is_ancestor(self.current_commit, self.pinned_commit):
self.upstream_commit = self.pinned_commit
upstream_ver_str = await self.describe(
f"{self.upstream_commit} --always --tags --long --abbrev=8",
)
elif self.channel == Channel.DEV:
self.upstream_commit = await self.rev_parse(
f"{self.git_remote}/{self.git_branch}"
)
Expand Down Expand Up @@ -612,11 +637,13 @@ async def wait_for_init(self) -> None:
raise self.server.error(
f"Git Repo {self.alias}: Initialization failure")

async def is_ancestor(self, ancestor_ref: str, descendent_ref: str) -> bool:
async def is_ancestor(
self, ancestor_ref: str, descendent_ref: str, attempts: int = 3
) -> bool:
self._verify_repo()
cmd = f"merge-base --is-ancestor {ancestor_ref} {descendent_ref}"
async with self.git_operation_lock:
for _ in range(3):
for _ in range(attempts):
try:
await self._run_git_cmd(cmd, attempts=1, corrupt_msg="error: ")
except self.cmd_helper.get_shell_command().error as err:
Expand Down Expand Up @@ -660,13 +687,18 @@ def log_repo_info(self) -> None:
f"Is Detached: {self.head_detached}\n"
f"Is Shallow: {self.is_shallow}\n"
f"Commits Behind Count: {self.commits_behind_count}\n"
f"Diverged: {self.diverged}"
f"Diverged: {self.diverged}\n"
f"Pinned Commit: {self.pinned_commit}"
f"{warnings}"
)

def _check_warnings(self) -> None:
self.repo_warnings.clear()
self.repo_anomalies.clear()
if self.pinned_commit is not None and not self.pinned_commit_valid:
self.repo_anomalies.append(
f"Pinned Commit {self.pinned_commit} does not exist"
)
if self.repo_corrupt:
self.repo_warnings.append("Repo is corrupt")
if self.git_branch == "?":
Expand Down Expand Up @@ -731,7 +763,7 @@ def _verify_repo(self, check_remote: bool = False) -> None:
async def reset(self, ref: Optional[str] = None) -> None:
async with self.git_operation_lock:
if ref is None:
if self.channel != Channel.DEV:
if self.channel != Channel.DEV or self.pinned_commit is not None:
ref = self.upstream_commit
else:
if self.git_remote == "?" or self.git_branch == "?":
Expand Down Expand Up @@ -760,7 +792,7 @@ async def pull(self) -> None:
cmd = "pull --progress"
if self.server.is_debug_enabled():
cmd = f"{cmd} --rebase"
if self.channel != Channel.DEV:
if self.channel != Channel.DEV or self.pinned_commit is not None:
cmd = f"{cmd} {self.git_remote} {self.upstream_commit}"
async with self.git_operation_lock:
await self._run_git_cmd_async(cmd)
Expand All @@ -771,6 +803,19 @@ async def list_branches(self) -> List[str]:
resp = await self._run_git_cmd("branch --list --no-color")
return resp.strip().split("\n")

async def check_commit_exists(self, commit: str) -> bool:
self._verify_repo()
async with self.git_operation_lock:
shell_cmd = self.cmd_helper.get_shell_command()
try:
await self._run_git_cmd(
f"cat-file -e {commit}^{{commit}}", attempts=1,
corrupt_msg=None
)
except shell_cmd.error:
return False
return True

async def remote(self, command: str = "", validate: bool = False) -> str:
self._verify_repo(check_remote=validate)
async with self.git_operation_lock:
Expand Down Expand Up @@ -847,7 +892,7 @@ async def checkout(self, branch: Optional[str] = None) -> None:
async with self.git_operation_lock:
if branch is None:
# No branch is specifed so we are checking out detached
if self.channel != Channel.DEV:
if self.channel != Channel.DEV or self.pinned_commit is not None:
reset_commit = self.upstream_commit
branch = f"{self.git_remote}/{self.git_branch}"
await self._run_git_cmd(f"checkout -q {branch}")
Expand Down Expand Up @@ -893,17 +938,13 @@ async def clone(self) -> None:
self.valid_git_repo = True
self.cmd_helper.notify_update_response(
f"Git Repo {self.alias}: Git Clone Complete")
if self.current_commit != "?":
try:
can_reset = await self.is_ancestor(self.current_commit, "HEAD")
except self.server.error:
can_reset = False
if can_reset:
self.cmd_helper.notify_update_response(
f"Git Repo {self.alias}: Moving HEAD to previous "
f"commit {self.current_commit}"
)
await self.reset(self.current_commit)
reset_commit = await self.get_recovery_ref("HEAD")
if reset_commit != "HEAD":
self.cmd_helper.notify_update_response(
f"Git Repo {self.alias}: Moving HEAD to previous "
f"commit {self.current_commit}"
)
await self.reset(reset_commit)

async def rollback(self) -> bool:
if self.rollback_commit == "?" or self.rollback_branch == "?":
Expand Down Expand Up @@ -938,7 +979,7 @@ async def get_commits_behind(self) -> List[Dict[str, Any]]:
if self.is_current():
return []
async with self.git_operation_lock:
if self.channel != Channel.DEV:
if self.channel != Channel.DEV or self.pinned_commit is not None:
ref = self.upstream_commit
else:
ref = f"{self.git_remote}/{self.git_branch}"
Expand Down Expand Up @@ -1048,25 +1089,33 @@ def get_repo_type(self) -> str:
return "worktree"
return "repo"

async def get_recovery_ref(self) -> str:
async def get_recovery_ref(self, upstream_ref: Optional[str] = None) -> str:
""" Fetch the best reference for a 'reset' recovery attempt
Returns the ref to reset to for "soft" recovery requests. The
preference is to reset to the current commit, however that is
only possible if the commit is known and if it is an ancestor of
the primary branch.
"""
remote = await self.config_get(f"branch.{self.primary_branch}.remote")
if remote is None:
raise self.server.error(
f"Failed to find remote for primary branch '{self.primary_branch}'"
)
upstream_ref = f"{remote}/{self.primary_branch}"
if (
self.current_commit != "?" and
await self.is_ancestor(self.current_commit, upstream_ref)
):
return self.current_commit
if upstream_ref is None:
remote = await self.config_get(f"branch.{self.primary_branch}.remote")
if remote is None:
raise self.server.error(
f"Failed to find remote for primary branch '{self.primary_branch}'"
)
upstream_ref = f"{remote}/{self.primary_branch}"
reset_commits: List[str] = []
if self.pinned_commit is not None:
reset_commits.append(self.pinned_commit)
if self.current_commit != "?":
reset_commits.append(self.current_commit)
for commit in reset_commits:
try:
is_ancs = await self.is_ancestor(commit, upstream_ref, attempts=1)
except self.server.error:
is_ancs = False
if is_ancs:
return commit
return upstream_ref

async def _check_lock_file_exists(self, remove: bool = False) -> bool:
Expand Down Expand Up @@ -1154,7 +1203,8 @@ async def _run_git_cmd_async(self,
await scmd.run(timeout=0)
except Exception:
pass
self.fetch_timeout_handle.cancel()
if self.fetch_timeout_handle is not None:
self.fetch_timeout_handle.cancel()
ret = scmd.get_return_code()
if ret == 0:
self.git_messages.clear()
Expand Down Expand Up @@ -1215,7 +1265,7 @@ async def _run_git_cmd(
timeout: float = 20.,
attempts: int = 5,
env: Optional[Dict[str, str]] = None,
corrupt_msg: str = "fatal: ",
corrupt_msg: Optional[str] = "fatal: ",
log_complete: bool = True
) -> str:
shell_cmd = self.cmd_helper.get_shell_command()
Expand All @@ -1238,9 +1288,10 @@ async def _run_git_cmd(
if stderr:
msg_lines.extend(stdout.split("\n"))
self.git_messages.append(stderr)
for line in msg_lines:
line = line.strip().lower()
if line.startswith(corrupt_msg):
self.repo_corrupt = True
break
if corrupt_msg is not None:
for line in msg_lines:
line = line.strip().lower()
if line.startswith(corrupt_msg):
self.repo_corrupt = True
break
raise

0 comments on commit fa1dc43

Please sign in to comment.