diff --git a/.github/workflows/test_e2e.yaml b/.github/workflows/test_e2e.yaml index c5bad0f0a1..29c54b18ff 100644 --- a/.github/workflows/test_e2e.yaml +++ b/.github/workflows/test_e2e.yaml @@ -16,7 +16,10 @@ concurrency: jobs: tests: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest, windows-latest ] steps: - uses: actions/checkout@v4 with: diff --git a/pyproject.toml b/pyproject.toml index 00af57691e..23e90ccc8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,8 @@ dependencies = [ "typer==0.9.0", "urllib3>=1.21.1,<2.3", "GitPython==3.1.42", - "pydantic==2.6.3" + "pydantic==2.6.3", + "oschmod" ] classifiers = [ "Development Status :: 3 - Alpha", diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..c3b41c4498 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +coverage==7.4.3 +jinja2==3.1.3 +pluggy==1.4.0 +PyYAML==6.0.1 +rich==13.7.1 +requests==2.31.0 +requirements-parser==0.5.0 +setuptools==69.1.1 +snowflake-connector-python[secure-local-storage]==3.7.1 +strictyaml==1.7.3 +tomlkit==0.12.3 +typer==0.9.0 +urllib3>=1.21.1,<2.3 +GitPython==3.1.42 +pydantic==2.6.3 +oschmod +pywin32 diff --git a/src/snowflake/cli/api/config.py b/src/snowflake/cli/api/config.py index 5d844f3173..e11a516acc 100644 --- a/src/snowflake/cli/api/config.py +++ b/src/snowflake/cli/api/config.py @@ -15,7 +15,6 @@ ) from snowflake.cli.api.secure_path import SecurePath from snowflake.cli.api.secure_utils import file_permissions_are_strict -from snowflake.connector.compat import IS_WINDOWS from snowflake.connector.config_manager import CONFIG_MANAGER from snowflake.connector.constants import CONFIG_FILE, CONNECTIONS_FILE from snowflake.connector.errors import MissingConfigOptionError @@ -224,13 +223,13 @@ def _get_envs_for_path(*path) -> dict: def _dump_config(conf_file_cache: Dict): - with SecurePath(CONFIG_MANAGER.file_path).open("w+") as fh: + secure_config_path = SecurePath(CONFIG_MANAGER.file_path) + with secure_config_path.open("w+") as fh: dump(conf_file_cache, fh) + secure_config_path.chmod(0o600) def _check_default_config_files_permissions() -> None: - if IS_WINDOWS: - return if CONNECTIONS_FILE.exists() and not file_permissions_are_strict(CONNECTIONS_FILE): raise ConfigFileTooWidePermissionsError(CONNECTIONS_FILE) if CONFIG_FILE.exists() and not file_permissions_are_strict(CONFIG_FILE): diff --git a/src/snowflake/cli/api/exceptions.py b/src/snowflake/cli/api/exceptions.py index 4ff4acf346..2ebdb3aeb5 100644 --- a/src/snowflake/cli/api/exceptions.py +++ b/src/snowflake/cli/api/exceptions.py @@ -5,6 +5,7 @@ from click.exceptions import ClickException from snowflake.cli.api.constants import ObjectType +from snowflake.connector.compat import IS_WINDOWS class EnvironmentVariableNotFoundError(ClickException): @@ -111,8 +112,14 @@ def __init__(self, path: Path): class ConfigFileTooWidePermissionsError(ClickException): def __init__(self, path: Path): + change_permission_message = ( + "set read and write access only to the current user." + if IS_WINDOWS + else f'run `chmod 0600 "{path}"`.' + ) + super().__init__( - f'Configuration file {path} has too wide permissions, run `chmod 0600 "{path}"`' + f"Configuration file {path} has too wide permissions, {change_permission_message}" ) diff --git a/src/snowflake/cli/api/secure_path.py b/src/snowflake/cli/api/secure_path.py index d9b6cb211f..d64fdbc6f6 100644 --- a/src/snowflake/cli/api/secure_path.py +++ b/src/snowflake/cli/api/secure_path.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Optional, Union +import oschmod from snowflake.cli.api.exceptions import DirectoryIsNotEmptyError, FileTooLargeError log = logging.getLogger(__name__) @@ -69,7 +70,7 @@ def chmod(self, permissions_mask: int) -> None: log.info( "Update permissions of file %s to %s", self._path, oct(permissions_mask) ) - self._path.chmod(permissions_mask) + oschmod.set_mode(str(self._path), permissions_mask) def touch(self, permissions_mask: int = 0o600, exist_ok: bool = True) -> None: """ diff --git a/src/snowflake/cli/api/secure_utils.py b/src/snowflake/cli/api/secure_utils.py index 97f4cd5552..5391278a75 100644 --- a/src/snowflake/cli/api/secure_utils.py +++ b/src/snowflake/cli/api/secure_utils.py @@ -1,6 +1,8 @@ import stat from pathlib import Path +import oschmod + def file_permissions_are_strict(file_path: Path) -> bool: accessible_by_others = ( @@ -12,4 +14,4 @@ def file_permissions_are_strict(file_path: Path) -> bool: | stat.S_IXGRP # executable by group | stat.S_IXOTH # executable by others ) - return (file_path.stat().st_mode & accessible_by_others) == 0 + return (oschmod.get_mode(str(file_path)) & accessible_by_others) == 0 diff --git a/tests_e2e/__snapshots__/test_config.ambr b/tests_e2e/__snapshots__/test_config.ambr new file mode 100644 index 0000000000..d977c47bef --- /dev/null +++ b/tests_e2e/__snapshots__/test_config.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_config_file_creation + ''' + No data + + + ''' +# --- +# name: test_config_file_creation.1 + ''' + No data + + + ''' +# --- diff --git a/tests_e2e/conftest.py b/tests_e2e/conftest.py index fa28c6dc17..246fa8742e 100644 --- a/tests_e2e/conftest.py +++ b/tests_e2e/conftest.py @@ -1,4 +1,5 @@ import os +import platform import subprocess import tempfile from pathlib import Path @@ -10,6 +11,18 @@ TEST_DIR = Path(__file__).parent +@pytest.fixture +def snowflake_home(monkeypatch): + """ + Set up the default location of config files to [temp_dir]/.snowflake + """ + with tempfile.TemporaryDirectory() as tmp_dir: + snowflake_home = Path(tmp_dir) / ".snowflake" + snowflake_home.mkdir() + monkeypatch.setenv("SNOWFLAKE_HOME", str(snowflake_home)) + yield snowflake_home + + @pytest.fixture(scope="session") def test_root_path(): return TEST_DIR @@ -41,7 +54,7 @@ def snowcli(test_root_path): _create_venv(tmp_dir_path) _build_snowcli(tmp_dir_path, test_root_path) _install_snowcli_with_external_plugin(tmp_dir_path, test_root_path) - yield tmp_dir_path / "bin" / "snow" + yield _snow_path(tmp_dir_path) @pytest.fixture(autouse=True) @@ -58,7 +71,7 @@ def _build_snowcli(venv_path: Path, test_root_path: Path) -> None: [_python_path(venv_path), "-m", "pip", "install", "--upgrade", "build"] ) subprocess.check_call( - [_python_path(venv_path), "-m", "build", test_root_path / ".."] + [_python_path(venv_path), "-m", "build", test_root_path.parent] ) @@ -73,7 +86,9 @@ def _install_snowcli_with_external_plugin( python = _python_path(venv_path) _pip_install( python, - test_root_path / f"../dist/snowflake_cli_labs-{version}-py3-none-any.whl", + test_root_path.parent + / "dist" + / f"snowflake_cli_labs-{version}-py3-none-any.whl", ) _pip_install( python, @@ -87,4 +102,15 @@ def _install_snowcli_with_external_plugin( def _python_path(venv_path: Path) -> Path: - return venv_path / "bin" / "python" + return _bin_in_venv_path(venv_path, "python") + + +def _snow_path(venv_path: Path) -> Path: + return _bin_in_venv_path(venv_path, "snow") + + +def _bin_in_venv_path(venv_path: Path, bin_name: str) -> Path: + if platform.system() == "Windows": + return venv_path / "Scripts" / bin_name + else: + return venv_path / "bin" / bin_name diff --git a/tests_e2e/test_config.py b/tests_e2e/test_config.py new file mode 100644 index 0000000000..412d1fbaf0 --- /dev/null +++ b/tests_e2e/test_config.py @@ -0,0 +1,12 @@ +import subprocess + +import pytest + + +@pytest.mark.e2e +def test_config_file_creation(snowcli, test_root_path, snowflake_home, snapshot): + output1 = subprocess.check_output([snowcli, "connection", "list"], encoding="utf-8") + snapshot.assert_match(output1) + + output2 = subprocess.check_output([snowcli, "connection", "list"], encoding="utf-8") + snapshot.assert_match(output2) diff --git a/tests_e2e/test_error_handling.py b/tests_e2e/test_error_handling.py index db943a7fc9..413822f629 100644 --- a/tests_e2e/test_error_handling.py +++ b/tests_e2e/test_error_handling.py @@ -1,10 +1,12 @@ import os +import platform import subprocess import pytest @pytest.mark.e2e +@pytest.mark.skipif(platform.system() == "Windows", reason="Chmod issues on Windows") def test_error_traceback_disabled_without_debug(snowcli, test_root_path): traceback_msg = "Traceback (most recent call last)" config_path = test_root_path / "config" / "malformatted_config.toml" diff --git a/tests_e2e/test_installation.py b/tests_e2e/test_installation.py index 0741e751f7..2ad61c48cb 100644 --- a/tests_e2e/test_installation.py +++ b/tests_e2e/test_installation.py @@ -1,3 +1,4 @@ +import platform import subprocess from pathlib import Path @@ -5,6 +6,9 @@ @pytest.mark.e2e +@pytest.mark.skipif( + platform.system() == "Windows", reason="Snapshot comparison issues on Windows" +) def test_snow_help(snowcli, snapshot): output = subprocess.check_output([snowcli, "--help"], encoding="utf-8") snapshot.assert_match(output)