diff --git a/dsd_pythonanywhere/client.py b/dsd_pythonanywhere/client.py index 6c9673d..6a42f03 100644 --- a/dsd_pythonanywhere/client.py +++ b/dsd_pythonanywhere/client.py @@ -263,21 +263,51 @@ def wait_for_ready(self) -> None: raise RuntimeError("Console did not become ready after waiting.") + # Marker appended to every command so we can recover its exit status. + # Console output otherwise gives no reliable signal of success/failure. + EXIT_STATUS_MARKER = "DSD_EXIT_STATUS" + EXIT_STATUS_PATTERN = re.compile(rf"{EXIT_STATUS_MARKER}:(-?\d+)\s*$") + def run_command(self, command: str) -> str: """Run a command and return its output. + Appends a marker that echoes the command's exit status, since the + console API otherwise gives no way to tell whether a command actually + succeeded. + Args: command: The command string to run in the console Returns: - The command output as a string, or empty string if command failed + The command output as a string, with the exit-status marker + stripped out. + + Raises: + RuntimeError: if the command could not be sent, or exited with a + non-zero status. """ - response = self.send_input(f"{command}\n") + full_command = f'{command}; echo "{self.EXIT_STATUS_MARKER}:$?"' + response = self.send_input(f"{full_command}\n") if not response.ok: - return "" + raise RuntimeError(f"Failed to send command to console: {command}") + + result = self.wait_for_command_completion(full_command) + output = result.output + + match = self.EXIT_STATUS_PATTERN.search(output) + if match is None: + raise RuntimeError( + f"Could not determine exit status for command: {command}\nOutput:\n{output}" + ) + + output = self.EXIT_STATUS_PATTERN.sub("", output).strip() + exit_status = int(match.group(1)) + if exit_status != 0: + raise RuntimeError( + f"Command exited with status {exit_status}: {command}\nOutput:\n{output}" + ) - result = self.wait_for_command_completion(command) - return result.output + return output class PythonAnywhereClient: diff --git a/dsd_pythonanywhere/platform_deployer.py b/dsd_pythonanywhere/platform_deployer.py index a93ef32..8747d52 100644 --- a/dsd_pythonanywhere/platform_deployer.py +++ b/dsd_pythonanywhere/platform_deployer.py @@ -152,12 +152,30 @@ def _copy_wsgi_file(self): plugin_utils.write_output(" Copying wsgi.py to PythonAnywhere...") django_project_name = dsd_config.local_project_name - domain = f"{self.client.username}.pythonanywhere.com" + # PythonAnywhere only looks for the lowercased filename here, regardless + # of the casing of the account's actual username. + domain = f"{self.client.username.lower()}.pythonanywhere.com" wsgi_dest = f"/var/www/{domain.replace('.', '_')}_wsgi.py" - wsgi_src = f"{self.repo_name}/{django_project_name}/wsgi.py" + # Use an absolute source path: consoles can be reused across API calls + # (get_active_console() may pick up any existing bash console), so a + # `cd` left over from an earlier step can't cause this to copy from + # the wrong place. + wsgi_src = str(self.pa_project_root_path / django_project_name / "wsgi.py") + + self.client.run_command(f"cp {wsgi_src} {wsgi_dest}") + + # cp can "succeed" while copying the wrong file (e.g. an unexpected + # cwd), so confirm the destination actually matches the source before + # declaring the deploy successful. + verify_cmd = f"cmp -s {wsgi_src} {wsgi_dest} && echo COPY_VERIFIED || echo COPY_FAILED" + verify_output = self.client.run_command(verify_cmd) + if "COPY_VERIFIED" not in verify_output: + raise DSDCommandError( + f"Failed to verify that {wsgi_src} was copied to {wsgi_dest} on " + "PythonAnywhere. The deployed app may still be serving PythonAnywhere's " + "placeholder wsgi app." + ) - cmd = f"cp {wsgi_src} {wsgi_dest}" - self.client.run_command(cmd) plugin_utils.write_output(f" Copied {wsgi_src} to {wsgi_dest}") def _create_webapp(self): diff --git a/tests/unit_tests/test_client_console.py b/tests/unit_tests/test_client_console.py index 5f9be12..6fbf8d9 100644 --- a/tests/unit_tests/test_client_console.py +++ b/tests/unit_tests/test_client_console.py @@ -146,6 +146,35 @@ def test_wait_for_command_completion_debug_polling(console, mocker): assert "raw console output" in str(debug_calls[0]) +def test_run_command_returns_output_on_success(console, mocker): + """run_command strips the exit-status marker and returns the real output.""" + mocker.patch.object(console, "send_input", return_value=mocker.Mock(ok=True)) + mock_result = CommandResult(command="ls", output="foo\nbar\nDSD_EXIT_STATUS:0") + mocker.patch.object(console, "wait_for_command_completion", return_value=mock_result) + + output = console.run_command("ls") + + assert output == "foo\nbar" + + +def test_run_command_raises_when_send_fails(console, mocker): + """run_command raises rather than silently returning an empty string.""" + mocker.patch.object(console, "send_input", return_value=mocker.Mock(ok=False)) + + with pytest.raises(RuntimeError, match="Failed to send command"): + console.run_command("ls") + + +def test_run_command_raises_on_nonzero_exit_status(console, mocker): + """run_command raises when the command's own exit status is non-zero.""" + mocker.patch.object(console, "send_input", return_value=mocker.Mock(ok=True)) + mock_result = CommandResult(command="false", output="some error text\nDSD_EXIT_STATUS:1") + mocker.patch.object(console, "wait_for_command_completion", return_value=mock_result) + + with pytest.raises(RuntimeError, match="exited with status 1"): + console.run_command("false") + + def test_wait_for_command_completion_handles_exceptions(console, mocker): """wait_for_command_completion continues on exceptions.""" # First call raises exception, second call succeeds diff --git a/tests/unit_tests/test_platform_deployer.py b/tests/unit_tests/test_platform_deployer.py index 0f9718e..e929a69 100644 --- a/tests/unit_tests/test_platform_deployer.py +++ b/tests/unit_tests/test_platform_deployer.py @@ -80,6 +80,40 @@ def test_add_requirements(tmp_path: Path, monkeypatch): assert "django-simple-deploy" not in modified_content +def test_copy_wsgi_file(monkeypatch, mocker): + """_copy_wsgi_file copies the project's wsgi.py to PythonAnywhere.""" + deployer = PlatformDeployer() + monkeypatch.setattr(dsd_config, "local_project_name", "mysite") + monkeypatch.setattr(dsd_config, "stdout", sys.stdout) + monkeypatch.setattr(deployer, "repo_name", "myrepo", raising=False) + monkeypatch.setattr(deployer, "pa_home", Path("/home/TestUser"), raising=False) + monkeypatch.setattr( + deployer, "pa_project_root_path", deployer.pa_home / deployer.repo_name, raising=False + ) + monkeypatch.setattr(deployer.client, "username", "TestUser") + + mock_run_command = mocker.patch.object(deployer.client, "run_command") + + # Successful, verified copy: an absolute source path (consoles can be + # reused across API calls, so a relative path is vulnerable to a `cd` + # left over from an earlier step) and a lowercased destination filename + # (PythonAnywhere only looks for the lowercased filename, regardless of + # the account's actual username casing). + mock_run_command.side_effect = ["", "COPY_VERIFIED"] + deployer._copy_wsgi_file() + cp_cmd = mock_run_command.call_args_list[0].args[0] + assert "/home/TestUser/myrepo/mysite/wsgi.py" in cp_cmd + assert cp_cmd.split()[1].startswith("/") + assert "/var/www/testuser_pythonanywhere_com_wsgi.py" in cp_cmd + + # cp "succeeds" (no error text returned), but cmp shows a content + # mismatch (e.g. a preceding command left the console's cwd somewhere + # unexpected). Must raise rather than proceed to reload_webapp(). + mock_run_command.side_effect = ["", "COPY_FAILED"] + with pytest.raises(DSDCommandError, match="Failed to verify"): + deployer._copy_wsgi_file() + + def test_validate_platform_missing_api_user(monkeypatch): """_validate_platform raises error when API_USER is missing.""" monkeypatch.delenv("API_USER", raising=False)