├── MANIFEST.in ├── tests ├── conftest.py ├── e2e_tests │ ├── __init__.py │ ├── test_deployment.py │ └── utils.py └── integration_tests │ ├── reference_files │ ├── .gitignore │ ├── Caddyfile │ ├── plugin_help_text.txt │ ├── requirements.txt │ ├── Pipfile │ ├── gunicorn.service │ ├── pyproject.toml │ ├── serve_project.sh │ └── settings.py │ ├── test_custom_cli_arg.py │ ├── test_help_output.py │ └── test_vps_config.py ├── dsd_vps ├── __init__.py ├── templates │ ├── gunicorn.socket │ ├── git_ssh_config_block.txt │ ├── Caddyfile │ ├── post-receive │ ├── gunicorn.service │ ├── dockerfile_example │ ├── settings.py │ └── serve_project.sh ├── deploy.py ├── plugin_config.py ├── cli.py ├── deploy_messages.py ├── platform_deployer.py └── utils.py ├── .gitignore ├── CHANGELOG.md ├── developer_resources └── README.md ├── requirements.txt ├── LICENSE ├── pyproject.toml └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | recursive-include dsd_vps/templates * 3 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """tests/conftest.py for dsd_vps.""" 2 | 3 | collect_ignore = ["e2e_tests"] -------------------------------------------------------------------------------- /dsd_vps/__init__.py: -------------------------------------------------------------------------------- 1 | from .deploy import dsd_get_plugin_config 2 | from .deploy import dsd_deploy 3 | -------------------------------------------------------------------------------- /tests/e2e_tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is needed to support importing helper functions from utils/. 2 | -------------------------------------------------------------------------------- /tests/integration_tests/reference_files/.gitignore: -------------------------------------------------------------------------------- 1 | b_env/ 2 | .venv/ 3 | 4 | __pycache__/ 5 | *.pyc 6 | .DS_Store 7 | 8 | db.sqlite3 9 | 10 | dsd_logs/ 11 | -------------------------------------------------------------------------------- /dsd_vps/templates/gunicorn.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=gunicorn socket 3 | 4 | [Socket] 5 | ListenStream=/run/gunicorn.sock 6 | 7 | [Install] 8 | WantedBy=sockets.target 9 | -------------------------------------------------------------------------------- /dsd_vps/templates/git_ssh_config_block.txt: -------------------------------------------------------------------------------- 1 | Host git-server 2 | HostName {{ server_ip }} 3 | User {{ server_username }} 4 | IdentityFile ~/.ssh/id_rsa_git 5 | IdentitiesOnly yes 6 | -------------------------------------------------------------------------------- /dsd_vps/templates/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | debug 3 | } 4 | 5 | {{ server_ip_address }} { 6 | encode zstd gzip 7 | 8 | handle { 9 | reverse_proxy unix//run/gunicorn.sock 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/integration_tests/reference_files/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | debug 3 | } 4 | 5 | None { 6 | encode zstd gzip 7 | 8 | handle { 9 | reverse_proxy unix//run/gunicorn.sock 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/integration_tests/reference_files/plugin_help_text.txt: -------------------------------------------------------------------------------- 1 | Options for dsd-vps: 2 | Plugin-specific CLI args for dsd-vps 3 | 4 | --platform PLATFORM Hosting platform, such as digital_ocean. 5 | --ssh-key SSH_KEY Path to private SSH key for accessing VPS instance. -------------------------------------------------------------------------------- /dsd_vps/templates/post-receive: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Update refs before checking out code. 4 | git --git-dir={{ project_path }}.git update-ref HEAD refs/heads/main 5 | 6 | # Check out code files. 7 | git --work-tree={{ project_path }} --git-dir={{ project_path }}.git checkout -f main 8 | -------------------------------------------------------------------------------- /tests/integration_tests/reference_files/requirements.txt: -------------------------------------------------------------------------------- 1 | asgiref==3.8.1 2 | certifi==2024.8.30 3 | charset-normalizer==3.4.0 4 | django==5.1.3 5 | django-bootstrap5==24.3 6 | idna==3.10 7 | requests==2.32.3 8 | sqlparse==0.5.2 9 | urllib3==2.2.3 10 | 11 | django-simple-deploy=={current-version} 12 | gunicorn -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | *_env/ 4 | 5 | *.pyc 6 | __pycache__/ 7 | 8 | dist/ 9 | *.egg-info 10 | 11 | # Using pip to install from a local directory leaves a build/ dir in the root of this 12 | # project. Ignore this, in case it's not destroyed properly after testing. 13 | build/ 14 | 15 | # Ignores the virtualenv directory 16 | .venv/ 17 | 18 | notes/ 19 | -------------------------------------------------------------------------------- /tests/integration_tests/reference_files/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | django = "*" 8 | django-bootstrap5 = "*" 9 | requests = "*" 10 | django-simple-deploy = "=={current-version}" 11 | gunicorn = "*" 12 | 13 | [dev-packages] 14 | 15 | [requires] 16 | python_version = "3.10" 17 | -------------------------------------------------------------------------------- /dsd_vps/templates/gunicorn.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=gunicorn daemon 3 | Requires=gunicorn.socket 4 | After=network.target 5 | 6 | [Service] 7 | User={{ server_username }} 8 | Group=www-data 9 | 10 | WorkingDirectory={{ project_path }} 11 | Environment="DEBUG=TRUE" 12 | Environment="ON_DIGITALOCEAN=1" 13 | 14 | ExecStart={{ project_path }}/.venv/bin/gunicorn \ 15 | --access-logfile - \ 16 | --workers 3 \ 17 | --bind unix:/run/gunicorn.sock \ 18 | {{ project_name }}.wsgi:application 19 | 20 | [Install] 21 | WantedBy=multi-user.target 22 | -------------------------------------------------------------------------------- /tests/integration_tests/reference_files/gunicorn.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=gunicorn daemon 3 | Requires=gunicorn.socket 4 | After=network.target 5 | 6 | [Service] 7 | User=django_user 8 | Group=www-data 9 | 10 | WorkingDirectory=/home/django_user/blog 11 | Environment="DEBUG=TRUE" 12 | Environment="ON_DIGITALOCEAN=1" 13 | 14 | ExecStart=/home/django_user/blog/.venv/bin/gunicorn \ 15 | --access-logfile - \ 16 | --workers 3 \ 17 | --bind unix:/run/gunicorn.sock \ 18 | blog.wsgi:application 19 | 20 | [Install] 21 | WantedBy=multi-user.target 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog: dsd-vps 2 | === 3 | 4 | 0.1 - Provisional support for deployments 5 | --- 6 | 7 | ### (Unreleased) 8 | 9 | #### External changes 10 | 11 | - Supports fully automated deployment to Digital Ocean using ssh keys to access VPS instance. Missing some functionality, such as admin CSS. 12 | - Adds an ssh-key arg to CLI. 13 | - Adds --platform arg as well. 14 | 15 | #### Internal changes 16 | 17 | - N/A 18 | 19 | ### 0.1.0 20 | 21 | #### External changes 22 | 23 | - Initial release, preliminary functionality. 24 | - Release primarily made to claim name on PyPI. 25 | -------------------------------------------------------------------------------- /tests/integration_tests/reference_files/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ "poetry-core>=1.0.0",] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "poetry_unpinned" 7 | version = "0.1.0" 8 | description = "" 9 | authors = [ "Your Name ",] 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.9" 13 | Django = "*" 14 | django-bootstrap5 = "*" 15 | requests = "*" 16 | 17 | [tool.poetry.dev-dependencies] 18 | 19 | [tool.poetry.group.deploy] 20 | optional = true 21 | 22 | [tool.poetry.group.deploy.dependencies] 23 | django-simple-deploy = "=={current-version}" 24 | gunicorn = "*" 25 | -------------------------------------------------------------------------------- /dsd_vps/templates/dockerfile_example: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION=3.10-slim-buster 2 | 3 | FROM python:${PYTHON_VERSION} 4 | 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONUNBUFFERED 1 7 | 8 | RUN mkdir -p /code 9 | 10 | WORKDIR /code 11 | 12 | COPY requirements.txt /tmp/requirements.txt 13 | 14 | RUN set -ex && \ 15 | pip install --upgrade pip && \ 16 | pip install -r /tmp/requirements.txt && \ 17 | rm -rf /root/.cache/ 18 | 19 | COPY . /code/ 20 | 21 | RUN ON_FLYIO_SETUP="1" python manage.py collectstatic --noinput 22 | 23 | EXPOSE 8000 24 | 25 | CMD ["gunicorn", "--bind", ":8000", "--workers", "2", "{{ django_project_name }}.wsgi"] 26 | -------------------------------------------------------------------------------- /developer_resources/README.md: -------------------------------------------------------------------------------- 1 | Developer Resources 2 | --- 3 | 4 | This is a collection of resources that are useful in developing and maintaining django-simple-deploy and related plugins. For example, having a sample of the output of actual CLI command calls saves us from having to run those commands repeatedly when writing code that parses the output. 5 | 6 | Most identifying information has been replaced by something similar to `redacted_username`. Some specific information has been left in, such as a project ID, if a project has already been destroyed. Also, if we're parsing for identifying information and it's helpful to have a string similar to what we've really found, actual information has been replaced by random strings with a similar structure. -------------------------------------------------------------------------------- /tests/integration_tests/test_custom_cli_arg.py: -------------------------------------------------------------------------------- 1 | """Test a custom plugin-specific CLI arg. 2 | """ 3 | 4 | import pytest 5 | 6 | from tests.integration_tests.conftest import tmp_project 7 | from tests.integration_tests.utils import manage_sample_project as msp 8 | 9 | # Skip the default module-level `manage.py deploy call`, so we can call 10 | # `deploy` with our own set of plugin-specific CLI args. 11 | pytestmark = pytest.mark.skip_auto_dsd_call 12 | 13 | 14 | # def test_vm_size_arg(tmp_project, request): 15 | # """Test that a custom vm size is written to fly.toml.""" 16 | # cmd = "python manage.py deploy --vm-size shared-cpu-2x" 17 | # msp.call_deploy(tmp_project, cmd, platform="fly_io") 18 | 19 | # path = tmp_project / "fly.toml" 20 | # contents_fly_toml = path.read_text() 21 | 22 | # assert 'size = "shared-cpu-2x"' in contents_fly_toml -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asgiref==3.8.1 2 | bcrypt==4.2.1 3 | black==25.1.0 4 | build==1.2.2.post1 5 | certifi==2025.1.31 6 | cffi==1.17.1 7 | charset-normalizer==3.4.1 8 | click==8.1.8 9 | cryptography==44.0.1 10 | django==5.1.6 11 | django-simple-deploy==1.0.0 12 | docutils==0.21.2 13 | id==1.5.0 14 | idna==3.10 15 | iniconfig==2.0.0 16 | jaraco-classes==3.4.0 17 | jaraco-context==6.0.1 18 | jaraco-functools==4.1.0 19 | keyring==25.6.0 20 | markdown-it-py==3.0.0 21 | mdurl==0.1.2 22 | more-itertools==10.6.0 23 | mypy-extensions==1.0.0 24 | nh3==0.2.20 25 | packaging==24.2 26 | paramiko==3.5.1 27 | pathspec==0.12.1 28 | platformdirs==4.3.6 29 | pluggy==1.5.0 30 | pycparser==2.22 31 | pygments==2.19.1 32 | pynacl==1.5.0 33 | pyproject-hooks==1.2.0 34 | pytest==8.3.4 35 | readme-renderer==44.0 36 | requests==2.32.3 37 | requests-toolbelt==1.0.0 38 | rfc3986==2.0.0 39 | rich==13.9.4 40 | sqlparse==0.5.3 41 | toml==0.10.2 42 | twine==6.1.0 43 | urllib3==2.3.0 44 | -------------------------------------------------------------------------------- /dsd_vps/deploy.py: -------------------------------------------------------------------------------- 1 | """Manages all VPS-specific aspects of the deployment process. 2 | 3 | Notes: 4 | - ... 5 | """ 6 | 7 | import django_simple_deploy 8 | 9 | from dsd_vps.platform_deployer import PlatformDeployer 10 | from .plugin_config import plugin_config 11 | from .cli import PluginCLI, validate_cli 12 | 13 | 14 | @django_simple_deploy.hookimpl 15 | def dsd_get_plugin_config(): 16 | """Get platform-specific attributes needed by core.""" 17 | return plugin_config 18 | 19 | 20 | @django_simple_deploy.hookimpl 21 | def dsd_get_plugin_cli(parser): 22 | """Get plugin's CLI extension.""" 23 | plugin_cli = PluginCLI(parser) 24 | 25 | 26 | @django_simple_deploy.hookimpl 27 | def dsd_validate_cli(options): 28 | """Validate and parse plugin-specific CLI args.""" 29 | validate_cli(options) 30 | 31 | 32 | @django_simple_deploy.hookimpl 33 | def dsd_deploy(): 34 | """Carry out platform-specific deployment steps.""" 35 | platform_deployer = PlatformDeployer() 36 | platform_deployer.deploy() 37 | -------------------------------------------------------------------------------- /dsd_vps/templates/settings.py: -------------------------------------------------------------------------------- 1 | {{current_settings}} 2 | 3 | # VPS settings. 4 | import os 5 | 6 | if os.environ.get("ON_DIGITALOCEAN"): 7 | # from https://whitenoise.evans.io/en/stable/#quickstart-for-django-apps 8 | STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") 9 | STATIC_URL = "/static/" 10 | # try: 11 | # STATICFILES_DIRS.append(os.path.join(BASE_DIR, "static")) 12 | # except NameError: 13 | # STATICFILES_DIRS = [ 14 | # os.path.join(BASE_DIR, "static"), 15 | # ] 16 | 17 | # i = MIDDLEWARE.index("django.middleware.security.SecurityMiddleware") 18 | # MIDDLEWARE.insert(i + 1, "whitenoise.middleware.WhiteNoiseMiddleware") 19 | 20 | # Use secret, if set, to update DEBUG value. 21 | if os.environ.get("DEBUG") == "TRUE": 22 | DEBUG = True 23 | else: 24 | DEBUG = False 25 | 26 | # Set a platform-specific allowed host. 27 | ALLOWED_HOSTS.append("*")#"{{ deployed_project_name }}.fly.dev") 28 | 29 | # Prevent CSRF "Origin checking failed" issue. 30 | # CSRF_TRUSTED_ORIGINS = ["https://{{ deployed_project_name }}.fly.dev"] 31 | -------------------------------------------------------------------------------- /dsd_vps/templates/serve_project.sh: -------------------------------------------------------------------------------- 1 | # Start serving project after receiving code. 2 | 3 | # Build a venv. 4 | cd {{ project_path }} 5 | {{ uv_path }} venv .venv 6 | source .venv/bin/activate 7 | {{ uv_path }} pip install -r requirements.txt 8 | 9 | # # Set env vars. 10 | export DEBUG=TRUE 11 | export ON_DIGITALOCEAN=1 12 | 13 | # Migrate, and run collectstatic. 14 | {{ project_path }}/.venv/bin/python manage.py migrate 15 | {{ project_path }}/.venv/bin/python manage.py collectstatic --noinput 16 | 17 | # Serve project. Reload service files, start gunicorn, start caddy. 18 | # The logic here allows this script to be run after services have been 19 | # started. This is especially helpful during development work. 20 | sudo /usr/bin/systemctl daemon-reload 21 | 22 | sudo /usr/bin/systemctl enable gunicorn.socket 23 | if systemctl is-active --quiet gunicorn.socket; then 24 | sudo /usr/bin/systemctl restart gunicorn.socket 25 | else 26 | sudo /usr/bin/systemctl start gunicorn.socket 27 | fi 28 | 29 | sudo /usr/bin/systemctl enable caddy 30 | if systemctl is-active --quiet caddy; then 31 | sudo /usr/bin/systemctl restart caddy 32 | else 33 | sudo /usr/bin/systemctl start caddy 34 | fi 35 | -------------------------------------------------------------------------------- /tests/integration_tests/reference_files/serve_project.sh: -------------------------------------------------------------------------------- 1 | # Start serving project after receiving code. 2 | 3 | # Build a venv. 4 | cd /home/django_user/blog 5 | /home/django_user/.local/bin/uv venv .venv 6 | source .venv/bin/activate 7 | /home/django_user/.local/bin/uv pip install -r requirements.txt 8 | 9 | # # Set env vars. 10 | export DEBUG=TRUE 11 | export ON_DIGITALOCEAN=1 12 | 13 | # Migrate, and run collectstatic. 14 | /home/django_user/blog/.venv/bin/python manage.py migrate 15 | /home/django_user/blog/.venv/bin/python manage.py collectstatic --noinput 16 | 17 | # Serve project. Reload service files, start gunicorn, start caddy. 18 | # The logic here allows this script to be run after services have been 19 | # started. This is especially helpful during development work. 20 | sudo /usr/bin/systemctl daemon-reload 21 | 22 | sudo /usr/bin/systemctl enable gunicorn.socket 23 | if systemctl is-active --quiet gunicorn.socket; then 24 | sudo /usr/bin/systemctl restart gunicorn.socket 25 | else 26 | sudo /usr/bin/systemctl start gunicorn.socket 27 | fi 28 | 29 | sudo /usr/bin/systemctl enable caddy 30 | if systemctl is-active --quiet caddy; then 31 | sudo /usr/bin/systemctl restart caddy 32 | else 33 | sudo /usr/bin/systemctl start caddy 34 | fi 35 | -------------------------------------------------------------------------------- /dsd_vps/plugin_config.py: -------------------------------------------------------------------------------- 1 | """Config class for plugin information shared with core.""" 2 | 3 | from . import deploy_messages as platform_msgs 4 | 5 | 6 | class PluginConfig: 7 | """Class for managing attributes that need to be shared with core. 8 | 9 | This is similar to the class SDConfig in core's sd_config.py. 10 | 11 | This should future-proof plugins somewhat, in that if more information needs 12 | to be shared back to core, it can be added here without breaking changes to the 13 | core-plugin interface. 14 | 15 | Get plugin-specific attributes required by core. 16 | 17 | Required: 18 | - automate_all_supported 19 | - platform_name 20 | Optional: 21 | - confirm_automate_all_msg (required if automate_all_supported is True) 22 | """ 23 | 24 | def __init__(self): 25 | self.automate_all_supported = True 26 | self.confirm_automate_all_msg = platform_msgs.confirm_automate_all 27 | self.platform_name = "VPS" 28 | 29 | self.platform = None 30 | self.supported_platforms = ["digital_ocean"] 31 | 32 | self.ip_address = None 33 | 34 | # Create plugin_config once right here. This approach keeps from having to pass the config 35 | # instance between core, plugins, and these utility functions. 36 | plugin_config = PluginConfig() -------------------------------------------------------------------------------- /tests/integration_tests/test_help_output.py: -------------------------------------------------------------------------------- 1 | """Test the help output when dsd-vps is installed. 2 | 3 | The core django-simple-deploy library tests its own help output. 4 | This test checks that plugin-specific options are included in the help output. 5 | """ 6 | 7 | from pathlib import Path 8 | 9 | import pytest 10 | 11 | from tests.integration_tests.conftest import tmp_project 12 | from tests.integration_tests.utils import manage_sample_project as msp 13 | 14 | # Skip the default module-level `manage.py deploy call`, so we can call 15 | # `deploy` with our own set of plugin-specific CLI args. 16 | pytestmark = pytest.mark.skip_auto_dsd_call 17 | 18 | 19 | def test_plugin_help_output(tmp_project, request): 20 | """Test that dsd-vps CLI args are included in help output. 21 | 22 | Note: When updating this, run `manage.py deploy --help` in a terminal set 23 | to 80 characters wide. That splits help text at the same places as the 24 | test environment. 25 | On macOS, you can simply run: 26 | $ COLUMNS=80 python manage.py deploy --help 27 | """ 28 | cmd = "python manage.py deploy --help" 29 | stdout, stderr = msp.call_deploy(tmp_project, cmd) 30 | 31 | path_reference = Path(__file__).parent / "reference_files" / "plugin_help_text.txt" 32 | help_lines = path_reference.read_text().splitlines() 33 | 34 | for line in help_lines: 35 | assert line in stdout 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Eric Matthes and individual contributors. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "dsd-vps" 7 | version = "0.1.0" 8 | description = "A plugin for django-simple-deploy, supporting deployments to VPS providers." 9 | readme = "README.md" 10 | 11 | authors = [ 12 | {name = "Eric Matthes", email = "ehmatthes@gmail.com" }, 13 | ] 14 | 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Framework :: Django :: 4.2", 18 | "Framework :: Django :: 5.0", 19 | "Framework :: Django :: 5.1", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: BSD License", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python :: 3 :: Only", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | ] 30 | 31 | keywords = ["django", "deployment"] 32 | 33 | requires-python = ">=3.9" 34 | 35 | dependencies = [ 36 | "django>=4.2", 37 | "pluggy>=1.5.0", 38 | "toml>=0.10.2", 39 | "requests>=2.32.2", 40 | "paramiko>=3.5", 41 | "django-simple-deploy>=0.9.0" 42 | ] 43 | 44 | [project.optional-dependencies] 45 | dev = [ 46 | "black>=24.1.0", 47 | "build>=1.2.1", 48 | "pytest>=8.3.0", 49 | "twine>=5.1.1", 50 | ] 51 | 52 | # [project.urls] 53 | # "Documentation" = "" 54 | # "GitHub" = "" 55 | # "Changelog" = "` 47 | - `$ export DSD_HOST_PW=` 48 | - Install `dsd-vps`. (If you cloned this repo, you probably want to make a local editable install of `dsd-vps`.) 49 | - Add `django_simple_deploy` to `INSTALLED_APPS`. 50 | - Run `python manage.py deploy --automate-all`. 51 | - This command takes a while. If you think it might be hanging, look at your VPS instance dashboard. High CPU means it's probably still updating. 52 | - For development work, it might be reasonable to use a higher spec instance, that will be destroyed in under an hour. 53 | - The deployment will ask you to confirm a fingerprint before connecting. It will also require the root password for the instance. 54 | 55 | The `deploy` command will add a new user named `django_user` to the instance, with the same password you originally chose. It will update and configure the server, configure Git on the server, configure the project to be served from the droplet, commit changes, push the project, and open the remote project in a new browser tab. 56 | 57 | It will add a local ssh key pair for Git, modifying `~/.ssh/config`. The key will be stored at `~/.ssh/id_rsa_git`. 58 | 59 | The project will be served over http, which means the browser will almost certainly flag it as insecure. 60 | 61 | ## Automated deployment - using SSH keys 62 | 63 | This is experimental, and you should review the codebase before running this early version on your system. It will modify local files outside of your project, such as `~/.ssh/config` and `~/.ssh/id_rsa_git`. 64 | 65 | The current version of dsd-vps only supports ssh-key-based deployment to Digital Ocean, because it only knows how to create resources on that platform. Support for other platforms will be added as the behavior stabilizes. 66 | 67 | - Install `dsd-vps`. (If you cloned this repo, you probably want to make a local editable install of `dsd-vps`.) 68 | - Add `django_simple_deploy` to `INSTALLED_APPS`. 69 | - Run `python manage.py deploy --platform digital_ocean --automate-all --ssh-key `. 70 | - This command takes a while. If you think it might be hanging, look at your VPS instance dashboard. High CPU means it's probably still updating. 71 | - For development work, it might be reasonable to use a higher spec instance, that will be destroyed in under an hour. 72 | - The deployment will ask you to confirm a fingerprint before connecting. 73 | 74 | The `deploy` command will add a new user named `django_user` to the instance. It will update and configure the server, configure Git on the server, configure the project to be served from the droplet, commit changes, push the project, and open the remote project in a new browser tab. 75 | 76 | It will add a local ssh key pair for Git, modifying `~/.ssh/config`. The key will be stored at `~/.ssh/id_rsa_git`. 77 | 78 | The project will be served over http, which means the browser will almost certainly flag it as insecure. 79 | -------------------------------------------------------------------------------- /tests/e2e_tests/utils.py: -------------------------------------------------------------------------------- 1 | """Helper functions specific to {{PlatformmName}}. 2 | 3 | Some Fly.io functions are included as an example. 4 | """ 5 | 6 | import re, time 7 | import json 8 | import subprocess 9 | import shlex 10 | 11 | import pytest 12 | 13 | from tests.e2e_tests.utils.it_helper_functions import make_sp_call 14 | 15 | 16 | # def create_project(): 17 | # """Create a project on Fly.io.""" 18 | # print("\n\nCreating a project on Fly.io...") 19 | # output = ( 20 | # make_sp_call(f"fly apps create --generate-name", capture_output=True) 21 | # .stdout.decode() 22 | # .strip() 23 | # ) 24 | # print("create_project output:", output) 25 | 26 | # re_app_name = r"New app created: (.*)" 27 | # app_name = re.search(re_app_name, output).group(1) 28 | # print(f" App name: {app_name}") 29 | 30 | # return app_name 31 | 32 | 33 | # def deploy_project(app_name): 34 | # """Make a non-automated deployment.""" 35 | # # Consider pausing before the deployment. Some platforms need a moment 36 | # # for the newly-created resources to become fully available. 37 | # # time.sleep(30) 38 | 39 | # print("Deploying to Fly.io...") 40 | # make_sp_call("fly deploy") 41 | 42 | # # Open project and get URL. 43 | # output = ( 44 | # make_sp_call(f"fly apps open -a {app_name}", capture_output=True) 45 | # .stdout.decode() 46 | # .strip() 47 | # ) 48 | # print("fly open output:", output) 49 | 50 | # re_url = r"opening (http.*) \.\.\." 51 | # project_url = re.search(re_url, output).group(1) 52 | # if "https" not in project_url: 53 | # project_url = project_url.replace("http", "https") 54 | 55 | # print(f" Project URL: {project_url}") 56 | 57 | # return project_url 58 | 59 | 60 | # def get_project_url_name(): 61 | # """Get project URL and app name of a deployed project. 62 | # This is used when testing the automate_all workflow. 63 | # """ 64 | # output = ( 65 | # make_sp_call("fly status --json", capture_output=True).stdout.decode().strip() 66 | # ) 67 | # status_json = json.loads(output) 68 | 69 | # app_name = status_json["Name"] 70 | # project_url = f"https://{app_name}.fly.dev" 71 | 72 | # print(f" Found app name: {app_name}") 73 | # print(f" Project URL: {project_url}") 74 | 75 | # return project_url, app_name 76 | 77 | def validate_do_cli(): 78 | """Make sure the DO CLI is installed, and authenticated. 79 | 80 | DEV: This may be generalized by a parent that makes sure some host platform's 81 | CLI is installed, or otherwise verify we'll be able to make a vps instance. 82 | """ 83 | # Make sure doctl is installed. 84 | cmd = "doctl version" 85 | cmd_parts = shlex.split(cmd) 86 | print(f"Checking that DO CLI is installed: {cmd}") 87 | try: 88 | output = subprocess.run(cmd_parts, capture_output=True).stdout.decode().strip() 89 | except FileNotFoundError: 90 | msg = " DO CLI is not installed; cannot create a VPS instance for testing." 91 | print(msg) 92 | pytest.exit(msg) 93 | else: 94 | print(f" DO CLI version: {output}") 95 | 96 | # Make sure it's authenticated. 97 | cmd = "doctl account get" 98 | cmd_parts = shlex.split(cmd) 99 | print(f"Checking that CLI is authenticated: {cmd}") 100 | # breakpoint() 101 | output = subprocess.run(cmd_parts, capture_output=True) 102 | stderr = output.stderr.decode() 103 | if "Unable to initialize DigitalOcean API client" in stderr: 104 | msg = " DO CLI is not authenticated; maybe run `doctl auth init`?" 105 | print(msg) 106 | pytest.exit(msg) 107 | else: 108 | stdout = output.stdout.decode() 109 | print(f" {stdout}") 110 | 111 | def create_vps_instance(): 112 | """Create a vps instance to test against.""" 113 | ... 114 | 115 | 116 | def check_log(tmp_proj_dir): 117 | """Check the log that was generated during a full deployment. 118 | 119 | Checks that log file exists, and that DATABASE_URL is not logged. 120 | """ 121 | path = tmp_proj_dir / "simple_deploy_logs" 122 | if not path.exists(): 123 | return False 124 | 125 | log_files = list(path.glob("simple_deploy_*.log")) 126 | if not log_files: 127 | return False 128 | 129 | log_str = log_files[0].read_text() 130 | if "DATABASE_URL" in log_str: 131 | return False 132 | 133 | return True 134 | 135 | 136 | # def destroy_project(request): 137 | # """Destroy the deployed project, and all remote resources.""" 138 | # print("\nCleaning up:") 139 | 140 | # app_name = request.config.cache.get("app_name", None) 141 | # if not app_name: 142 | # print(" No app name found; can't destroy any remote resources.") 143 | # return None 144 | 145 | # print(" Destroying Fly.io project...") 146 | # make_sp_call(f"fly apps destroy -y {app_name}") 147 | 148 | # print(" Destroying Fly.io database...") 149 | # make_sp_call(f"fly apps destroy -y {app_name}-db") 150 | -------------------------------------------------------------------------------- /tests/integration_tests/reference_files/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for blog project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.1.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "django-insecure-j+*1=he4!%=(-3g^$hj=1pkmzkbdjm0-h2%yd-=1sf%trwun_-" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | # My apps. 35 | "blogs", 36 | "users", 37 | # Third party apps. 38 | "django_simple_deploy", 39 | "django_bootstrap5", 40 | # Default django apps. 41 | "django.contrib.admin", 42 | "django.contrib.auth", 43 | "django.contrib.contenttypes", 44 | "django.contrib.sessions", 45 | "django.contrib.messages", 46 | "django.contrib.staticfiles", 47 | ] 48 | 49 | MIDDLEWARE = [ 50 | "django.middleware.security.SecurityMiddleware", 51 | "django.contrib.sessions.middleware.SessionMiddleware", 52 | "django.middleware.common.CommonMiddleware", 53 | "django.middleware.csrf.CsrfViewMiddleware", 54 | "django.contrib.auth.middleware.AuthenticationMiddleware", 55 | "django.contrib.messages.middleware.MessageMiddleware", 56 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 57 | ] 58 | 59 | ROOT_URLCONF = "blog.urls" 60 | 61 | TEMPLATES = [ 62 | { 63 | "BACKEND": "django.template.backends.django.DjangoTemplates", 64 | "DIRS": [], 65 | "APP_DIRS": True, 66 | "OPTIONS": { 67 | "context_processors": [ 68 | "django.template.context_processors.debug", 69 | "django.template.context_processors.request", 70 | "django.contrib.auth.context_processors.auth", 71 | "django.contrib.messages.context_processors.messages", 72 | ], 73 | }, 74 | }, 75 | ] 76 | 77 | WSGI_APPLICATION = "blog.wsgi.application" 78 | 79 | 80 | # Database 81 | # https://docs.djangoproject.com/en/4.1/ref/settings/#databases 82 | 83 | DATABASES = { 84 | "default": { 85 | "ENGINE": "django.db.backends.sqlite3", 86 | "NAME": BASE_DIR / "db.sqlite3", 87 | } 88 | } 89 | 90 | 91 | # Password validation 92 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 93 | 94 | AUTH_PASSWORD_VALIDATORS = [ 95 | { 96 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 97 | }, 98 | { 99 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 100 | }, 101 | { 102 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 103 | }, 104 | { 105 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 106 | }, 107 | ] 108 | 109 | 110 | # Internationalization 111 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 112 | 113 | LANGUAGE_CODE = "en-us" 114 | 115 | TIME_ZONE = "UTC" 116 | 117 | USE_I18N = True 118 | 119 | USE_TZ = True 120 | 121 | 122 | # Static files (CSS, JavaScript, Images) 123 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 124 | 125 | STATIC_URL = "static/" 126 | 127 | # Default primary key field type 128 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 129 | 130 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 131 | 132 | # My settings. 133 | LOGIN_URL = "users:login" 134 | 135 | 136 | # VPS settings. 137 | import os 138 | 139 | if os.environ.get("ON_DIGITALOCEAN"): 140 | # from https://whitenoise.evans.io/en/stable/#quickstart-for-django-apps 141 | STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") 142 | STATIC_URL = "/static/" 143 | # try: 144 | # STATICFILES_DIRS.append(os.path.join(BASE_DIR, "static")) 145 | # except NameError: 146 | # STATICFILES_DIRS = [ 147 | # os.path.join(BASE_DIR, "static"), 148 | # ] 149 | 150 | # i = MIDDLEWARE.index("django.middleware.security.SecurityMiddleware") 151 | # MIDDLEWARE.insert(i + 1, "whitenoise.middleware.WhiteNoiseMiddleware") 152 | 153 | # Use secret, if set, to update DEBUG value. 154 | if os.environ.get("DEBUG") == "TRUE": 155 | DEBUG = True 156 | else: 157 | DEBUG = False 158 | 159 | # Set a platform-specific allowed host. 160 | ALLOWED_HOSTS.append("*")#"blog.fly.dev") 161 | 162 | # Prevent CSRF "Origin checking failed" issue. 163 | # CSRF_TRUSTED_ORIGINS = ["https://blog.fly.dev"] 164 | -------------------------------------------------------------------------------- /tests/integration_tests/test_vps_config.py: -------------------------------------------------------------------------------- 1 | """Integration tests for django-simple-deploy, targeting a VPS.""" 2 | 3 | import sys 4 | from pathlib import Path 5 | import subprocess 6 | 7 | import pytest 8 | 9 | from tests.integration_tests.utils import it_helper_functions as hf 10 | from tests.integration_tests.conftest import ( 11 | tmp_project, 12 | run_dsd, 13 | reset_test_project, 14 | pkg_manager, 15 | dsd_version, 16 | ) 17 | 18 | 19 | # Skip until more stable configuration to test. 20 | pytestmark = pytest.mark.skip 21 | 22 | 23 | # --- Fixtures --- 24 | 25 | 26 | # --- Test modifications to project files. --- 27 | 28 | 29 | def test_settings(tmp_project): 30 | """Verify there's a VPS-specific settings section. 31 | This function only checks the entire settings file. It does not examine 32 | individual settings. 33 | 34 | Note: This will fail as soon as you make updates to the user's settings file. 35 | That's good! Look in the test's temp dir, look at the settings file after it was 36 | modified, and if it's correct, copy that file to reference_files. Tests should pass 37 | again. 38 | """ 39 | hf.check_reference_file(tmp_project, "blog/settings.py", "dsd-vps") 40 | 41 | 42 | def test_requirements_txt(tmp_project, pkg_manager, tmp_path, dsd_version): 43 | """Test that the requirements.txt file is correct. 44 | Note: This will fail as soon as you add new requirements. That's good! Look in the 45 | test's temp dir, look at the requirements.txt file after it was modified, and if 46 | it's correct, copy it to reference files. Tests should pass again. 47 | """ 48 | if pkg_manager == "req_txt": 49 | context = {"current-version": dsd_version} 50 | hf.check_reference_file( 51 | tmp_project, 52 | "requirements.txt", 53 | "dsd-vps", 54 | context=context, 55 | tmp_path=tmp_path, 56 | ) 57 | elif pkg_manager in ["poetry", "pipenv"]: 58 | assert not Path("requirements.txt").exists() 59 | 60 | 61 | def test_pyproject_toml(tmp_project, pkg_manager, tmp_path, dsd_version): 62 | """Test that pyproject.toml is correct.""" 63 | if pkg_manager in ("req_txt", "pipenv"): 64 | assert not Path("pyproject.toml").exists() 65 | elif pkg_manager == "poetry": 66 | context = {"current-version": dsd_version} 67 | hf.check_reference_file( 68 | tmp_project, 69 | "pyproject.toml", 70 | "dsd-vps", 71 | context=context, 72 | tmp_path=tmp_path, 73 | ) 74 | 75 | 76 | def test_pipfile(tmp_project, pkg_manager, tmp_path, dsd_version): 77 | """Test that Pipfile is correct.""" 78 | if pkg_manager in ("req_txt", "poetry"): 79 | assert not Path("Pipfile").exists() 80 | elif pkg_manager == "pipenv": 81 | context = {"current-version": dsd_version} 82 | hf.check_reference_file( 83 | tmp_project, "Pipfile", "dsd-vps", context=context, tmp_path=tmp_path 84 | ) 85 | 86 | def test_serve_project_sh(tmp_project): 87 | """Test that serve_project.sh is correct.""" 88 | hf.check_reference_file(tmp_project, "serve_project.sh", "dsd-vps") 89 | 90 | def test_caddyfile(tmp_project): 91 | """Test that Caddyfile is correct. 92 | 93 | The file is written to the root directory of the local project during testing. 94 | This checks that the contents are correct, not the actual location on the server. 95 | """ 96 | hf.check_reference_file(tmp_project, "Caddyfile", "dsd-vps") 97 | 98 | def test_gunicorn_service_file(tmp_project): 99 | """Test that gunicorn.service is correct. 100 | 101 | gunicorn.socket is a static file, and is not tested. 102 | """ 103 | hf.check_reference_file(tmp_project, "gunicorn.service", "dsd-vps") 104 | 105 | 106 | 107 | 108 | def test_gitignore(tmp_project): 109 | """Test that .gitignore has been modified correctly.""" 110 | hf.check_reference_file(tmp_project, ".gitignore", "dsd-vps") 111 | 112 | 113 | # --- Test VPS-specific files --- 114 | 115 | # Example test for a platform-specicific file such as Fly's Dockerfile 116 | # def test_creates_dockerfile(tmp_project, pkg_manager): 117 | # """Verify that dockerfile is created correctly.""" 118 | # if pkg_manager == "req_txt": 119 | # hf.check_reference_file(tmp_project, "dockerfile", "dsd-flyio") 120 | # elif pkg_manager == "poetry": 121 | # hf.check_reference_file( 122 | # tmp_project, 123 | # "dockerfile", 124 | # "dsd-flyio", 125 | # reference_filename="poetry.dockerfile", 126 | # ) 127 | # elif pkg_manager == "pipenv": 128 | # hf.check_reference_file( 129 | # tmp_project, 130 | # "dockerfile", 131 | # "dsd-flyio", 132 | # reference_filename="pipenv.dockerfile", 133 | # ) 134 | 135 | 136 | # --- Test logs --- 137 | 138 | 139 | def test_log_dir(tmp_project): 140 | """Test that the log directory exists, and contains an appropriate log file.""" 141 | log_path = Path(tmp_project / "dsd_logs") 142 | assert log_path.exists() 143 | 144 | # There should be exactly two log files. 145 | log_files = sorted(log_path.glob("*")) 146 | log_filenames = [lf.name for lf in log_files] 147 | # Check for exactly the log files we expect to find. 148 | # DEV: Currently just testing that a log file exists. Add a regex text for a file 149 | # like "simple_deploy_2022-07-09174245.log". 150 | assert len(log_files) == 1 151 | 152 | # Read log file. We can never just examine the log file directly to a reference, 153 | # because it will have different timestamps. 154 | # If we need to, we can make a comparison of all content except timestamps. 155 | # DEV: Look for specific log file; not sure this log file is always the second one. 156 | # We're looking for one similar to "simple_deploy_2022-07-09174245.log". 157 | log_file = log_files[0] # update on friendly summary 158 | log_file_text = log_file.read_text() 159 | 160 | # DEV: Update these for more platform-specific log messages. 161 | # Spot check for opening log messages. 162 | assert "INFO: Logging run of `manage.py deploy`..." in log_file_text 163 | assert "INFO: Configuring project for deployment..." in log_file_text 164 | 165 | assert "INFO: CLI args:" in log_file_text 166 | assert ( 167 | "INFO: Deployment target: VPS" in log_file_text 168 | or "INFO: Deployment target: VPS" in log_file_text 169 | ) 170 | assert "INFO: Using plugin: dsd_vps" in log_file_text 171 | assert "INFO: Local project name: blog" in log_file_text 172 | assert "INFO: git status --porcelain" in log_file_text 173 | assert "INFO: ?? dsd_logs/" in log_file_text 174 | 175 | # Spot check for success messages. 176 | assert ( 177 | "INFO: --- Your project is now configured for deployment. ---" 178 | in log_file_text 179 | ) 180 | assert "INFO: To deploy your project, you will need to:" in log_file_text 181 | 182 | assert ( 183 | "INFO: - You can find a full record of this configuration in the dsd_logs directory." 184 | in log_file_text 185 | ) 186 | -------------------------------------------------------------------------------- /dsd_vps/platform_deployer.py: -------------------------------------------------------------------------------- 1 | """Manages all VPS-specific aspects of the deployment process. 2 | 3 | VPS notes: 4 | 5 | - All actions taken against the server should be idempotent if at all possible. If an 6 | action is not idempotent, that should be noted. 7 | """ 8 | 9 | import sys, os, re, json 10 | import time 11 | from pathlib import Path 12 | import tempfile 13 | import webbrowser 14 | 15 | from django.utils.safestring import mark_safe 16 | 17 | import requests 18 | 19 | from .plugin_config import plugin_config 20 | from . import deploy_messages as platform_msgs 21 | from . import utils as do_utils 22 | 23 | from django_simple_deploy.management.commands.utils import plugin_utils 24 | from django_simple_deploy.management.commands.utils.plugin_utils import dsd_config 25 | from django_simple_deploy.management.commands.utils.command_errors import DSDCommandError 26 | 27 | 28 | class PlatformDeployer: 29 | """Perform the initial deployment. 30 | 31 | If --automate-all is used, carry out an actual deployment. 32 | If not, do all configuration work so the user only has to commit changes, and ... 33 | """ 34 | 35 | def __init__(self): 36 | self.templates_path = Path(__file__).parent / "templates" 37 | 38 | # --- Public methods --- 39 | 40 | def deploy(self, *args, **options): 41 | """Coordinate the overall configuration and deployment.""" 42 | plugin_utils.write_output("\nConfiguring project for deployment...") 43 | 44 | self._validate_platform() 45 | self._prep_automate_all() 46 | 47 | # Configure server. 48 | self._connect_server() 49 | self._update_server() 50 | self._setup_server() 51 | 52 | # Configure project for deployment. 53 | self._add_requirements() 54 | self._modify_settings() 55 | self._add_serve_project_file() 56 | 57 | self._add_caddyfile() 58 | self._configure_gunicorn() 59 | 60 | self._conclude_automate_all() 61 | self._show_success_message() 62 | 63 | # --- Helper methods for deploy() --- 64 | 65 | def _validate_platform(self): 66 | """Make sure the local environment and project supports deployment to a VPS. 67 | 68 | Returns: 69 | None 70 | Raises: 71 | DSDCommandError: If we find any reason deployment won't work. 72 | """ 73 | pass 74 | 75 | 76 | def _prep_automate_all(self): 77 | """Take any further actions needed if using automate_all.""" 78 | if not dsd_config.automate_all: 79 | return 80 | 81 | if not plugin_config.platform: 82 | msg = "You must specify a --platform in order to use --automate-all." 83 | raise DSDCommandError(msg) 84 | 85 | if plugin_config.platform == "digital_ocean": 86 | plugin_utils.write_output("Creating droplet on Digital Ocean...") 87 | 88 | # Get SSH key ID. 89 | do_utils.get_ssh_key_ids_digitalocean() 90 | 91 | # Make a new droplet, and get droplet ID. 92 | cmd = f"doctl compute droplet create dsd-e2e-test --image ubuntu-25-04-x64 --size s-1vcpu-1gb --region nyc3 -o json --ssh-keys {plugin_config.ssh_key_id}" 93 | output = plugin_utils.run_quick_command(cmd).stdout.decode() 94 | plugin_utils.write_output(output, write_to_console=False) 95 | 96 | output_json = json.loads(output) 97 | plugin_config.droplet_id = output_json[0]["id"] 98 | 99 | msg = f" Droplet ID: {plugin_config.droplet_id}" 100 | plugin_utils.write_output(msg) 101 | 102 | # Sleep 3 seconds to let droplet spin up. 103 | pause = 10 104 | plugin_utils.write_output(f" Sleeping {pause} seconds to let droplet spin up...") 105 | time.sleep(pause) 106 | 107 | # Get IP address of new droplet. 108 | droplet_status = "new" 109 | num_tries = 0 110 | while droplet_status == "new" and num_tries < 10: 111 | plugin_utils.write_output(" Querying for ip_address...") 112 | 113 | cmd = f"doctl compute droplet get {plugin_config.droplet_id} -o json" 114 | output = plugin_utils.run_quick_command(cmd).stdout.decode() 115 | plugin_utils.write_output(output, write_to_console=False) 116 | output_json = json.loads(output) 117 | 118 | droplet_status = output_json[0]["status"] 119 | 120 | if droplet_status == "new": 121 | time.sleep(5) 122 | num_tries += 1 123 | 124 | # Public IP address should be available 125 | # There are two, and they haven't been in a consistent order. 126 | # We're looking for the IP address starting with 3 digits. 127 | network_dicts = output_json[0]["networks"]["v4"] 128 | for network_dict in network_dicts: 129 | if network_dict["type"] == "public": 130 | plugin_config.ip_address = network_dict["ip_address"] 131 | break 132 | 133 | if not plugin_config.ip_address: 134 | msg = "Could not identify IP address." 135 | raise DSDCommandError(msg) 136 | 137 | msg = f" IP address: {plugin_config.ip_address}" 138 | plugin_utils.write_output(msg) 139 | 140 | 141 | def _connect_server(self): 142 | """Make sure we can connect to the server, with an appropriate username.""" 143 | do_utils.set_server_username() 144 | do_utils.configure_firewall() 145 | 146 | 147 | def _update_server(self): 148 | """Update the server.""" 149 | # Don't update during unit and integration testing. 150 | plugin_utils.write_output("Updating server (this may take a few minutes)...") 151 | if dsd_config.unit_testing: 152 | plugin_utils.write_output(" (skipped during testing)") 153 | return 154 | 155 | # Run update command. 156 | cmd = "sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y" 157 | stdout, stderr = do_utils.run_server_cmd_ssh(cmd) 158 | plugin_utils.write_output(" Finished updating server.") 159 | 160 | # See if we need to reboot. If rebooted, check for updates again. This is most 161 | # likely needed with a fresh VM, after its first round of updates. 162 | rebooted = do_utils.reboot_if_required() 163 | if rebooted: 164 | self._update_server() 165 | 166 | def _setup_server(self): 167 | """Run initial server setup. 168 | 169 | Roughly follows a standard Ubuntu server setup guide, such as: 170 | - https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu 171 | """ 172 | # DEV: Disable during development. 173 | do_utils.install_uv() 174 | do_utils.install_python() 175 | do_utils.configure_git(self.templates_path) 176 | do_utils.install_caddy() 177 | 178 | def _add_requirements(self): 179 | """Add server-specific requirements.""" 180 | plugin_utils.write_output(" Adding server-specific requirements...") 181 | requirements = ["gunicorn"] 182 | plugin_utils.add_packages(requirements) 183 | 184 | def _modify_settings(self): 185 | # Add do-specific settings. 186 | template_path = self.templates_path / "settings.py" 187 | context = { 188 | "deployed_project_name": dsd_config.local_project_name, 189 | "ip_addr": os.environ.get("DSD_HOST_IPADDR"), 190 | } 191 | plugin_utils.modify_settings_file(template_path, context) 192 | 193 | def _add_serve_project_file(self): 194 | # Add a bash script to start server process after code pushes. 195 | # template_path = self.templates_path / "dockerfile_example" 196 | # context = { 197 | # "django_project_name": dsd_config.local_project_name, 198 | # } 199 | # contents = plugin_utils.get_template_string(template_path, context) 200 | 201 | # # Write file to project. 202 | # path = dsd_config.project_root / "Dockerfile" 203 | # plugin_utils.add_file(path, contents) 204 | 205 | 206 | template_path = self.templates_path / "serve_project.sh" 207 | project_path = Path(f"/home/{dsd_config.server_username}/{dsd_config.local_project_name}") 208 | context = { 209 | "project_path": project_path, 210 | "uv_path": f"/home/{dsd_config.server_username}/.local/bin/uv", 211 | } 212 | contents = plugin_utils.get_template_string(template_path, context) 213 | 214 | # Write file to project. 215 | path = dsd_config.project_root / "serve_project.sh" 216 | plugin_utils.add_file(path, contents) 217 | 218 | def _add_caddyfile(self): 219 | """Add a Caddyfile to the project. 220 | 221 | This configures Caddy, for serving static files. 222 | """ 223 | template_path = self.templates_path / "Caddyfile" 224 | if dsd_config.unit_testing: 225 | context = {"server_ip_address": None} 226 | elif plugin_config.path_ssh_key: 227 | context = {"server_ip_address": plugin_config.ip_address} 228 | else: 229 | context = {"server_ip_address": os.environ.get("DSD_HOST_IPADDR")} 230 | contents = plugin_utils.get_template_string(template_path, context) 231 | 232 | with tempfile.NamedTemporaryFile() as tmp: 233 | path_local = Path(tmp.name) 234 | 235 | # Write to the local project during testing, so we can test the contents. 236 | if dsd_config.unit_testing: 237 | path_local = dsd_config.project_root / "Caddyfile" 238 | 239 | path_local.write_text(contents) 240 | 241 | path_remote = f"/home/{dsd_config.server_username}/Caddyfile" 242 | do_utils.copy_to_server(path_local, path_remote) 243 | 244 | cmd = f"sudo mv /home/{dsd_config.server_username}/Caddyfile /etc/caddy/Caddyfile" 245 | do_utils.run_server_cmd_ssh(cmd) 246 | 247 | 248 | 249 | 250 | 251 | def _configure_gunicorn(self): 252 | """Configure gunicorn to run as a system service. 253 | 254 | DEV: This should probably go somewhere else. 255 | """ 256 | plugin_utils.write_output(" Configuring gunicorn to run as a service.") 257 | 258 | # Write gunicorn.socket to accessible location, then move it to appropriate location. 259 | template_path = self.templates_path / "gunicorn.socket" 260 | # cmd = f"scp {template_path} {dsd_config.server_username}@{os.environ.get("DSD_HOST_IPADDR")}:/home/{dsd_config.server_username}/gunicorn.socket" 261 | # plugin_utils.write_output(cmd) 262 | # plugin_utils.run_quick_command(cmd) 263 | 264 | path_local = self.templates_path / "gunicorn.socket" 265 | path_remote = f"/home/{dsd_config.server_username}/gunicorn.socket" 266 | do_utils.copy_to_server(path_local, path_remote) 267 | 268 | cmd = f"sudo mv /home/{dsd_config.server_username}/gunicorn.socket /etc/systemd/system/gunicorn.socket" 269 | do_utils.run_server_cmd_ssh(cmd) 270 | 271 | # gunicorn.service 272 | template_path = self.templates_path / "gunicorn.service" 273 | project_path = Path(f"/home/{dsd_config.server_username}/{dsd_config.local_project_name}") 274 | context = { 275 | "server_username": dsd_config.server_username, 276 | "project_path": project_path, 277 | "project_name": dsd_config.local_project_name, 278 | } 279 | contents = plugin_utils.get_template_string(template_path, context) 280 | with tempfile.NamedTemporaryFile() as tmp: 281 | path_local = Path(tmp.name) 282 | 283 | # Write to the local project during testing, so we can test the contents. 284 | if dsd_config.unit_testing: 285 | path_local = dsd_config.project_root / "gunicorn.service" 286 | 287 | path_local.write_text(contents) 288 | 289 | # cmd = f"scp {path.as_posix()} {dsd_config.server_username}@{os.environ.get("DSD_HOST_IPADDR")}:/etc/systemd/system/gunicorn.service" 290 | # plugin_utils.write_output(cmd) 291 | # plugin_utils.run_quick_command(cmd) 292 | 293 | # cmd = f"scp {path} {dsd_config.server_username}@{os.environ.get("DSD_HOST_IPADDR")}:/home/{dsd_config.server_username}/gunicorn.service" 294 | # plugin_utils.write_output(cmd) 295 | # plugin_utils.run_quick_command(cmd) 296 | 297 | path_remote = f"/home/{dsd_config.server_username}/gunicorn.service" 298 | do_utils.copy_to_server(path_local, path_remote) 299 | 300 | 301 | 302 | cmd = f"sudo mv /home/{dsd_config.server_username}/gunicorn.service /etc/systemd/system/gunicorn.service" 303 | do_utils.run_server_cmd_ssh(cmd) 304 | 305 | 306 | 307 | 308 | def _conclude_automate_all(self): 309 | """Finish automating the push. 310 | 311 | - Commit all changes. 312 | - ... 313 | """ 314 | # Making this check here lets deploy() be cleaner. 315 | if not dsd_config.automate_all: 316 | return 317 | 318 | plugin_utils.commit_changes() 319 | 320 | # Push project. 321 | plugin_utils.write_output(" Deploying project...") 322 | 323 | do_utils.push_project() 324 | # Make serve script executable. 325 | project_path = Path(f"/home/{dsd_config.server_username}/{dsd_config.local_project_name}") 326 | serve_script_path = f"{project_path}/serve_project.sh" 327 | cmd = f"chmod +x {serve_script_path}" 328 | do_utils.run_server_cmd_ssh(cmd) 329 | 330 | do_utils.serve_project() 331 | 332 | # Should set self.deployed_url, which will be reported in the success message. 333 | if plugin_config.path_ssh_key: 334 | self.deployed_url = f"http://{plugin_config.ip_address}/" 335 | else: 336 | self.deployed_url = f"http://{os.environ.get('DSD_HOST_IPADDR')}/" 337 | webbrowser.open(self.deployed_url) 338 | 339 | 340 | def _show_success_message(self): 341 | """After a successful run, show a message about what to do next. 342 | 343 | Describe ongoing approach of commit, push, migrate. 344 | """ 345 | if dsd_config.automate_all: 346 | msg = platform_msgs.success_msg_automate_all(self.deployed_url) 347 | else: 348 | msg = platform_msgs.success_msg(log_output=dsd_config.log_output) 349 | plugin_utils.write_output(msg) 350 | -------------------------------------------------------------------------------- /dsd_vps/utils.py: -------------------------------------------------------------------------------- 1 | """Utilities specific to deploying to a VPS.""" 2 | 3 | import json 4 | import os 5 | import time 6 | from pathlib import Path 7 | 8 | import paramiko 9 | from paramiko.ssh_exception import NoValidConnectionsError 10 | 11 | from django_simple_deploy.management.commands.utils import plugin_utils 12 | from django_simple_deploy.management.commands.utils.plugin_utils import dsd_config 13 | from django_simple_deploy.management.commands.utils.command_errors import DSDCommandError 14 | 15 | from .plugin_config import plugin_config 16 | 17 | 18 | def get_ssh_key_ids_digitalocean(): 19 | """Get a DigitalOcean ssh key ID.""" 20 | cmd = "doctl compute ssh-key list -o json" 21 | output = plugin_utils.run_quick_command(cmd, skip_logging=True).stdout.decode() 22 | key_dicts = json.loads(output) 23 | 24 | ssh_key_ids = [key_dict["id"] for key_dict in key_dicts] 25 | 26 | if len(ssh_key_ids) == 1: 27 | ssh_key_id = ssh_key_ids[0] 28 | key_name = key_dicts[0]["name"] 29 | msg = f"\nWould you like to use the DO ssh key ID {ssh_key_id}, from the key {key_name}? " 30 | proceed = plugin_utils.get_confirmation(msg, skip_logging=True) 31 | 32 | if proceed: 33 | plugin_config.ssh_key_id = ssh_key_id 34 | return 35 | else: 36 | msg = "Can't proceed without an SSH key id." 37 | raise DSDCommandError(msg) 38 | 39 | # Show keys, in a call to plugin_utils.get_numbered_choice(). 40 | 41 | 42 | # If appropriate, offer to generate a new key. 43 | 44 | 45 | 46 | 47 | def run_server_cmd_ssh(cmd, timeout=10, max_tries=3, pause=3, show_output=True, skip_logging=None): 48 | """Run a command on the server, through an SSH connection. 49 | 50 | Returns: 51 | Tuple of Str: (stdout, stderr) 52 | """ 53 | # If skip_logging is not explicitly set, set it to False. 54 | # This matches the default in plugin_utils.write_output(). 55 | if skip_logging is None: 56 | skip_logging = False 57 | 58 | plugin_utils.write_output("Running server command over SSH...", skip_logging=skip_logging) 59 | if show_output: 60 | plugin_utils.write_output(f" command: {cmd}", skip_logging=skip_logging) 61 | else: 62 | plugin_utils.write_output(" (command not shown)", skip_logging=skip_logging) 63 | 64 | # Skip during local testing. 65 | if dsd_config.unit_testing: 66 | return 67 | 68 | # Get client. 69 | client = paramiko.SSHClient() 70 | client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 71 | 72 | # Run command, and close connection. 73 | try: 74 | if plugin_config.path_ssh_key: 75 | num_tries = 0 76 | while num_tries < max_tries: 77 | try: 78 | plugin_utils.write_output(" Trying to connect using ssh key...") 79 | client.connect( 80 | hostname = plugin_config.ip_address, 81 | username = dsd_config.server_username, 82 | key_filename = plugin_config.path_ssh_key.as_posix(), 83 | timeout = timeout 84 | ) 85 | except (paramiko.ssh_exception.SSHException, paramiko.ssh_exception.NoValidConnectionsError, TimeoutError, ConnectionResetError) as e: 86 | plugin_utils.write_output(str(e)) 87 | plugin_utils.write_output(" Attempt failed.") 88 | num_tries += 1 89 | 90 | if num_tries == max_tries: 91 | raise e 92 | else: 93 | print(f" Waiting {pause}s...") 94 | time.sleep(pause) 95 | else: 96 | plugin_utils.write_output(" Connection successful, running command.") 97 | break 98 | 99 | _stdin, _stdout, _stderr = client.exec_command(cmd) 100 | 101 | stdout = _stdout.read().decode().strip() 102 | stderr = _stderr.read().decode().strip() 103 | else: 104 | client.connect( 105 | hostname = os.environ.get("DSD_HOST_IPADDR"), 106 | username = dsd_config.server_username, 107 | password = os.environ.get("DSD_HOST_PW"), 108 | timeout = timeout 109 | ) 110 | _stdin, _stdout, _stderr = client.exec_command(cmd) 111 | 112 | stdout = _stdout.read().decode().strip() 113 | stderr = _stderr.read().decode().strip() 114 | finally: 115 | client.close() 116 | 117 | # Show stdout and stderr, unless suppressed. 118 | if stdout and show_output: 119 | plugin_utils.write_output(stdout, skip_logging=skip_logging) 120 | if stderr and show_output: 121 | plugin_utils.write_output(stderr, skip_logging=skip_logging) 122 | 123 | # Return both stdout and stderr. 124 | return stdout, stderr 125 | 126 | def copy_to_server(path_local, path_remote, timeout=10, skip_logging=None): 127 | """Copy a local file to the server. 128 | 129 | This should only be for system files. All project files should be part of the 130 | repository, and pushed through Git. 131 | """ 132 | # If skip_logging is not explicitly set, set it to False. 133 | # This matches the default in plugin_utils.write_output(). 134 | if skip_logging is None: 135 | skip_logging = False 136 | 137 | plugin_utils.write_output(f"Copying {path_local.as_posix()} to server.", skip_logging=skip_logging) 138 | 139 | # Skip during local testing. 140 | if dsd_config.unit_testing: 141 | return 142 | 143 | # Get client. 144 | client = paramiko.SSHClient() 145 | client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 146 | 147 | # Copy file, and close connection. 148 | if plugin_config.path_ssh_key: 149 | num_tries = 0 150 | pause = 20 151 | while num_tries < 10: 152 | try: 153 | plugin_utils.write_output(" Trying to connect using ssh key...") 154 | client.connect( 155 | hostname = plugin_config.ip_address, 156 | username = dsd_config.server_username, 157 | key_filename = plugin_config.path_ssh_key.as_posix(), 158 | timeout = timeout 159 | ) 160 | except (paramiko.ssh_exception.SSHException, paramiko.ssh_exception.NoValidConnectionsError, TimeoutError, ConnectionResetError) as e: 161 | plugin_utils.write_output(str(e)) 162 | plugin_utils.write_output(f" Attempt failed, waiting {pause}s...") 163 | time.sleep(pause) 164 | num_tries += 1 165 | else: 166 | plugin_utils.write_output(" Connection successful, running command.") 167 | break 168 | 169 | sftp = client.open_sftp() 170 | sftp.put(path_local, path_remote) 171 | sftp.close() 172 | client.close() 173 | else: 174 | try: 175 | client.connect( 176 | hostname = os.environ.get("DSD_HOST_IPADDR"), 177 | username = dsd_config.server_username, 178 | password = os.environ.get("DSD_HOST_PW"), 179 | timeout = timeout 180 | ) 181 | sftp = client.open_sftp() 182 | sftp.put(path_local, path_remote) 183 | sftp.close() 184 | finally: 185 | client.close() 186 | 187 | 188 | 189 | 190 | def configure_firewall(): 191 | """Configure the ufw firewall.""" 192 | plugin_utils.write_output("Configuring firewall...") 193 | cmd = "sudo ufw allow OpenSSH" 194 | run_server_cmd_ssh(cmd) 195 | 196 | cmd = "sudo ufw --force enable" 197 | run_server_cmd_ssh(cmd) 198 | 199 | cmd = "sudo ufw allow 80/tcp" 200 | run_server_cmd_ssh(cmd) 201 | 202 | cmd = "sudo ufw allow 443/tcp" 203 | run_server_cmd_ssh(cmd) 204 | 205 | 206 | def set_server_username(): 207 | """Sets dsd_config.server_username, for logging into the server. 208 | 209 | The username will either be set through an env var, or django_user from 210 | an earlier run, or root if no non-root user has been added yet, ie for 211 | a fresh VM. If it's root, we'll add a new user. 212 | 213 | - DO_DJANGO_USER gets priority if it's set. 214 | - Then, try django_user. 215 | - If django_user is not available, use root to add django_user. 216 | 217 | Returns: 218 | None 219 | Sets: 220 | dsd_config.server_username 221 | Raises: 222 | DSDCommandError: If unable to connect to server and establish a username. 223 | """ 224 | plugin_utils.write_output("Determining server username...") 225 | 226 | if (username := os.environ.get("DO_DJANGO_USER")): 227 | # Use this custom username. 228 | dsd_config.server_username = username 229 | plugin_utils.write_output(f" username: {username}") 230 | return 231 | 232 | # # If using ssh keys, create django_user in case they don't exist.. 233 | # if plugin_config.path_ssh_key: 234 | # dsd_config. 235 | # add_server_user() 236 | # plugin_utils.write_output(f" username: {dsd_config.server_username}") 237 | # return 238 | 239 | # Using un/pw connection, not ssh keys. 240 | # Use "django_user" from this point forward. Try to connect with this default username. 241 | dsd_config.server_username = "django_user" 242 | try: 243 | run_server_cmd_ssh("uptime", timeout=3) 244 | except (paramiko.ssh_exception.AuthenticationException, paramiko.ssh_exception.SSHException, paramiko.ssh_exception.NoValidConnectionsError, AttributeError, TimeoutError): 245 | # Default non-root user doesn't exist. 246 | dsd_config.server_username = "root" 247 | plugin_utils.write_output(" Using root for now...") 248 | else: 249 | # Default username works, we're done here. 250 | plugin_utils.write_output(f" username: {dsd_config.server_username}") 251 | return 252 | 253 | add_server_user() 254 | plugin_utils.write_output(f" username: {dsd_config.server_username}") 255 | 256 | 257 | def reboot_if_required(): 258 | """Reboot the server if required. 259 | 260 | Returns: 261 | bool: True if rebooted, False if not rebooted. 262 | """ 263 | plugin_utils.write_output("Checking if reboot required...") 264 | 265 | cmd = "ls /var/run" 266 | stdout, stderr = run_server_cmd_ssh(cmd, show_output=False) 267 | 268 | if "reboot-required" in stdout: 269 | reboot_server() 270 | return True 271 | else: 272 | plugin_utils.write_output(" No reboot required.") 273 | return False 274 | 275 | def reboot_server(): 276 | """Reboot the server, and wait for it to be available again. 277 | 278 | Returns: 279 | None 280 | Raises: 281 | DSDCommandError: If the server is unavailable after rebooting. 282 | """ 283 | plugin_utils.write_output(" Rebooting...") 284 | cmd = "sudo systemctl reboot" 285 | stdout, stderr = run_server_cmd_ssh(cmd) 286 | 287 | # Pause to let shutdown begin; polling too soon shows server available because 288 | # shutdown hasn't started yet. 289 | time.sleep(5) 290 | 291 | # Poll for availability. 292 | if not check_server_available(): 293 | raise DSDCommandError("Cannot reach server after reboot.") 294 | 295 | 296 | def check_server_available(delay=10, timeout=300): 297 | """Check if the server is responding. 298 | 299 | Returns: 300 | bool 301 | """ 302 | plugin_utils.write_output("Checking if server is responding...") 303 | 304 | max_attempts = int(timeout / delay) 305 | for attempt in range(max_attempts): 306 | try: 307 | stdout, stderr = run_server_cmd_ssh("uptime") 308 | plugin_utils.write_output(" Server is available.") 309 | return True 310 | except (TimeoutError, NoValidConnectionsError): 311 | plugin_utils.write_output(f" Attempt {attempt+1}/{max_attempts} failed.") 312 | plugin_utils.write_output(f" Waiting {delay}s for server to become available.") 313 | time.sleep(delay) 314 | 315 | plugin_utils.write_output("Server did not respond.") 316 | return False 317 | 318 | def add_server_user(): 319 | """Add a non-root user. 320 | Returns: 321 | None 322 | Raises: 323 | DSDCommandError: If unable to connect using new user. 324 | """ 325 | # # Leave if there's already a non-root user. 326 | # username = os.environ.get("DSD_HOST_USERNAME") 327 | # if (username != "root") or dsd_config.unit_testing: 328 | # return 329 | 330 | # Add the new user. 331 | django_username = "django_user" 332 | plugin_utils.write_output(f"Adding non-root user: {django_username}") 333 | cmd = f"useradd -m {django_username}" 334 | stdout, stderr = run_server_cmd_ssh(cmd) 335 | 336 | if "already exists" in stderr: 337 | return 338 | 339 | # Set the password. 340 | if password := os.environ.get("DSD_HOST_PW"): 341 | plugin_utils.write_output(" Setting password; will not display or log this.") 342 | cmd = f'echo "{django_username}:{password}" | chpasswd' 343 | run_server_cmd_ssh(cmd, show_output=False, skip_logging=True) 344 | else: 345 | plugin_utils.write_output(" Setting password; will not display or log this.") 346 | # DEV: Simple pw for development work. 347 | password = "django_user" 348 | cmd = f'echo "{django_username}:{password}" | chpasswd' 349 | run_server_cmd_ssh(cmd, show_output=False, skip_logging=True) 350 | 351 | if plugin_config.path_ssh_key: 352 | # Copy ssh keys for this user. 353 | # Make ~/.ssh 354 | cmd = f"mkdir /home/{django_username}/.ssh" 355 | output = run_server_cmd_ssh(cmd) 356 | 357 | # Copy keys. 358 | cmd = f"cp /root/.ssh/authorized_keys /home/{django_username}/.ssh/authorized_keys" 359 | output = run_server_cmd_ssh(cmd) 360 | 361 | # Set ownership. 362 | cmd = f"chown -R {django_username}:{django_username} /home/{django_username}/.ssh" 363 | output = run_server_cmd_ssh(cmd) 364 | 365 | 366 | # Add user to sudo group. 367 | plugin_utils.write_output(" Adding user to sudo group.") 368 | cmd = f"usermod -aG sudo {django_username}" 369 | run_server_cmd_ssh(cmd) 370 | 371 | # Modify /etc/sudoers.d to allow scripted use of sudo commands. 372 | # DEV: This can probably be copied to an accessible place, and then mv 373 | # to the correct location, like gunicorn.socket. 374 | plugin_utils.write_output(" Modifying /etc/sudoers.d.") 375 | cmd = f'echo "{django_username} ALL=(ALL) NOPASSWD:SETENV: /usr/bin/apt-get, NOPASSWD: /usr/bin/apt-get, /usr/bin/mv, /usr/bin/systemctl daemon-reload, /usr/bin/systemctl reboot, /usr/bin/systemctl start gunicorn.socket, /usr/bin/systemctl enable gunicorn.socket, /usr/bin/systemctl restart gunicorn.socket, /usr/bin/systemctl start caddy, /usr/bin/systemctl enable caddy, /usr/bin/systemctl restart caddy, /usr/sbin/ufw, /usr/bin/gpg, /usr/bin/tee" | sudo tee /etc/sudoers.d/{django_username}' 376 | run_server_cmd_ssh(cmd) 377 | 378 | # Use the new user from this point forward. 379 | dsd_config.server_username = django_username 380 | 381 | # Verify connection. 382 | plugin_utils.write_output(" Ensuring connection...") 383 | if not check_server_available(): 384 | msg = "Could not connect with new user." 385 | raise DSDCommandError(msg) 386 | 387 | def install_uv(): 388 | """Install uv on the server.""" 389 | plugin_utils.write_output("Installing uv...") 390 | cmd = "curl -LsSf https://astral.sh/uv/install.sh | sh" 391 | run_server_cmd_ssh(cmd) 392 | 393 | def install_python(): 394 | """Install Python on the server.""" 395 | plugin_utils.write_output("Installing Python...") 396 | cmd = f"/home/{dsd_config.server_username}/.local/bin/uv python install 3.12" 397 | run_server_cmd_ssh(cmd) 398 | 399 | def configure_git(templates_path): 400 | """Configure Git for pushing project to server.""" 401 | 402 | # --- Server configuration --- 403 | plugin_utils.write_output("Initializing Git project on server...") 404 | 405 | # Skip for local testing. 406 | if dsd_config.unit_testing: 407 | return 408 | 409 | if plugin_config.path_ssh_key: 410 | ipaddr = plugin_config.ip_address 411 | else: 412 | ipaddr = os.environ.get("DSD_HOST_IPADDR") 413 | 414 | # Configure ssh keys, so push can happen without prompting for password. 415 | # Generate key pair. 416 | path_keyfile = Path.home() / ".ssh" / "id_rsa_git" 417 | if not path_keyfile.exists(): 418 | plugin_utils.write_output(" Generating ssh keys...") 419 | cmd = f'ssh-keygen -t rsa -b 4096 -C "{dsd_config.server_username}@{ipaddr}" -f {path_keyfile.as_posix()} -N ""' 420 | output_obj = plugin_utils.run_quick_command(cmd) 421 | stdout, stderr = output_obj.stdout.decode(), output_obj.stderr.decode() 422 | plugin_utils.write_output(stdout) 423 | if stderr: 424 | plugin_utils.write_output("--- Error ---") 425 | plugin_utils.write_output(stderr) 426 | 427 | # Copy key to server. 428 | if plugin_config.path_ssh_key: 429 | cmd = f"ssh-copy-id -f -i {path_keyfile.as_posix()} -o StrictHostKeyChecking=accept-new -o IdentityFile={plugin_config.path_ssh_key} {dsd_config.server_username}@{plugin_config.ip_address}" 430 | else: 431 | cmd = f"ssh-copy-id -i ~/.ssh/id_rsa_git.pub git@{ipaddr}" 432 | output_obj = plugin_utils.run_quick_command(cmd) 433 | stdout, stderr = output_obj.stdout.decode(), output_obj.stderr.decode() 434 | plugin_utils.write_output(stdout) 435 | if stderr: 436 | plugin_utils.write_output("--- Error ---") 437 | plugin_utils.write_output(stderr) 438 | 439 | # Add ssh config to end of config file, if not already present. 440 | template_path = templates_path / "git_ssh_config_block.txt" 441 | context = { 442 | "server_ip": ipaddr, 443 | "server_username": dsd_config.server_username, 444 | } 445 | git_config_block = plugin_utils.get_template_string(template_path, context) 446 | path_git_config = Path.home() / ".ssh" / "config" 447 | contents_git_config = path_git_config.read_text() 448 | 449 | if git_config_block not in contents_git_config: 450 | # Add new config block to ~/.ssh/config. 451 | contents = contents_git_config + "\n" + git_config_block 452 | path_git_config.write_text(contents) 453 | 454 | # Set up project on remote. 455 | template_path = templates_path / "post-receive" 456 | project_path = Path(f"/home/{dsd_config.server_username}/{dsd_config.local_project_name}") 457 | 458 | # Make a project directory. 459 | cmd = f"mkdir -p {project_path}" 460 | run_server_cmd_ssh(cmd) 461 | 462 | cmd = f"chown -R {dsd_config.server_username}:{dsd_config.server_username} {project_path}" 463 | run_server_cmd_ssh(cmd) 464 | 465 | # Set default branch to main. 466 | plugin_utils.write_output(" Setting default branch to main.") 467 | cmd = "git config --global init.defaultBranch main" 468 | run_server_cmd_ssh(cmd) 469 | 470 | # Make a bare git repository. 471 | cmd = f"git init --bare /home/{dsd_config.server_username}/{dsd_config.local_project_name}.git" 472 | run_server_cmd_ssh(cmd) 473 | 474 | # Write post-receive hook. 475 | context = { 476 | "project_path": project_path.as_posix(), 477 | } 478 | post_receive_string = plugin_utils.get_template_string(template_path, context) 479 | 480 | post_receive_path = Path(f"{project_path}.git") / "hooks" / "post-receive" 481 | cmd = f'echo "{post_receive_string}" > {post_receive_path.as_posix()}' 482 | run_server_cmd_ssh(cmd) 483 | 484 | # Make hook executable. 485 | plugin_utils.write_output(" Making hook executable...") 486 | cmd = f"chmod +x {post_receive_path.as_posix()} " 487 | run_server_cmd_ssh(cmd) 488 | 489 | # --- Local configuration --- 490 | 491 | plugin_utils.write_output(" Adding remote to local Git project.") 492 | # cmd = f"git remote add do_server '{dsd_config.server_username}@{os.environ.get("DSD_HOST_IPADDR")}:{dsd_config.local_project_name}.git'" 493 | cmd = f"git remote add do_server 'git-server:/home/{dsd_config.server_username}/{dsd_config.local_project_name}.git'" 494 | output_obj = plugin_utils.run_quick_command(cmd) 495 | stdout, stderr = output_obj.stdout.decode(), output_obj.stderr.decode() 496 | plugin_utils.write_output(stdout) 497 | if stderr: 498 | plugin_utils.write_output("--- Error ---") 499 | plugin_utils.write_output(stderr) 500 | 501 | 502 | def install_caddy(): 503 | """Install Caddy, for static file serving.""" 504 | plugin_utils.write_output("Installing Caddy, to serve static files.") 505 | 506 | # --- Installation --- 507 | cmd = "sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl" 508 | run_server_cmd_ssh(cmd) 509 | 510 | cmd = "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg" 511 | run_server_cmd_ssh(cmd) 512 | 513 | cmd = "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list" 514 | run_server_cmd_ssh(cmd) 515 | 516 | cmd = "sudo apt-get update && sudo apt-get install caddy" 517 | run_server_cmd_ssh(cmd) 518 | 519 | 520 | 521 | 522 | def push_project(): 523 | """Push the project to the server.""" 524 | plugin_utils.write_output(" Pushing project code to server.") 525 | # DEV: --force during development 526 | # Skip during local testing. 527 | if dsd_config.unit_testing: 528 | return 529 | 530 | cmd = f"git push do_server main --force" 531 | output_obj = plugin_utils.run_quick_command(cmd) 532 | stdout, stderr = output_obj.stdout.decode(), output_obj.stderr.decode() 533 | plugin_utils.write_output(stdout) 534 | if stderr: 535 | plugin_utils.write_output("--- Error ---") 536 | plugin_utils.write_output(stderr) 537 | 538 | 539 | def set_on_do(): 540 | """Set the ON_DIGITALOCEAN env var.""" 541 | # DEV: This may not persist where it's needed? 542 | # Write a .env file and export it when activating venv? 543 | plugin_utils.write_output(" Setting ON_DIGITALOCEAN env var.") 544 | cmd = "export ON_DIGITALOCEAN=1" 545 | run_server_cmd_ssh(cmd) 546 | 547 | def serve_project(): 548 | """Start serving the project. 549 | 550 | - build venv 551 | - set env vars 552 | - start server process 553 | - ensure available 554 | """ 555 | # Run serve script. 556 | project_path = Path(f"/home/{dsd_config.server_username}/{dsd_config.local_project_name}") 557 | serve_script_path = f"{project_path}/serve_project.sh" 558 | cmd = f"{serve_script_path}" 559 | run_server_cmd_ssh(cmd) 560 | --------------------------------------------------------------------------------