diff --git a/jubilant/_juju.py b/jubilant/_juju.py index 43acd3b..6997427 100644 --- a/jubilant/_juju.py +++ b/jubilant/_juju.py @@ -97,7 +97,36 @@ def __repr__(self) -> str: ] return f'Juju({", ".join(args)})' - # Keep the public methods in alphabetical order, so we don't have to think + @functools.cached_property + def temp_dir(self) -> str: + """Path of a temporary directory accessible by the Juju CLI. + + The directory is created and cached on first use. If :attr:`cli_binary` points to a Juju + CLI installed as a snap, this will be a directory accessible to the snap (under + ``~/snap/juju/common``), otherwise it will be under ``/tmp``. + + Example:: + + juju = jubilant.Juju() + with tempfile.NamedTemporaryFile('w+', dir=juju.temp_dir) as file: + file.write('contents') + file.flush() + juju.scp(file.name, 'ubuntu/0:/path/to/destination') + """ + if self._juju_is_snap: + # If Juju is running as a snap, we can't use /tmp, so put temp files here instead. + temp_dir = os.path.expanduser('~/snap/juju/common') + os.makedirs(temp_dir, exist_ok=True) + return temp_dir + else: + return tempfile.gettempdir() + + @functools.cached_property + def _juju_is_snap(self) -> bool: + which = shutil.which(self.cli_binary) + return which is not None and '/snap/' in which + + # Keep the public methods below in alphabetical order, so we don't have to think # about where to put each new method. def add_model( @@ -162,7 +191,7 @@ def add_secret( if info is not None: args.extend(['--info', info]) - with tempfile.NamedTemporaryFile('w+', dir=self._temp_dir) as file: + with tempfile.NamedTemporaryFile('w+', dir=self.temp_dir) as file: _yaml.safe_dump(content, file) file.flush() args.extend(['--file', file.name]) @@ -935,7 +964,7 @@ def run( args.extend(['--wait', f'{wait}s']) with ( - tempfile.NamedTemporaryFile('w+', dir=self._temp_dir) + tempfile.NamedTemporaryFile('w+', dir=self.temp_dir) if params is not None else contextlib.nullcontext() ) as params_file: @@ -977,6 +1006,9 @@ def scp( ) -> None: """Securely transfer files within a model. + A local *source* or *destination* must be accessible by the Juju CLI (important if Juju + is installed as a confined snap). See :attr:`temp_dir` for an example. + Args: source: Source of file, in format ``[[@]:]``. destination: Destination for file, in format ``[@][:]``. @@ -1177,7 +1209,7 @@ def update_secret( if auto_prune: args.append('--auto-prune') - with tempfile.NamedTemporaryFile('w+', dir=self._temp_dir) as file: + with tempfile.NamedTemporaryFile('w+', dir=self.temp_dir) as file: _yaml.safe_dump(content, file) file.flush() args.extend(['--file', file.name]) @@ -1269,21 +1301,6 @@ def wait( raise TimeoutError(f'wait timed out after {timeout}s') raise TimeoutError(f'wait timed out after {timeout}s\n{status}') - @functools.cached_property - def _juju_is_snap(self) -> bool: - which = shutil.which(self.cli_binary) - return which is not None and '/snap/' in which - - @functools.cached_property - def _temp_dir(self) -> str: - if self._juju_is_snap: - # If Juju is running as a snap, we can't use /tmp, so put temp files here instead. - temp_dir = os.path.expanduser('~/snap/juju/common') - os.makedirs(temp_dir, exist_ok=True) - return temp_dir - else: - return tempfile.gettempdir() - def _format_config(k: str, v: ConfigValue) -> str: if v is None: # type: ignore diff --git a/tests/integration/test_execution.py b/tests/integration/test_execution.py index 2f3a473..a7e979f 100644 --- a/tests/integration/test_execution.py +++ b/tests/integration/test_execution.py @@ -113,9 +113,10 @@ def test_ssh_and_scp(juju: jubilant.Juju): output = juju.ssh('snappass-test/0', 'ls', '/charm/container', container='redis') assert 'pebble' in output.split() - juju.scp('snappass-test/0:agents/unit-snappass-test-0/charm/src/charm.py', 'charm.py') - charm_src = pathlib.Path('charm.py').read_text() - assert 'class Snappass' in charm_src + pathlib.Path('scptest').write_text('SCPTEST') + juju.scp('scptest', 'snappass-test/0:/tmp/scptest2') + juju.scp('snappass-test/0:/tmp/scptest2', 'scptest3') + assert pathlib.Path('scptest3').read_text() == 'SCPTEST' juju.scp('snappass-test/0:/etc/passwd', 'passwd', container='redis') passwd = pathlib.Path('passwd').read_text() diff --git a/tests/integration/test_machine.py b/tests/integration/test_machine.py index 9ebef53..0d8a451 100644 --- a/tests/integration/test_machine.py +++ b/tests/integration/test_machine.py @@ -1,5 +1,8 @@ from __future__ import annotations +import pathlib +import tempfile + import pytest import jubilant @@ -33,6 +36,17 @@ def test_ssh(juju: jubilant.Juju): assert output == 'MACHINE\n' +def test_scp(juju: jubilant.Juju): + with tempfile.NamedTemporaryFile('w+', dir=juju.temp_dir) as file: + file.write('SCPTEST_MACHINE') + file.flush() + juju.scp(file.name, 'ubuntu/0:/tmp/scptest') + + with tempfile.NamedTemporaryFile('w+', dir=juju.temp_dir) as file: + juju.scp('ubuntu/0:/tmp/scptest', file.name) + assert pathlib.Path(file.name).read_text() == 'SCPTEST_MACHINE' + + def test_add_and_remove_unit(juju: jubilant.Juju): juju.add_unit('ubuntu') juju.wait(lambda status: jubilant.all_active(status) and len(status.apps['ubuntu'].units) == 2) diff --git a/tests/unit/test_juju_class.py b/tests/unit/test_juju_class.py index e883ee8..f5dc67f 100644 --- a/tests/unit/test_juju_class.py +++ b/tests/unit/test_juju_class.py @@ -52,11 +52,11 @@ def test_default_tempdir(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr('shutil.which', lambda _: '/bin/juju') # type: ignore juju = jubilant.Juju() - assert 'snap' not in juju._temp_dir + assert 'snap' not in juju.temp_dir def test_snap_tempdir(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr('shutil.which', lambda _: '/snap/bin/juju') # type: ignore juju = jubilant.Juju() - assert 'snap' in juju._temp_dir + assert 'snap' in juju.temp_dir