Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check version control status and common Django errors #17

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions cr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,34 @@
USER_AGENT = f"CodeRed-CLI/{VERSION} ({DOCS_LINK})"


PROD_BRANCHES = [
"live",
"main",
"master",
"prod",
"production",
"release",
]


STAGING_BRANCHES = [
"dev",
"develop",
"development",
"gold",
"pre-prod",
"pre-production",
"pre-release",
"preprod",
"preproduction",
"prerelease",
"stage",
"staging",
"test",
"testing",
]


class ConfigurationError(Exception):
"""
Raised when project does not match expected configuration.
Expand Down
134 changes: 117 additions & 17 deletions cr/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,32 @@
from urllib.request import urlopen

import certifi
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress

from cr import DOCS_LINK
from cr import LOGGER
from cr import PROD_BRANCHES
from cr import STAGING_BRANCHES
from cr import USER_AGENT
from cr import VERSION
from cr import AppType
from cr import ConfigurationError
from cr import DatabaseType
from cr import Env
from cr import UserCancelError
from cr.rich_utils import Console
from cr.utils import django_manage_check
from cr.utils import django_requirements_check
from cr.utils import django_run_check
from cr.utils import django_run_migratecheck
from cr.utils import django_settings_check
from cr.utils import django_settings_fix
from cr.utils import django_wsgi_check
from cr.utils import django_wsgi_find
from cr.utils import git_branch
from cr.utils import git_uncommitted
from cr.utils import git_unpushed
from cr.utils import html_index_check
from cr.utils import wagtail_settings_fix
from cr.utils import wordpress_wpconfig_check
Expand Down Expand Up @@ -108,7 +115,7 @@ def database(self) -> DatabaseServer:
"""
return getattr(self, f"{self.env}_dbserver")

