-
Notifications
You must be signed in to change notification settings - Fork 0
MSI: Update uninstall scripts #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
b2d2025
81a75b4
3300636
a813e17
d64e6ab
0f4d68d
d64a807
c3d51fd
d9fa4f8
1d40e95
f39a787
60f85bb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,46 @@ | ||
| set "INSTDIR=%cd%" | ||
| @echo {{ 'on' if add_debug else 'off' }} | ||
| setlocal | ||
|
|
||
| rem Assign INSTDIR and normalize the path | ||
| set "INSTDIR=%~dp0.." | ||
| for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" | ||
|
|
||
| set "BASE_PATH=%INSTDIR%\base" | ||
| set "PREFIX=%BASE_PATH%" | ||
| set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" | ||
| set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" | ||
|
|
||
| {%- if add_debug %} | ||
| rem Get the name of the install directory | ||
| for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" | ||
| set "LOG=%TEMP%\%APPNAME%-preuninstall.log" | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not really happy with this solution of setting the file name here, since it is also explicitly set in
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a way to access the log file for the MSI installer somehow?
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you mean accessing it from the CI pipeline? Otherwise no, only by opening the file, I think it's a bit clunky, MSI has "support" for logging but it seems very generic since as you might have seen that we enable logging for MSI Installers via
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, that's why I'm asking if there is any avenue that we can access
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, it could work but it's risky. We can end-up with encoding mismatch, MSI log is usually UTF-16. cmd.exe echo writes in the console codepage (effectively “ANSI”/OEM), so you’ll end up mixing encodings in one file.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A problem to solve for a different PR, but I would really like to see those logged in the same file. Maybe we need to extend the logger to detect the file encoding of an existing log file and change it accordingly? The documentation is a little sparse about what happens when Is the risk for race-condition really there? Would an MSI installer even write to log while the script is running?
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To answer this correctly, I think we need to do a lot of testing. I agree something for a different PR. |
||
|
|
||
| echo ==== pre_uninstall start ==== >> "%LOG%" | ||
| echo SCRIPT=%~f0 >> "%LOG%" | ||
| echo CWD=%CD% >> "%LOG%" | ||
| echo INSTDIR=%INSTDIR% >> "%LOG%" | ||
| echo BASE_PATH=%BASE_PATH% >> "%LOG%" | ||
| echo CONDA_EXE=%CONDA_EXE% >> "%LOG%" | ||
| echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%" | ||
| "%CONDA_EXE%" --version >> "%LOG%" 2>&1 | ||
| {%- endif %} | ||
|
|
||
| {%- set redir = ' >> "%LOG%" 2>&1' if add_debug else '' %} | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| {%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %} | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should always create a log if possible. The log level should just determine what goes into the log. We do have to make sure it gets deleted in the end though.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @marcoesters thanks for your first set of review comments. I thought I'd respond here first because the current design is due to some limitations I ran into last week, and I think we need to discuss this before I make any additional changes.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is how the logger is set up: https://github.com/conda/conda-standalone/blob/00deca6accc6fd0b9dae8346f125f1ad1d107524/src/entry_point.py#L134-L164 So, it should catch all
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I just tried it and unfortunately it doesn't add unhandled exceptions to the log file.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As we found, that was an incorrect example, but it does work. |
||
|
|
||
| rem Sanity checks | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we call these "consistency checks" instead of "sanity checks"?
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I still see some instances
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| if not exist "%CONDA_EXE%" ( | ||
| {% if add_debug %}echo [ERROR] CONDA_EXE not found: "%CONDA_EXE%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b 10 | ||
| ) | ||
|
Comment on lines
+40
to
+42
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think if we error out, we should always add a message. So, I'd skip the entire check if not debugging or always add the message.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why don't we just always add to the log file? That answer may depend on how we deal with the MSI vs post-install logging inconsistency.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We cant add everything that is outputted from the scripts to the log file while also displaying the output "live" to the person who runs the installer, see my answer in #3 (comment) (the first bullet).
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Or we might be able to achieve "always write to file" (but not the same file for the MSI engine + pre/post uninstall/install), with the following approach:
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The workaround I used for NSIS was to write to a step-log file and then |
||
|
|
||
| rem Recreate an empty payload tar. This file was deleted during installation but the | ||
| rem MSI installer expects it to exist. | ||
| type nul > "%PAYLOAD_TAR%" | ||
| if errorlevel 1 ( | ||
| {% if add_debug %}echo [ERROR] Failed to create "%PAYLOAD_TAR%" >> "%LOG%" & type "%LOG%" & {% endif %}exit /b %errorlevel% | ||
| ) | ||
|
|
||
| "%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%"{{ redir }} | ||
| if errorlevel 1 ( {{ dump_and_exit }} ) | ||
|
|
||
| exit /b 0 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,5 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import ctypes | ||
| import getpass | ||
| import json | ||
| import os | ||
|
|
@@ -10,6 +9,7 @@ | |
| import time | ||
| import warnings | ||
| import xml.etree.ElementTree as ET | ||
| from dataclasses import dataclass | ||
| from datetime import timedelta | ||
| from functools import cache | ||
| from pathlib import Path | ||
|
|
@@ -338,28 +338,99 @@ def _sentinel_file_checks(example_path, install_dir): | |
| ) | ||
|
|
||
|
|
||
| def is_admin() -> bool: | ||
| try: | ||
| return ctypes.windll.shell32.IsUserAnAdmin() | ||
| except Exception: | ||
| return False | ||
|
|
||
|
|
||
| def calculate_msi_install_path(installer: Path) -> Path: | ||
| """This is a temporary solution for now since we cannot choose the install location ourselves. | ||
| Installers are named <name>-<version>-Windows-x86_64.msi. | ||
| """ | ||
| dir_name = installer.name.replace("-Windows-x86_64.msi", "").replace("-", " ") | ||
| if is_admin(): | ||
|
marcoesters marked this conversation as resolved.
|
||
| root_dir = Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) | ||
| else: | ||
| local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) | ||
| root_dir = Path(local_dir) / "Programs" | ||
| local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) | ||
| root_dir = Path(local_dir) / "Programs" | ||
| root_dir.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| assert root_dir.is_dir() # Sanity check to avoid strange unexpected errors | ||
| return Path(root_dir) / dir_name | ||
|
|
||
|
|
||
| def handle_exception_and_error_out( | ||
| failure: InstallationFailure | UninstallationFailure, original_exception: BaseException | ||
| ) -> None: | ||
| """Print failure context (including logs) and re-raise with exception chaining.""" | ||
| print(failure.read_text()) | ||
| raise failure from original_exception | ||
|
|
||
|
|
||
| def _read_briefcase_log_tail(path: Path, last_digits: int) -> str: | ||
| """Helper function to read logs from installers created with briefcase. | ||
| The encoding can vary between the different logs. | ||
| """ | ||
| if not path or not path.exists(): | ||
| return f"(log not found: {path})" | ||
|
|
||
| # Try UTF-16 first (MSI logs), fallback to UTF-8 | ||
| try: | ||
| text = path.read_text(encoding="utf-16", errors="replace") | ||
| except UnicodeError: | ||
| text = path.read_text(encoding="utf-8", errors="replace") | ||
|
|
||
| return text[last_digits:] | ||
|
|
||
|
|
||
| @dataclass | ||
| class InstallationFailure(RuntimeError): | ||
|
marcoesters marked this conversation as resolved.
|
||
| cmd: list[str] | ||
| returncode: int | ||
| msi_log: Path | None = None | ||
| post_install_log: Path | None = None | ||
|
|
||
| def read_text(self, last_digits: int = -15000) -> str: | ||
| parts = [ | ||
| f"Command: {self.cmd}", | ||
| f"Return code: {self.returncode}", | ||
| ] | ||
|
|
||
| if self.post_install_log: | ||
| parts.append( | ||
| f"\n=== MSI LOG POST INSTALL: {self.post_install_log} ===\n" | ||
| + _read_briefcase_log_tail(self.post_install_log, last_digits) | ||
| ) | ||
|
|
||
| if self.msi_log: | ||
| parts.append( | ||
| f"\n=== MSI LOG: {self.msi_log} ===\n" | ||
| + _read_briefcase_log_tail(self.msi_log, last_digits) | ||
| ) | ||
|
|
||
| return "\n".join(parts) | ||
|
|
||
|
|
||
| @dataclass | ||
| class UninstallationFailure(RuntimeError): | ||
| cmd: list[str] | ||
| returncode: int | ||
| msi_log: Path | None = None | ||
| pre_uninstall_log: Path | None = None | ||
|
|
||
| def read_text(self, last_digits: int = -15000) -> str: | ||
| parts = [ | ||
| f"Command: {self.cmd}", | ||
| f"Return code: {self.returncode}", | ||
| ] | ||
|
|
||
| if self.pre_uninstall_log: | ||
| parts.append( | ||
| f"\n=== MSI LOG PRE UNINSTALL: {self.pre_uninstall_log} ===\n" | ||
| + _read_briefcase_log_tail(self.pre_uninstall_log, last_digits) | ||
| ) | ||
|
|
||
| if self.msi_log: | ||
| parts.append( | ||
| f"\n=== MSI LOG: {self.msi_log} ===\n" | ||
| + _read_briefcase_log_tail(self.msi_log, last_digits) | ||
| ) | ||
|
|
||
| return "\n".join(parts) | ||
|
|
||
|
|
||
| def _run_installer_msi( | ||
| installer: Path, | ||
| install_dir: Path, | ||
|
|
@@ -388,22 +459,41 @@ def _run_installer_msi( | |
| "/qn", | ||
| ] | ||
|
|
||
| log_path = Path(os.environ.get("TEMP")) / (install_dir.name + ".log") | ||
| # Prepare logging | ||
| post_install_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-postinstall.log") | ||
| log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-install.log") | ||
| for log_file in [post_install_log, log_path]: | ||
| if log_file.exists(): | ||
| os.remove(log_file) | ||
| cmd.extend(["/L*V", str(log_path)]) | ||
|
|
||
| # Run installer and handle errors/logs if necessary | ||
| try: | ||
| process = _execute(cmd, installer_input=installer_input, timeout=timeout, check=check) | ||
| except subprocess.CalledProcessError as e: | ||
| if log_path.exists(): | ||
| # When running on the CI system, it tries to decode a UTF-16 log file as UTF-8, | ||
| # therefore we need to specify encoding before printing. | ||
| print(f"\n=== MSI LOG {log_path} START ===") | ||
| print( | ||
| log_path.read_text(encoding="utf-16", errors="replace")[-15000:] | ||
| ) # last 15k chars | ||
| print(f"\n=== MSI LOG {log_path} END ===") | ||
| raise e | ||
| if check: | ||
| print("A check for MSI Installers not yet implemented") | ||
| handle_exception_and_error_out( | ||
| InstallationFailure( | ||
| cmd=cmd, | ||
| returncode=e.returncode, | ||
| msi_log=log_path, | ||
| post_install_log=post_install_log, | ||
| ), | ||
| original_exception=e, | ||
| ) | ||
|
|
||
| # Sanity check the installation directory | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not really needed but something I had use of while debugging last week. Let me know if I should remove it.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this can be removed to keep the code cleaner.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Forgot to respond to this one, removed f39a787 |
||
| expected_items = [ | ||
| install_dir / "base", | ||
| install_dir / "base" / "conda-meta", | ||
| install_dir / "_conda.exe", | ||
| ] | ||
| missing_items = [item for item in expected_items if not item.exists()] | ||
| if missing_items: | ||
| missing_items_string = "\n".join(missing_items) | ||
| raise Exception( | ||
| f"Sanity check failed, unable to find expected paths: \n{missing_items_string}" | ||
| ) | ||
|
|
||
| return process | ||
|
|
||
|
|
||
|
|
@@ -419,13 +509,32 @@ def _run_uninstaller_msi( | |
| str(installer), | ||
| "/qn", | ||
| ] | ||
| process = _execute(cmd, timeout=timeout, check=check) | ||
|
|
||
| # Prepare logging | ||
| pre_uninstall_log = Path(os.environ.get("TEMP")) / (install_dir.name + "-preuninstall.log") | ||
| log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-uninstall.log") | ||
| for log_file in [pre_uninstall_log, log_path]: | ||
| if log_file.exists(): | ||
| os.remove(log_file) | ||
| cmd.extend(["/L*V", str(log_path)]) | ||
|
|
||
| try: | ||
| process = _execute(cmd, installer_input=None, timeout=timeout, check=check) | ||
| except subprocess.CalledProcessError as e: | ||
| handle_exception_and_error_out( | ||
| UninstallationFailure( | ||
| cmd=cmd, | ||
| returncode=e.returncode, | ||
| msi_log=log_path, | ||
| post_install_log=pre_uninstall_log, | ||
| ), | ||
| original_exception=e, | ||
| ) | ||
|
|
||
| if check: | ||
| # TODO: | ||
| # Check log and if there are remaining files, similar to the exe installers | ||
| pass | ||
| # This is temporary until uninstallation works fine | ||
| shutil.rmtree(str(install_dir), ignore_errors=True) | ||
|
|
||
| return process | ||
|
|
||
|
|
@@ -1051,8 +1160,6 @@ def test_register_envs(tmp_path, request): | |
| """Verify that 'register_envs: False' results in the environment not being registered.""" | ||
| input_path = _example_path("register_envs") | ||
| for installer, install_dir in create_installer(input_path, tmp_path): | ||
| if installer.suffix == ".msi": | ||
| raise NotImplementedError("Test for 'register_envs' not yet implemented for MSI") | ||
| _run_installer(input_path, installer, install_dir, request=request) | ||
| environments_txt = Path("~/.conda/environments.txt").expanduser().read_text() | ||
| assert str(install_dir) not in environments_txt | ||
|
|
@@ -1622,6 +1729,8 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): | |
| input_path = _example_path("register_envs") # The specific example we use here is not important | ||
| options = ["/InstallationType=JustMe", f"/NoRegistry={no_registry}"] | ||
| for installer, install_dir in create_installer(input_path, tmp_path): | ||
| if installer.suffix == ".msi": | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test is for the
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not just change the installer type to
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can't use This could also be solved in a more neat way using proper parametrization (related conda#1146)
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The assertions won't fail because they are not inside the for-loop. The test seems to be written with the assumption that only
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes that's correct. I don't see however how using
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The difference is that currently, your tests are marked as The loop will first create the I agree that it is clunky. We don't even need to move the test into the for-loop because in the end as long as we ensure that the
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see, that's tedious. but it makes sense now when you say that. |
||
| pytest.skip("Test is only applicable for NSIS based installers.") | ||
| _run_installer( | ||
| input_path, | ||
| installer, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This I'll change back to
1later.