From 14ee66e9cf3af49a7c1b729d279a2ce620c2b4bf Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 2 Nov 2023 14:22:02 +0100 Subject: [PATCH 1/4] fs mount on local mk8s --- jhack/charm/mk8s_fs_mount.py | 99 ++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 jhack/charm/mk8s_fs_mount.py diff --git a/jhack/charm/mk8s_fs_mount.py b/jhack/charm/mk8s_fs_mount.py new file mode 100644 index 0000000..d9a0df5 --- /dev/null +++ b/jhack/charm/mk8s_fs_mount.py @@ -0,0 +1,99 @@ +import shlex +import subprocess +from pathlib import Path +from random import random +from subprocess import PIPE +from typing import Optional, Tuple +from jhack.logger import logger as jhack_logger +from jhack.helpers import JPopen +import getpass + +logger = jhack_logger.getChild("mk8s_fs_mount") + + +def _find_local_mk8s_mount( + unit_name: str, + model_name: Optional[str], + container_name: Optional[str] = "charm", + remote_dir="/", + *, + pwd: str, +) -> Path: + model = f" -m {model_name}" if model_name else "" + random_fname = "." + "".join(str(random())) + ".jhack_find_sentinel" + + touch = f"juju ssh --container {container_name} {unit_name}{model} touch {remote_dir}{random_fname}" + # print(touch) + + logger.debug(f"touching {random_fname} in {unit_name} : {container_name}") + proc = JPopen(shlex.split(touch)) + + proc.wait() + if err := proc.stderr.read(): + raise RuntimeError(f"failed dropping sentinel file ({err})") + + # might be something like + # /var/snap/microk8s/common/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/24918/fs/mysecretfile2.2 + # or even: + # /var/snap/microk8s/common/default-storage/graf-test-trfk-edge-configurations-d6c24946-trfk-edge-0-pvc-... + # if it's a workload container, or + # somewhere hidden in /var/snap/microk8s/common/var/lib/kubelet/pods if it's the charm container + # so we start our search at /var/snap/microk8s/common/ common denominator + search_root = "/var/snap/microk8s/common/" + + # sudo requires the flag '-S' in order to take input from stdin + find = f"sudo -S find {search_root} -name {random_fname}" + + # print(find) + + proc = subprocess.run( + find, + input=pwd, + shell=True, + stdout=subprocess.PIPE, + encoding="ascii", + ) + + return Path(proc.stdout).parent + + +def _open_with_sudo_file_browser(root: Path, *, pwd: str): + cmd = f"sudo -S xdg-open {root}" + print(f"attempting to open {root} in your local file browser...") + + subprocess.run( + cmd, + input=pwd, + shell=True, + stdout=subprocess.PIPE, + encoding="ascii", + ) + + +def _open_local_mk8s_mount( + unit_name: str, + model_name: Optional[str], + container_name: Optional[str] = "charm", + remote_dir: str = "/", +): + pwd = getpass.getpass("Please enter your password: ") + local_root = _find_local_mk8s_mount( + unit_name, model_name, container_name, remote_dir, pwd=pwd + ) + logger.info(f"found local root: {local_root}") + + _open_with_sudo_file_browser(local_root, pwd=pwd) + + +# other approach to consider: +# install https://matt.ucc.asn.au/dropbear/dropbear.html (dropbear ssh) into the target +# container and use it to fork out a ssh server, then connect over sshfs. + + +if __name__ == "__main__": + print(_open_local_mk8s_mount("trfk-edge/0", None, "charm")) + # print( + # _open_local_mk8s_mount( + # "trfk-edge/0", None, "traefik", remote_dir="/opt/traefik/juju/" + # ) + # ) From af5d5a7c3df026707d906b92d7ec1979fea75b5f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 2 Nov 2023 14:57:19 +0100 Subject: [PATCH 2/4] jhack main bind --- jhack/charm/mk8s_fs_mount.py | 52 ++++++++++++++++++++++++++++-------- jhack/main.py | 2 ++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/jhack/charm/mk8s_fs_mount.py b/jhack/charm/mk8s_fs_mount.py index d9a0df5..7b786a6 100644 --- a/jhack/charm/mk8s_fs_mount.py +++ b/jhack/charm/mk8s_fs_mount.py @@ -4,6 +4,9 @@ from random import random from subprocess import PIPE from typing import Optional, Tuple + +import typer + from jhack.logger import logger as jhack_logger from jhack.helpers import JPopen import getpass @@ -15,17 +18,19 @@ def _find_local_mk8s_mount( unit_name: str, model_name: Optional[str], container_name: Optional[str] = "charm", - remote_dir="/", + remote_dir: Optional[str] = None, *, pwd: str, ) -> Path: model = f" -m {model_name}" if model_name else "" random_fname = "." + "".join(str(random())) + ".jhack_find_sentinel" + if remote_dir is None: + remote_dir = "/var/lib/juju/" if container_name == "charm" else "/" + touch = f"juju ssh --container {container_name} {unit_name}{model} touch {remote_dir}{random_fname}" - # print(touch) - logger.debug(f"touching {random_fname} in {unit_name} : {container_name}") + print(f"touching {remote_dir}{random_fname} in {unit_name} : {container_name}") proc = JPopen(shlex.split(touch)) proc.wait() @@ -74,7 +79,7 @@ def _open_local_mk8s_mount( unit_name: str, model_name: Optional[str], container_name: Optional[str] = "charm", - remote_dir: str = "/", + remote_dir: Optional[str] = None, ): pwd = getpass.getpass("Please enter your password: ") local_root = _find_local_mk8s_mount( @@ -90,10 +95,35 @@ def _open_local_mk8s_mount( # container and use it to fork out a ssh server, then connect over sshfs. -if __name__ == "__main__": - print(_open_local_mk8s_mount("trfk-edge/0", None, "charm")) - # print( - # _open_local_mk8s_mount( - # "trfk-edge/0", None, "traefik", remote_dir="/opt/traefik/juju/" - # ) - # ) +def open_local_mk8s_mount( + unit_name: str = typer.Argument(..., help="The target unit."), + model: str = typer.Option( + None, + "-m", + "--model", + help="Model the target unit is in. Defaults to the current model.", + ), + container_name: str = typer.Option( + None, + "-c", + "--container-name", + help="Container name to target. Defaults to ``charm``", + ), + container_dir: str = typer.Option( + None, "--container-dir", help="The directory in the container to open." + ), +): + """Open a juju-owned charm or workload container as if it were a locally mounted filesystem. + + NB Currently only works on local (as in localhost) development kubernetes deployments. + NB Requires your admin password in order to do some hopefully harmless hackery that has been described as: + - horrific + - disgusting + - ungodly + - cursed + - charming + + NB if a directory appears empty, it might be that it's mounted to some place we can't reach it easily. + Use container_dir to open that directory DIRECTLY, and you should be able to xplore it. + """ + return _open_local_mk8s_mount(unit_name, model, container_name, container_dir) diff --git a/jhack/main.py b/jhack/main.py index cc54f18..130935c 100755 --- a/jhack/main.py +++ b/jhack/main.py @@ -27,6 +27,7 @@ def main(): from jhack.charm.provision import provision from jhack.charm.record import record from jhack.charm.repack import refresh + from jhack.charm.mk8s_fs_mount import open_local_mk8s_mount from jhack.charm.sync import sync as sync_packed_charm from jhack.charm.update import update from jhack.charm.vinfo import vinfo @@ -85,6 +86,7 @@ def main(): charm.command(name="func", no_args_is_help=True)(functional.run) charm.command(name="sync", no_args_is_help=True)(sync_packed_charm) charm.command(name="vinfo", no_args_is_help=True)(vinfo) + charm.command(name="xplore", no_args_is_help=True)(open_local_mk8s_mount) charm.command(name="provision")(provision) replay = typer.Typer(name="replay", help="Commands to replay events.") From d3abe7793e30e49e487211e19e1f65ce7e835820 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 3 Nov 2023 07:57:16 +0100 Subject: [PATCH 3/4] xplore for machine models --- jhack/charm/{mk8s_fs_mount.py => xplore.py} | 80 ++++++++++++++++----- jhack/main.py | 4 +- 2 files changed, 64 insertions(+), 20 deletions(-) rename jhack/charm/{mk8s_fs_mount.py => xplore.py} (58%) diff --git a/jhack/charm/mk8s_fs_mount.py b/jhack/charm/xplore.py similarity index 58% rename from jhack/charm/mk8s_fs_mount.py rename to jhack/charm/xplore.py index 7b786a6..d552e4e 100644 --- a/jhack/charm/mk8s_fs_mount.py +++ b/jhack/charm/xplore.py @@ -1,15 +1,15 @@ +import getpass import shlex import subprocess from pathlib import Path from random import random -from subprocess import PIPE -from typing import Optional, Tuple +from tempfile import TemporaryDirectory +from typing import Optional, Union import typer +from jhack.helpers import JPopen, get_substrate, check_command_available, show_unit from jhack.logger import logger as jhack_logger -from jhack.helpers import JPopen -import getpass logger = jhack_logger.getChild("mk8s_fs_mount") @@ -17,14 +17,14 @@ def _find_local_mk8s_mount( unit_name: str, model_name: Optional[str], - container_name: Optional[str] = "charm", + container_name: Optional[str] = None, remote_dir: Optional[str] = None, *, pwd: str, ) -> Path: model = f" -m {model_name}" if model_name else "" random_fname = "." + "".join(str(random())) + ".jhack_find_sentinel" - + container_name = container_name or "charm" if remote_dir is None: remote_dir = "/var/lib/juju/" if container_name == "charm" else "/" @@ -59,12 +59,21 @@ def _find_local_mk8s_mount( encoding="ascii", ) - return Path(proc.stdout).parent + local_root = Path(proc.stdout).parent + # clean up that weird file + cleanup = f"juju ssh --container {container_name} {unit_name}{model} rm {remote_dir}{random_fname}" + JPopen(shlex.split(cleanup)).wait() + return local_root -def _open_with_sudo_file_browser(root: Path, *, pwd: str): - cmd = f"sudo -S xdg-open {root}" - print(f"attempting to open {root} in your local file browser...") + +def _open_with_file_browser(root: Union[str, Path], *, pwd: Optional[str]): + if pwd: + print(f"attempting to open {root} as root in your local file browser...") + cmd = f"sudo -S xdg-open {root}" + else: + print(f"attempting to open {root} in your local file browser...") + cmd = f"xdg-open {root}" subprocess.run( cmd, @@ -75,10 +84,29 @@ def _open_with_sudo_file_browser(root: Path, *, pwd: str): ) -def _open_local_mk8s_mount( +def _xplore_machine( unit_name: str, model_name: Optional[str], - container_name: Optional[str] = "charm", + remote_dir: Optional[str] = None, + user: Optional[str] = None, +): + if not check_command_available("sshfs"): + exit(f"sshfs not installed; please install it and try again.") + + td = TemporaryDirectory() + user = user or "ubuntu" + remote_dir = remote_dir or "/" + machine_ip = show_unit(unit_name, model_name)["public-address"] + JPopen(shlex.split(f"sshfs {user}@{machine_ip}:{remote_dir} {td.name}")) + + print(f"{user}@{machine_ip}:{remote_dir} mounted on {td.name}") + _open_with_file_browser(td.name, pwd=None) + + +def _xplore_k8s( + unit_name: str, + model_name: Optional[str], + container_name: Optional[str] = None, remote_dir: Optional[str] = None, ): pwd = getpass.getpass("Please enter your password: ") @@ -87,7 +115,21 @@ def _open_local_mk8s_mount( ) logger.info(f"found local root: {local_root}") - _open_with_sudo_file_browser(local_root, pwd=pwd) + _open_with_file_browser(local_root, pwd=pwd) + + +def _xplore( + unit_name: str, + model_name: Optional[str], + container_name: Optional[str] = None, + remote_dir: Optional[str] = None, +): + if get_substrate(model_name) == "k8s": + return _xplore_k8s(unit_name, model_name, container_name, remote_dir) + else: + if container_name is not None: + logger.warning("container_name option is meaningless in machine models.") + return _xplore_machine(unit_name, model_name, remote_dir) # other approach to consider: @@ -95,7 +137,7 @@ def _open_local_mk8s_mount( # container and use it to fork out a ssh server, then connect over sshfs. -def open_local_mk8s_mount( +def xplore( unit_name: str = typer.Argument(..., help="The target unit."), model: str = typer.Option( None, @@ -107,7 +149,7 @@ def open_local_mk8s_mount( None, "-c", "--container-name", - help="Container name to target. Defaults to ``charm``", + help="Container name to target. Defaults to ``charm``. Only meaningful on k8s models.", ), container_dir: str = typer.Option( None, "--container-dir", help="The directory in the container to open." @@ -115,8 +157,10 @@ def open_local_mk8s_mount( ): """Open a juju-owned charm or workload container as if it were a locally mounted filesystem. - NB Currently only works on local (as in localhost) development kubernetes deployments. - NB Requires your admin password in order to do some hopefully harmless hackery that has been described as: + NB Currently only works on local (as in localhost) development kubernetes deployments, + and only if it has hostpath storage enabled, or on local machine models. + + NB Requires your admin password in order to do some (hopefully harmless) hackery that has been described as: - horrific - disgusting - ungodly @@ -126,4 +170,4 @@ def open_local_mk8s_mount( NB if a directory appears empty, it might be that it's mounted to some place we can't reach it easily. Use container_dir to open that directory DIRECTLY, and you should be able to xplore it. """ - return _open_local_mk8s_mount(unit_name, model, container_name, container_dir) + return _xplore(unit_name, model, container_name, container_dir) diff --git a/jhack/main.py b/jhack/main.py index 130935c..c4bf732 100755 --- a/jhack/main.py +++ b/jhack/main.py @@ -27,7 +27,7 @@ def main(): from jhack.charm.provision import provision from jhack.charm.record import record from jhack.charm.repack import refresh - from jhack.charm.mk8s_fs_mount import open_local_mk8s_mount + from jhack.charm.xplore import xplore from jhack.charm.sync import sync as sync_packed_charm from jhack.charm.update import update from jhack.charm.vinfo import vinfo @@ -86,7 +86,7 @@ def main(): charm.command(name="func", no_args_is_help=True)(functional.run) charm.command(name="sync", no_args_is_help=True)(sync_packed_charm) charm.command(name="vinfo", no_args_is_help=True)(vinfo) - charm.command(name="xplore", no_args_is_help=True)(open_local_mk8s_mount) + charm.command(name="xplore", no_args_is_help=True)(xplore) charm.command(name="provision")(provision) replay = typer.Typer(name="replay", help="Commands to replay events.") From af1852996621468fa75f5981596d1b5c46891cbc Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 3 Nov 2023 09:17:03 +0100 Subject: [PATCH 4/4] almost done with cleanup script --- jhack/charm/xplore.py | 39 ++++++++++++++++++++++++++++++++++----- jhack/helpers.py | 1 + pyproject.toml | 4 +--- snap/snapcraft.yaml | 14 ++++++++++++-- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/jhack/charm/xplore.py b/jhack/charm/xplore.py index d552e4e..d1553de 100644 --- a/jhack/charm/xplore.py +++ b/jhack/charm/xplore.py @@ -1,13 +1,15 @@ import getpass +import os import shlex import subprocess from pathlib import Path from random import random -from tempfile import TemporaryDirectory +from tempfile import TemporaryDirectory, mkdtemp from typing import Optional, Union import typer +from jhack.config import IS_SNAPPED, get_jhack_data_path from jhack.helpers import JPopen, get_substrate, check_command_available, show_unit from jhack.logger import logger as jhack_logger @@ -93,14 +95,36 @@ def _xplore_machine( if not check_command_available("sshfs"): exit(f"sshfs not installed; please install it and try again.") - td = TemporaryDirectory() + # FIXME: + # if we're snapped, we need to create tempdirs in some specific place, or we'll be apparmor'd + # when we try to mount a fuse filesystem in it. + # we create the tempdir in your home folder because apparently we can't write $SNAP_DATA (??) + # either way, the directory appears to be empty so there's something going wrong with the + # mount. + + mounts_path = get_jhack_data_path() / "xplore-mounts" + mounts_path.mkdir(exist_ok=True) + td = Path(mkdtemp(dir=mounts_path)) + user = user or "ubuntu" remote_dir = remote_dir or "/" machine_ip = show_unit(unit_name, model_name)["public-address"] - JPopen(shlex.split(f"sshfs {user}@{machine_ip}:{remote_dir} {td.name}")) + proc = JPopen( + shlex.split(f"sshfs {user}@{machine_ip}:{remote_dir} {td}"), wait=True + ) + sshfs_pid = proc.pid + + print(f"{user}@{machine_ip}:{remote_dir} mounted on {td}; pid={sshfs_pid}") + _open_with_file_browser(td, pwd=None) - print(f"{user}@{machine_ip}:{remote_dir} mounted on {td.name}") - _open_with_file_browser(td.name, pwd=None) + cleanup_script = [f"kill -9 {sshfs_pid}", f"umount -f {td}", f"rm -rf {td}"] + + cleanup_script_file = td.with_name("cleanup_" + td.name) + cleanup_script_file.write_text("\n".join(cleanup_script)) + + print( + f"When you are done, cleanup the mount and resources with: \n sudo {cleanup_script_file}" + ) def _xplore_k8s( @@ -170,4 +194,9 @@ def xplore( NB if a directory appears empty, it might be that it's mounted to some place we can't reach it easily. Use container_dir to open that directory DIRECTLY, and you should be able to xplore it. """ + if IS_SNAPPED: + exit( + "this command is not supported in snapped mode because strict confinement is *tough*." + "Use jhack from sources (or pypi)." + ) return _xplore(unit_name, model, container_name, container_dir) diff --git a/jhack/helpers.py b/jhack/helpers.py index 3f82c32..ccb9890 100644 --- a/jhack/helpers.py +++ b/jhack/helpers.py @@ -19,6 +19,7 @@ try: from enum import StrEnum except ImportError: + # older python versions support from enum import Enum class StrEnum(str, Enum): diff --git a/pyproject.toml b/pyproject.toml index e00cda2..ddf8131 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jhack" -# TODO: keep snapcraft.yaml in sync with version! -version = "0.3.21" +version = "0.4" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] @@ -14,7 +13,6 @@ license.text = "Apache 2.0" keywords = ["juju", "hacks", "cli", "charm", "charming"] urls.Source = "https://github.com/PietroPasotti/jhack" dependencies = [ - # TODO: keep snapcraft.yaml in sync with these! "typer(==0.7.0)", "ops(==1.5.3)", "rich(==13.3.0)", diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index c5ee153..9e84066 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -32,6 +32,8 @@ parts: snapcraftctl build VERSION=$(cat ./pyproject.toml | grep -Po 'version = "\K[^"]*') snapcraftctl set-version $VERSION + stage-packages: + - sshfs stage-snaps: - juju/3.3/beta @@ -41,13 +43,13 @@ apps: plugs: - network - network-bind - - # do we need to add the custom plugs here as well? - dot-local-share-juju - dot-config-jhack - shared-memory - home-read - ssh-read + - fuse-support + - desktop plugs: # read-write access to .local/share/juju (JUJU_DATA) @@ -74,3 +76,11 @@ plugs: # access ssh keys to make them available to the embedded juju snap ssh-read: interface: ssh-keys + + # use xdg-open + desktop: + interface: desktop + + # allow sshfs to bind /dev/fuse + fuse-support: + interface: fuse-support \ No newline at end of file