def local_check_path(self, p: Path, c: Optional[Console]) -> None:
def local_check(self, p: Path, c: Optional[Console]) -> None:
"""
Check that provided Path ``p`` appears to contain a valid AppType
project. If Console ``c`` is provided, ask the user for input to
Expand Down Expand Up @@ -207,13 +214,10 @@ def local_check_django(self, p: Path, c: Optional[Console] = None) -> None:
# If settings file is misconfigured, offer to fix it.
except ConfigurationError:
LOGGER.warning("Settings file may be misconfigured. %s", settings)
if (
c
and "y"
== c.input(
f"Settings file `{settings_rel}` may be misconfigured. "
"Correct it? [prompt.choices](y/N)[/] "
).lower()
if c and c.prompt_yn(
f"Settings file `{settings_rel}` may be misconfigured. "
"Correct it?",
nouser=False,
):
fix_me = True
if fix_me:
Expand All @@ -235,6 +239,105 @@ def local_check_wordpress(
except FileNotFoundError as err:
_prompt_filenotfound(err, c)

def local_predeploy(self, p: Path, c: Optional[Console]) -> None:
"""
Runs various common checks before a deployment, to help prevent the
user from creating a broken deployment or deviating from the recommended
development process.
"""

# Check git branch.
b = git_branch()
if b and self.env == Env.STAGING and b not in STAGING_BRANCHES:
if c and not c.prompt_yn(
f"You are deploying to STAGING from the `{b}` branch!\n"
"It is recommended to deploy from a dedicated staging branch.\n"
"Continue anyways?",
nouser=True,
):
raise UserCancelError()
if b and self.env == Env.PROD and b not in PROD_BRANCHES:
if c and not c.prompt_yn(
f"You are deploying to PROD from the `{b}` branch!\n"
"It is recommended to deploy from a dedicated production branch.\n"
"Continue anyways?",
nouser=True,
):
raise UserCancelError()

# Check for un-commited changes.
if git_uncommitted():
if c and not c.prompt_yn(
"You have changes which are NOT COMMITTED to git!\n"
"It is recommended to commit and push your changes before deploying.\n"
"Continue anyways?",
nouser=True,
):
raise UserCancelError()

# Check for un-pushed.
if git_unpushed():
if c and not c.prompt_yn(
"You have changes which are NOT PUSHED to git!\n"
"It is recommended to push your changes before deploying.\n"
"Continue anyways?",
nouser=True,
):
raise UserCancelError()

if self.app_type in [
AppType.CODEREDCMS,
AppType.DJANGO,
AppType.WAGTAIL,
]:
self.local_predeploy_django(p, c)

def local_predeploy_django(self, p: Path, c: Optional[Console]) -> None:
"""
Runs Django-specific checks before a deployment, to help prevent the
user from creating a broken deployment.
"""
if c:
c.print("Checking Django project for potential problems...", end="")
try:
ok, output = django_run_check(p)
except FileNotFoundError:
ok = False
output = "Could not find python on this system."
if c and ok:
c.print(" [cr.success]OK[/]")
if c and not ok:
c.print(" [cr.fail]FAIL[/]")
if not c.prompt_yn(
"Django check returned the following errors:\n\n"
f"{output}\n\n"
"TIP: be sure to activate your virtual environment and install requirements.txt.\n"
"Continue anyways?",
nouser=True,
):
raise UserCancelError()

# Check migrations.
if c:
c.print("Checking for missing migrations...", end="")
try:
ok, output = django_run_migratecheck(p)
except FileNotFoundError:
ok = False
output = "Could not find python on this system."
if c and ok:
c.print(" [cr.success]OK[/]")
if c and not ok:
c.print(" [cr.fail]FAIL[/]")
if not c.prompt_yn(
f"\n{output}\n\n"
"TIP: did you forget to run `manage.py makemigrations`?\n"
"TIP: be sure to activate your virtual environment and install requirements.txt.\n"
"Continue anyways?",
nouser=True,
):
raise UserCancelError()

def api_set_django_project(self, name: str) -> None:
"""
PATCH the webapp on coderedapi and set the local django_project.
Expand Down Expand Up @@ -512,13 +615,10 @@ def _prompt_filenotfound(
found.
"""
LOGGER.warning("%s file does not exist!", err)
if (
c
and "y"
!= c.input(
f"Missing `{err}` file. "
"Without this your app will not deploy correctly. "
"Continue anyways? [prompt.choices](y/N)[/] ",
).lower()
if c and not c.prompt_yn(
f"Missing `{err}` file.\n"
"Without this your app will not deploy correctly. "
"Continue anyways?",
nouser=False,
):
raise UserCancelError()
36 changes: 33 additions & 3 deletions cr/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,22 @@ def add_args(self, p: argparse.ArgumentParser):
"Re-deploys the website version already on CodeRed Cloud."
),
)
p.add_argument(
"--skip-predeploy",
action="store_true",
help=(
"Skip common pre-deployment checks. "
"Only checks for local configuration errors."
),
)

@classmethod
def run(self, args: argparse.Namespace):
w = self.get_webapp(args)
if not args.no_upload:
w.local_check_path(args.path, CONSOLE)
w.local_check(args.path, CONSOLE)
if not args.skip_predeploy:
w.local_predeploy(args.path, CONSOLE)

with Progress(
TextColumn("[progress.description]{task.description}"),
Expand Down Expand Up @@ -357,11 +367,21 @@ def add_args(self, p: argparse.ArgumentParser):
p.add_argument(*arg_env.args, **arg_env.kwargs)
p.add_argument(*arg_token.args, **arg_token.kwargs)
p.add_argument(*arg_path.args, **arg_path.kwargs)
p.add_argument(
"--skip-predeploy",
action="store_true",
help=(
"Skip common pre-deployment checks. "
"Only checks for local configuration errors."
),
)

@classmethod
def run(self, args: argparse.Namespace):
w = self.get_webapp(args)
w.local_check_path(args.path, CONSOLE)
w.local_check(args.path, CONSOLE)
if not args.skip_predeploy:
w.local_predeploy(args.path, CONSOLE)


class Logs(Command):
Expand Down Expand Up @@ -521,6 +541,14 @@ def add_args(self, p: argparse.ArgumentParser):
"Defaults to `/www` which is the main directory."
),
)
p.add_argument(
"--skip-predeploy",
action="store_true",
help=(
"Skip common pre-deployment checks. "
"Only checks for local configuration errors."
),
)

@classmethod
def run(self, args: argparse.Namespace):
Expand All @@ -529,7 +557,9 @@ def run(self, args: argparse.Namespace):
# If the destination is the usual ``/www`` dir, and ``--path`` is a
# directory, confirm with the user.
if args.remote == PurePosixPath("/www") and args.path.is_dir():
w.local_check_path(args.path, CONSOLE)
w.local_check(args.path, CONSOLE)
if not args.skip_predeploy:
w.local_predeploy(args.path, CONSOLE)

with Progress(
TextColumn("[progress.description]{task.description}"),
Expand Down
29 changes: 28 additions & 1 deletion cr/rich_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from typing import Optional

from rich.console import WINDOWS
from rich.console import Console
from rich.console import Console as _Console
from rich.console import Group
from rich.console import RenderableType
from rich.highlighter import RegexHighlighter
Expand Down Expand Up @@ -45,6 +45,8 @@
"cr.argparse_text": "default",
"cr.code": "bright_magenta",
"cr.progress_print": "bright_black",
"cr.success": "bright_green",
"cr.fail": "bright_red",
"cr.update_border": "bright_black",
}
)
Expand All @@ -64,6 +66,31 @@ class CustomHighlighter(RegexHighlighter):
]


class Console(_Console):
"""
Adds extra functionality for custom prompt behavior.
"""

def prompt_yn(self, message: str, nouser: bool) -> bool:
"""
Prompts the user with Yes/No. If user enters Yes, return True.
Otherwise, return False.

If the prompt occurs within a headless terminal (i.e. in a CI/CD
pipeline), always return the value of ``nouser``.
"""
if self.is_interactive:
val = self.input(message + " [prompt.choices](y/N)[/] ")
return val.strip().lower() == "y"
else:
self.print(message + " [prompt.choices](yes/no)[/] ")
if nouser:
self.print(" (Continuing without user input)")
else:
self.print(" (Not continuting without user input)")
return nouser


CONSOLE = Console(highlighter=CustomHighlighter(), theme=RICH_THEME)
CONSOLE_ERR = Console(
highlighter=CustomHighlighter(), theme=RICH_THEME, stderr=True
Expand Down
Loading