diff --git a/pyproject.toml b/pyproject.toml index 18ba011..514fb73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] -name = "shinylive_deloy_webserver" -version = "2024.6.29" +name = "shinylive_deloy" +version = "2024.7.1" authors = [ {name="darrida", email="darrida.py@gmail.com"} ] @@ -22,6 +22,9 @@ tests = [ 'shinyswatch', ] +[project.scripts] +shinylive_deploy = "shinylive_deploy.app:main" + [project.urls] Homepage = "https://github.com/darrida/py-shinylive-deploy-webserver" Issues = "https://github.com/darrida/py-shinylive-deploy-webserver/issues" diff --git a/shiny_deploy.toml b/shiny_deploy.toml index 0b358ca..dcefc4b 100644 --- a/shiny_deploy.toml +++ b/shiny_deploy.toml @@ -20,4 +20,4 @@ host = "127.0.0.1" user = "shinylive" port = 2222 directory = "shinyapps" -base_url = "http://localhost:5000/" \ No newline at end of file +base_url = "http://localhost:5000" \ No newline at end of file diff --git a/shinylive_deploy/__init__.py b/shinylive_deploy/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/shinylive_deploy/models.py b/shinylive_deploy/models.py deleted file mode 100644 index 0708e5e..0000000 --- a/shinylive_deploy/models.py +++ /dev/null @@ -1,208 +0,0 @@ -import subprocess -import sys -from contextlib import contextmanager -from dataclasses import dataclass -from pathlib import Path, PurePosixPath -from typing import Literal - -import git -import paramiko -import tomllib -from paramiko import AutoAddPolicy, SFTPClient, SSHClient -from pydantic import SecretStr - -repo = git.Repo() - -with open("shiny_deploy.toml", "rb") as f: - toml = tomllib.load(f) - -deploy_local = toml["deploy"]["local"] -deploy_server = toml["deploy"]["server"] -development: dict = toml.get("development", {}) -staging: dict = toml["deploy"].get("staging", {}) -gitbranch: dict = toml["deploy"].get("gitbranch", {}) - -subprocess_config = { - "capture_output": True, "text": True, - "shell": True, "check": True -} - -@dataclass -class ShinyDeploy: - base_url: str - app_name: str = toml["general"]["app_name"] - dir_deployment: str = None - dir_development: str = development.get("directory", "src") - dir_staging: str = staging.get("directory", "staging") - prod_branch: str = gitbranch.get("prod", "main") - beta_branch: str = gitbranch.get("beta", "main") - mode: Literal["local", "test", "beta", "prod"] = None - - @property - def deploy_name(self): - modes = {"prod": "", "beta": "-beta", "test": "-test", "local": ""} - return self.app_name + modes[self.mode] - - def _message(self): - print( - "\n##################################" - f"\nDEPLOYMENT MODE: {self.mode}" - f"\nDEPLOYMENT NAME: {self.deploy_name}" - "\n##################################" - ) - - def _check_requirements(self): - # if len(sys.argv) < 2 or sys.argv[1] not in ("test", "beta", "prod", "local"): - # raise ValueError("\nERROR: One of the following arguments is required -> [ local | test | beta | prod ]\n") - # self.mode = sys.argv[1] - if self.mode == "prod" and str(repo.active_branch) != self.prod_branch: - raise DeployException(f"Missing Requirement: `prod` deployments can only be executed from the `{self.prod_branch}` branch") - elif self.mode == "beta" and str(repo.active_branch) != self.beta_branch: - raise DeployException(f"Missing Requirement: `beta` deployments can only be executed from the `{self.beta_branch}` branch") - - def _compile(self): - cmd = f"shinylive export {Path(self.dir_development)} {Path(self.dir_staging) / self.deploy_name}" - print(f"\nExport Command: {cmd}") - subprocess.run(cmd, shell=True, check=True) - - -@dataclass -class ServerShinyDeploy(ShinyDeploy): - host: str = None - user: str = None - port: int = 22 - password: SecretStr = None - - @property - def base_ssh_cmd(self): - return f"ssh -p {self.port} {self.user}:{self.password.get_secret_value()}@{self.host}" - - def ssh_connection(self, client: SSHClient) -> SSHClient: - client.set_missing_host_key_policy(AutoAddPolicy()) - client.connect( - hostname=self.host, port=self.port, username=self.user, password=self.password.get_secret_value() - ) - return client - - def deployed_dir_exists(self, sftp: SFTPClient): - existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name - # result = subprocess.run(f"{self.base_ssh_cmd} ls {self.dir_deployment}", **subprocess_config) - # directories = result.stdout.split("\n") - directories = sftp.listdir(str(self.dir_deployment)) - if Path(existing_deploy_dir).name in directories: - return True - return False - - def backup_dir_exists(self, sftp: SFTPClient): - existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name - # result = subprocess.run(f"{self.base_ssh_cmd} ls {self.dir_deployment}", **subprocess_config) - # directories = result.stdout.split("\n") - directories = sftp.listdir(str(self.dir_deployment)) - if Path(f"{existing_deploy_dir}-backup").name in directories: - return True - return False - - def deploy(self, testing: bool = False): - if not all([self.host, self.user, self.password]): - raise ValueError("For ServerShinyDeploy, all of the following are required: host, user, password") - self._check_requirements() - self._message() - self._compile() - - with SSHClient() as ssh: - ssh = self.ssh_connection(ssh) - sftp = ssh.open_sftp() - - staging_dir = Path(self.dir_staging) / self.deploy_name - deployment_dir = PurePosixPath(self.dir_deployment) / self.deploy_name - if self.deployed_dir_exists(sftp): - if self.backup_dir_exists(sftp): - print("\n>>> WARNING <<<: Deployment STOPPED. Backup directory already exists. Delete backup directory, or rollback before redeploying.\n") - return - # subprocess.run(f"{self.base_ssh_cmd} mv {deployment_dir} {deployment_dir}-backup", shell=True) - sftp.rename(str(deployment_dir), f"{deployment_dir}-backup") - # subprocess.run(f"{self.base_ssh_cmd} mkdir shinyapps/app1-test", shell=True, check=True) - sftp.mkdir(str(deployment_dir)) - cmd = f"pscp -P {self.port} -r -pw {staging_dir} {self.user}@{self.host}:{self.dir_deployment}" # /homes/user/docker_volumes/shinyapps/ - print(f"PSCP Command: {cmd}") - if testing: - return - subprocess.run(cmd.replace("", self.password.get_secret_value()), shell=True, check=True) - print( - "\nCOMPLETE:" - f"\n- `{self.app_name}` compiled and deployed to webserver" - f"\n- App available at {self.base_url}/{self.deploy_name}" - ) - - def rollback(self): - self._check_requirements() - deployment_dir = PurePosixPath(self.dir_deployment) / self.deploy_name - - with SSHClient() as ssh: - ssh = self.ssh_connection(ssh) - sftp = ssh.open_sftp() - if not self.deployed_dir_exists(sftp): - print("\n>>> WARNING <<<: Backback STOPPED. No app directory exists to rollback from.\n") - return - if not self.backup_dir_exists(sftp): - print("\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n") - return - ssh.exec_command(f"rm -rf {deployment_dir}") - ssh.exec_command(f"mv {deployment_dir}-backup {deployment_dir}") - - -class LocalShinyDeploy(ShinyDeploy): - def deployed_dir_exists(self): - existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name - result = subprocess.run(f"ls {self.dir_deployment}", **subprocess_config) - directories = result.stdout.split("\n") - if Path(existing_deploy_dir).name in directories: - return True - return False - - def backup_dir_exists(self): - existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name - result = subprocess.run(f"ls {self.dir_deployment}", **subprocess_config) - directories = result.stdout.split("\n") - if Path(f"{existing_deploy_dir}-backup").name in directories: - return True - return False - - def deploy(self): - self._check_requirements() - self._message() - self._compile() - - staging_dir = Path(self.dir_staging) / self.deploy_name - existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name - if self.deployed_dir_exists(): - if self.backup_dir_exists(): - print("\n>>> WARNING <<<: Deployment STOPPED. Backup directory already exists. Delete backup directory, or rollback before redeploying.\n") - return - cmd = f"mv {existing_deploy_dir} {existing_deploy_dir}-backup" - print(f"Backup move command: {cmd}") - subprocess.run(f"mv {existing_deploy_dir} {existing_deploy_dir}-backup", shell=True) - cmd = f"mv {staging_dir} {Path(self.dir_deployment)}" - print(f"Local Move Command: {cmd}") - subprocess.run(cmd, shell=True, check=True) - print( - "\nCOMPLETE:" - f"\n- Application `{self.app_name}` compiled and deployed locally as `{self.deploy_name}`" - f"\n- Available at {self.base_url}/{self.deploy_name}" - ) - - def rollback(self): - self._check_requirements() - existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name - if not self.deployed_dir_exists(): - print("\n>>> WARNING <<<: Backback STOPPED. No app directory exists to rollback from.\n") - return - if not self.backup_dir_exists(): - print("\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n") - return - subprocess.run(f"rm -r {existing_deploy_dir}", **subprocess_config) - subprocess.run(f"mv {existing_deploy_dir}-backup {existing_deploy_dir}", **subprocess_config) - - -class DeployException(Exception): - pass \ No newline at end of file diff --git a/shinylive_deploy/main.py b/src/shinylive_deploy/app.py similarity index 66% rename from shinylive_deploy/main.py rename to src/shinylive_deploy/app.py index 7233706..ca46040 100644 --- a/shinylive_deploy/main.py +++ b/src/shinylive_deploy/app.py @@ -1,11 +1,28 @@ import sys from getpass import getpass +from pathlib import Path -from models import LocalShinyDeploy, ServerShinyDeploy, toml from pydantic import SecretStr +from shinylive_deploy.data import create_config_file +from shinylive_deploy.models import LocalShinyDeploy, ServerShinyDeploy, toml -if __name__ == "__main__": - # parse arguments + +def main(): + config_file = Path.cwd() / "shinylive_deploy.toml" + if not config_file.exists(): + create_config_file() + return + + mode, rollback = _parse_arguments() + shinylive_ = _initialize_configuration(mode) + + if rollback is True: + shinylive_.rollback() + else: + shinylive_.deploy() + + +def _parse_arguments() -> tuple[str, bool]: if len(sys.argv) < 2 or sys.argv[1] not in ("test", "beta", "prod", "local"): raise ValueError("\nERROR: One of the following arguments is required -> [ local | test | beta | prod ]\n") deploy_mode = sys.argv[1] @@ -16,11 +33,13 @@ rollback = True except IndexError: rollback = False + return deploy_mode, rollback - # initialize configuration + +def _initialize_configuration(deploy_mode: str): if deploy_mode in ("test", "beta", "prod"): config = toml["deploy"]["server"] - shinylive_ = ServerShinyDeploy( + return ServerShinyDeploy( mode=deploy_mode, base_url=config["base_url"], dir_deployment=config["directory"], @@ -31,14 +50,12 @@ ) else: # local config = toml["deploy"]["local"] - shinylive_ = LocalShinyDeploy( + return LocalShinyDeploy( mode=deploy_mode, base_url=config["base_url"], dir_deployment=config["directory"] ) - - # execute deployment change - if rollback is True: - shinylive_.rollback() - else: - shinylive_.deploy() \ No newline at end of file + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/shinylive_deploy/data.py b/src/shinylive_deploy/data.py new file mode 100644 index 0000000..daca3c8 --- /dev/null +++ b/src/shinylive_deploy/data.py @@ -0,0 +1,33 @@ +from pathlib import Path + + +def create_config_file(): + text = """ +[general] +app_name = "app1" + +[development] +directory = "src_dev/app1" + +[deploy.gitbranch] +prod = "main" +beta = "main" + +[deploy.staging] +directory = "staging" + +[deploy.local] +directory = "src_test_webserver/shinyapps/" +base_url = "localhost:8000/apps" + +[deploy.server] +host = "127.0.0.1" +user = "shinylive" +port = 2222 +directory = "shinyapps" +base_url = "http://localhost:5000" +""" + + with open(Path.cwd() / "shinylive_deploy.toml", "w") as f: + f.write(text) + \ No newline at end of file diff --git a/src/shinylive_deploy/models/__init__.py b/src/shinylive_deploy/models/__init__.py new file mode 100644 index 0000000..8d15471 --- /dev/null +++ b/src/shinylive_deploy/models/__init__.py @@ -0,0 +1,5 @@ +from .base import toml +from .local import LocalShinyDeploy +from .server import ServerShinyDeploy + +__all__ = [toml, LocalShinyDeploy, ServerShinyDeploy] diff --git a/src/shinylive_deploy/models/base.py b/src/shinylive_deploy/models/base.py new file mode 100644 index 0000000..b4c046d --- /dev/null +++ b/src/shinylive_deploy/models/base.py @@ -0,0 +1,62 @@ +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +import git +import tomllib + +repo = git.Repo() + +with open("shiny_deploy.toml", "rb") as f: + toml = tomllib.load(f) + +deploy_local = toml["deploy"]["local"] +deploy_server = toml["deploy"]["server"] +development: dict = toml.get("development", {}) +staging: dict = toml["deploy"].get("staging", {}) +gitbranch: dict = toml["deploy"].get("gitbranch", {}) + + + +@dataclass +class ShinyDeploy: + base_url: str + app_name: str = toml["general"]["app_name"] + dir_deployment: str = None + dir_development: str = development.get("directory", "src") + dir_staging: str = staging.get("directory", "staging") + prod_branch: str = gitbranch.get("prod", "main") + beta_branch: str = gitbranch.get("beta", "main") + mode: Literal["local", "test", "beta", "prod"] = None + + @property + def deploy_name(self): + modes = {"prod": "", "beta": "-beta", "test": "-test", "local": ""} + return self.app_name + modes[self.mode] + + def _message(self): + print( + "\n##################################" + f"\nDEPLOYMENT MODE: {self.mode}" + f"\nDEPLOYMENT NAME: {self.deploy_name}" + "\n##################################" + ) + + def _check_requirements(self): + # if len(sys.argv) < 2 or sys.argv[1] not in ("test", "beta", "prod", "local"): + # raise ValueError("\nERROR: One of the following arguments is required -> [ local | test | beta | prod ]\n") + # self.mode = sys.argv[1] + if self.mode == "prod" and str(repo.active_branch) != self.prod_branch: + raise DeployException(f"Missing Requirement: `prod` deployments can only be executed from the `{self.prod_branch}` branch") + elif self.mode == "beta" and str(repo.active_branch) != self.beta_branch: + raise DeployException(f"Missing Requirement: `beta` deployments can only be executed from the `{self.beta_branch}` branch") + + def _compile(self): + cmd = f"shinylive export {Path(self.dir_development)} {Path(self.dir_staging) / self.deploy_name}" + print(f"\nExport Command: {cmd}") + subprocess.run(cmd, shell=True, check=True) + + +class DeployException(Exception): + pass \ No newline at end of file diff --git a/src/shinylive_deploy/models/local.py b/src/shinylive_deploy/models/local.py new file mode 100644 index 0000000..927486a --- /dev/null +++ b/src/shinylive_deploy/models/local.py @@ -0,0 +1,70 @@ +import subprocess +from pathlib import Path + +from .base import ShinyDeploy + +subprocess_config = {"capture_output": True, "text": True, "shell": True, "check": True} + +class LocalShinyDeploy(ShinyDeploy): + def deploy(self): + self._check_requirements() + self._message() + self._compile() + + has_backup = self.__manage_backup() + if has_backup is None: + return + + staging_dir = Path(self.dir_staging) / self.deploy_name + subprocess.run(f"mv {staging_dir} {Path(self.dir_deployment)}", shell=True, check=True) + + print( + "\nCOMPLETE:" + f"\n- Application `{self.app_name}` compiled and deployed locally as `{self.deploy_name}`" + f"\n- Available at {self.base_url}/{self.deploy_name}" + f"\n- Backup available at {self.base_url}/{self.deploy_name}-backup" if has_backup is True else "" + ) + + def rollback(self): + self._check_requirements() + existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name + if not self.__deployed_dir_exists(): + print("\n>>> WARNING <<<: Backback STOPPED. No app directory exists to rollback from.\n") + return + if not self.__backup_dir_exists(): + print("\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n") + return + subprocess.run(f"rm -r {existing_deploy_dir}", **subprocess_config) + print(f"\n1. Removed `{self.deploy_name}`") + subprocess.run(f"mv {existing_deploy_dir}-backup {existing_deploy_dir}", **subprocess_config) + print(f"2. Renamed `{self.deploy_name}-backup` as `{self.deploy_name}`") + + print( + "\nROLLBACK COMPLETE:" + f"\n- Application `{self.app_name}` rolled back locally as `{self.deploy_name}`" + f"\n- Available at {self.base_url}/{self.deploy_name}" + ) + + def __deployed_dir_exists(self): + result = subprocess.run(f"ls {self.dir_deployment}", **subprocess_config) + directories = result.stdout.split("\n") + if Path(self.deploy_name).name in directories: + return True + return False + + def __backup_dir_exists(self): + result = subprocess.run(f"ls {self.dir_deployment}", **subprocess_config) + directories = result.stdout.split("\n") + if Path(f"{self.deploy_name}-backup").name in directories: + return True + return False + + def __manage_backup(self): + existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name + if self.__deployed_dir_exists(): + if self.__backup_dir_exists(): + print("\n>>> WARNING <<<: Deployment STOPPED. Backup directory already exists. Delete backup directory, or rollback before redeploying.\n") + return None + subprocess.run(f"mv {existing_deploy_dir} {existing_deploy_dir}-backup", shell=True) + return True + return False diff --git a/src/shinylive_deploy/models/server.py b/src/shinylive_deploy/models/server.py new file mode 100644 index 0000000..785924c --- /dev/null +++ b/src/shinylive_deploy/models/server.py @@ -0,0 +1,112 @@ +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +from paramiko import AutoAddPolicy, SFTPClient, SSHClient +from pydantic import SecretStr + +from .base import ShinyDeploy + +subprocess_config = {"capture_output": True, "text": True, "shell": True, "check": True} + + +@dataclass +class ServerShinyDeploy(ShinyDeploy): + host: str = None + user: str = None + port: int = 22 + password: SecretStr = None + + @property + def base_ssh_cmd(self): + return f"ssh -p {self.port} {self.user}:{self.password.get_secret_value()}@{self.host}" + + def deploy(self, testing: bool = False): + if not all([self.host, self.user, self.password]): + raise ValueError("For ServerShinyDeploy, all of the following are required: host, user, password") + self._check_requirements() + self._message() + self._compile() + + with SSHClient() as ssh: + ssh = self.__ssh_connection(ssh) + sftp = ssh.open_sftp() + + has_backup = self.__manage_backup(sftp) + if has_backup is None: + return + + self.__push_app(sftp, testing) + + print( + "\nCOMPLETE:" + f"\n- `{self.app_name}` compiled and deployed to webserver as `{self.deploy_name}`" + f"\n- App available at {self.base_url}/{self.deploy_name}" + f"\n- Backup available at {self.base_url}/{self.deploy_name}-backup" if has_backup is True else "" + ) + + def rollback(self): + self._check_requirements() + deployment_dir = PurePosixPath(self.dir_deployment) / self.deploy_name + + with SSHClient() as ssh: + ssh = self.__ssh_connection(ssh) + sftp = ssh.open_sftp() + if not self.__deployed_dir_exists(sftp): + print("\n>>> WARNING <<<: Backback STOPPED. No app directory exists to rollback from.\n") + return + if not self.__backup_dir_exists(sftp): + print("\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n") + return + + ssh.exec_command(f"rm -rf {deployment_dir}") + print(f"\n1. Removed `{self.deploy_name}`") + ssh.exec_command(f"mv {deployment_dir}-backup {deployment_dir}") + print(f"2. Renamed `{self.deploy_name}-backup` as `{self.deploy_name}`") + + print( + "\nROLLBACK COMPLETE:" + f"\n- App name: `{self.app_name}`" + f"\n- Available at {self.base_url}/{self.deploy_name}" + ) + + def __ssh_connection(self, client: SSHClient) -> SSHClient: + client.set_missing_host_key_policy(AutoAddPolicy()) + client.connect( + hostname=self.host, port=self.port, username=self.user, password=self.password.get_secret_value() + ) + return client + + def __deployed_dir_exists(self, sftp: SFTPClient): + directories = sftp.listdir(str(self.dir_deployment)) + if self.deploy_name in directories: + return True + return False + + def __backup_dir_exists(self, sftp: SFTPClient): + directories = sftp.listdir(str(self.dir_deployment)) + if f"{self.deploy_name}-backup" in directories: + return True + return False + + def __manage_backup(self, sftp: SFTPClient): + deployment_filepath = PurePosixPath(self.dir_deployment) / self.deploy_name + print(deployment_filepath) + if self.__deployed_dir_exists(sftp): + if self.__backup_dir_exists(sftp): + print("\n>>> WARNING <<<: Deployment STOPPED. Backup directory already exists. Delete backup directory, or rollback before redeploying.\n") + return None + sftp.rename(str(deployment_filepath), f"{deployment_filepath}-backup") + return True + return False + + def __push_app(self, sftp: SFTPClient, testing: bool = False): + staging_filepath = Path(self.dir_staging) / self.deploy_name + + sftp.mkdir(str(PurePosixPath(self.dir_deployment) / self.deploy_name)) + cmd = f"pscp -P {self.port} -r -pw {staging_filepath} {self.user}@{self.host}:{self.dir_deployment}" # /homes/user/docker_volumes/shinyapps/ + print(f"PSCP Command: {cmd}") + if testing: + return + subprocess.run(cmd.replace("", self.password.get_secret_value()), shell=True, check=True) \ No newline at end of file