phoxtail 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phoxtail-0.1.0/LICENSE +28 -0
- phoxtail-0.1.0/MANIFEST.in +9 -0
- phoxtail-0.1.0/PKG-INFO +123 -0
- phoxtail-0.1.0/README.md +92 -0
- phoxtail-0.1.0/phoxtail/__init__.py +3 -0
- phoxtail-0.1.0/phoxtail/__main__.py +80 -0
- phoxtail-0.1.0/phoxtail/commands/__init__.py +1 -0
- phoxtail-0.1.0/phoxtail/commands/db.py +323 -0
- phoxtail-0.1.0/phoxtail/commands/docker.py +333 -0
- phoxtail-0.1.0/phoxtail/commands/env.py +235 -0
- phoxtail-0.1.0/phoxtail/commands/lint.py +53 -0
- phoxtail-0.1.0/phoxtail/commands/manage.py +45 -0
- phoxtail-0.1.0/phoxtail/commands/media.py +152 -0
- phoxtail-0.1.0/phoxtail/commands/nginx.py +124 -0
- phoxtail-0.1.0/phoxtail/commands/requirements.py +67 -0
- phoxtail-0.1.0/phoxtail/commands/ssl.py +96 -0
- phoxtail-0.1.0/phoxtail/commands/test.py +52 -0
- phoxtail-0.1.0/phoxtail/templates/docker/Dockerfile +47 -0
- phoxtail-0.1.0/phoxtail/templates/docker/docker-compose.yaml +121 -0
- phoxtail-0.1.0/phoxtail/templates/env/development.env +23 -0
- phoxtail-0.1.0/phoxtail/templates/env/production.env +40 -0
- phoxtail-0.1.0/phoxtail/templates/nginx/initial.conf +39 -0
- phoxtail-0.1.0/phoxtail/templates/nginx/production.conf +153 -0
- phoxtail-0.1.0/phoxtail/utils/__init__.py +1 -0
- phoxtail-0.1.0/phoxtail/utils/config.py +130 -0
- phoxtail-0.1.0/phoxtail/utils/docker.py +35 -0
- phoxtail-0.1.0/phoxtail/utils/env.py +29 -0
- phoxtail-0.1.0/phoxtail/utils/templates.py +24 -0
- phoxtail-0.1.0/phoxtail/utils/venv.py +109 -0
- phoxtail-0.1.0/phoxtail.egg-info/PKG-INFO +123 -0
- phoxtail-0.1.0/phoxtail.egg-info/SOURCES.txt +35 -0
- phoxtail-0.1.0/phoxtail.egg-info/dependency_links.txt +1 -0
- phoxtail-0.1.0/phoxtail.egg-info/entry_points.txt +2 -0
- phoxtail-0.1.0/phoxtail.egg-info/requires.txt +9 -0
- phoxtail-0.1.0/phoxtail.egg-info/top_level.txt +1 -0
- phoxtail-0.1.0/pyproject.toml +65 -0
- phoxtail-0.1.0/setup.cfg +4 -0
phoxtail-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Phoxmor
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
phoxtail-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: phoxtail
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The engine behind every Phoxtail project.
|
|
5
|
+
Author-email: Phoxmor <info@phoxtail.com>
|
|
6
|
+
Maintainer-email: Evangelos Pisinas <evangelos.pisinas@phoxtail.com>
|
|
7
|
+
License-Expression: BSD-3-Clause
|
|
8
|
+
Project-URL: Homepage, https://phoxtail.com
|
|
9
|
+
Project-URL: Repository, https://github.com/phoxmor/phoxtail
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Wagtail
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: typer>=0.9.0
|
|
23
|
+
Requires-Dist: rich>=13.0.0
|
|
24
|
+
Requires-Dist: questionary>=2.0.0
|
|
25
|
+
Requires-Dist: jinja2>=3.1.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pip-tools>=7.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.4.0; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# Phoxtail
|
|
33
|
+
|
|
34
|
+
The engine behind every Phoxtail project.
|
|
35
|
+
|
|
36
|
+
Phoxtail is a CLI toolkit that automates the development and deployment workflow for Phoxtail projects — Docker configuration, environment setup, database operations, nginx, SSL, media sync, and more.
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- Python 3.11+
|
|
41
|
+
- [uv](https://docs.astral.sh/uv/) (recommended) or pip
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
### As a CLI tool
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Recommended: installs in an isolated environment
|
|
49
|
+
uv tool install phoxtail
|
|
50
|
+
|
|
51
|
+
# Or with pip/pipx
|
|
52
|
+
pip install phoxtail
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### For development
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
git clone git@github.com:phoxmor/phoxtail.git
|
|
59
|
+
cd phoxtail
|
|
60
|
+
uv venv
|
|
61
|
+
source .venv/bin/activate
|
|
62
|
+
uv pip install -e ".[dev]"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
Run `phoxtail` from the root of any Phoxtail project (where `phoxtail.toml` lives):
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
phoxtail --help
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Commands
|
|
74
|
+
|
|
75
|
+
| Command | Description |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `phoxtail docker up/down/restart` | Docker lifecycle management |
|
|
78
|
+
| `phoxtail docker create compose` | Generate docker-compose.yaml |
|
|
79
|
+
| `phoxtail docker create dockerfile` | Generate Dockerfile |
|
|
80
|
+
| `phoxtail env create` | Generate .env files (dev/production) |
|
|
81
|
+
| `phoxtail nginx create initial` | Nginx config for SSL certificate retrieval |
|
|
82
|
+
| `phoxtail nginx create production` | Nginx config with SSL/TLS |
|
|
83
|
+
| `phoxtail ssl obtain/renew` | SSL certificate management |
|
|
84
|
+
| `phoxtail db backup/restore` | Database backup and restore |
|
|
85
|
+
| `phoxtail db pull` | Pull database from remote server |
|
|
86
|
+
| `phoxtail media pull` | Sync media files from remote server |
|
|
87
|
+
| `phoxtail requirements compile` | Compile Python dependencies |
|
|
88
|
+
| `phoxtail manage <command>` | Run Django management commands |
|
|
89
|
+
| `phoxtail test` | Run the test suite |
|
|
90
|
+
| `phoxtail lint` | Run linting and formatting |
|
|
91
|
+
| `phoxtail version` | Show CLI version |
|
|
92
|
+
|
|
93
|
+
## Project detection
|
|
94
|
+
|
|
95
|
+
Phoxtail detects projects by looking for `phoxtail.toml` in the current directory. This file declares the project name, Docker image prefix, database clusters, and their dependencies.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
### Running tests
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pytest
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Project structure
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
phoxtail/
|
|
109
|
+
phoxtail/
|
|
110
|
+
__init__.py # Package version
|
|
111
|
+
__main__.py # CLI entry point (Typer app)
|
|
112
|
+
commands/ # Command modules
|
|
113
|
+
utils/ # Shared utilities (config, docker, env, templates)
|
|
114
|
+
templates/ # Jinja2 templates (Dockerfile, compose, nginx, env)
|
|
115
|
+
tests/ # Test suite
|
|
116
|
+
pyproject.toml
|
|
117
|
+
LICENSE
|
|
118
|
+
README.md
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
BSD 3-Clause. See [LICENSE](LICENSE).
|
phoxtail-0.1.0/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Phoxtail
|
|
2
|
+
|
|
3
|
+
The engine behind every Phoxtail project.
|
|
4
|
+
|
|
5
|
+
Phoxtail is a CLI toolkit that automates the development and deployment workflow for Phoxtail projects — Docker configuration, environment setup, database operations, nginx, SSL, media sync, and more.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python 3.11+
|
|
10
|
+
- [uv](https://docs.astral.sh/uv/) (recommended) or pip
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
### As a CLI tool
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Recommended: installs in an isolated environment
|
|
18
|
+
uv tool install phoxtail
|
|
19
|
+
|
|
20
|
+
# Or with pip/pipx
|
|
21
|
+
pip install phoxtail
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### For development
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
git clone git@github.com:phoxmor/phoxtail.git
|
|
28
|
+
cd phoxtail
|
|
29
|
+
uv venv
|
|
30
|
+
source .venv/bin/activate
|
|
31
|
+
uv pip install -e ".[dev]"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Run `phoxtail` from the root of any Phoxtail project (where `phoxtail.toml` lives):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
phoxtail --help
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Commands
|
|
43
|
+
|
|
44
|
+
| Command | Description |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `phoxtail docker up/down/restart` | Docker lifecycle management |
|
|
47
|
+
| `phoxtail docker create compose` | Generate docker-compose.yaml |
|
|
48
|
+
| `phoxtail docker create dockerfile` | Generate Dockerfile |
|
|
49
|
+
| `phoxtail env create` | Generate .env files (dev/production) |
|
|
50
|
+
| `phoxtail nginx create initial` | Nginx config for SSL certificate retrieval |
|
|
51
|
+
| `phoxtail nginx create production` | Nginx config with SSL/TLS |
|
|
52
|
+
| `phoxtail ssl obtain/renew` | SSL certificate management |
|
|
53
|
+
| `phoxtail db backup/restore` | Database backup and restore |
|
|
54
|
+
| `phoxtail db pull` | Pull database from remote server |
|
|
55
|
+
| `phoxtail media pull` | Sync media files from remote server |
|
|
56
|
+
| `phoxtail requirements compile` | Compile Python dependencies |
|
|
57
|
+
| `phoxtail manage <command>` | Run Django management commands |
|
|
58
|
+
| `phoxtail test` | Run the test suite |
|
|
59
|
+
| `phoxtail lint` | Run linting and formatting |
|
|
60
|
+
| `phoxtail version` | Show CLI version |
|
|
61
|
+
|
|
62
|
+
## Project detection
|
|
63
|
+
|
|
64
|
+
Phoxtail detects projects by looking for `phoxtail.toml` in the current directory. This file declares the project name, Docker image prefix, database clusters, and their dependencies.
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
### Running tests
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pytest
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Project structure
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
phoxtail/
|
|
78
|
+
phoxtail/
|
|
79
|
+
__init__.py # Package version
|
|
80
|
+
__main__.py # CLI entry point (Typer app)
|
|
81
|
+
commands/ # Command modules
|
|
82
|
+
utils/ # Shared utilities (config, docker, env, templates)
|
|
83
|
+
templates/ # Jinja2 templates (Dockerfile, compose, nginx, env)
|
|
84
|
+
tests/ # Test suite
|
|
85
|
+
pyproject.toml
|
|
86
|
+
LICENSE
|
|
87
|
+
README.md
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
BSD 3-Clause. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Main CLI entry point for Phoxtail."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
|
|
7
|
+
from phoxtail.commands import (
|
|
8
|
+
db,
|
|
9
|
+
docker,
|
|
10
|
+
env,
|
|
11
|
+
lint,
|
|
12
|
+
manage,
|
|
13
|
+
media,
|
|
14
|
+
nginx,
|
|
15
|
+
requirements,
|
|
16
|
+
ssl,
|
|
17
|
+
test,
|
|
18
|
+
)
|
|
19
|
+
from phoxtail.utils.config import _find_config_file
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="phoxtail",
|
|
23
|
+
help="The engine behind every Phoxtail project.",
|
|
24
|
+
add_completion=True,
|
|
25
|
+
invoke_without_command=True,
|
|
26
|
+
)
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
# Commands that don't require a phoxtail project.
|
|
30
|
+
NO_PROJECT_COMMANDS = {"version"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.callback(invoke_without_command=True)
|
|
34
|
+
def main(ctx: typer.Context):
|
|
35
|
+
"""The engine behind every Phoxtail project."""
|
|
36
|
+
if ctx.invoked_subcommand is None:
|
|
37
|
+
console.print(ctx.get_help())
|
|
38
|
+
raise typer.Exit()
|
|
39
|
+
|
|
40
|
+
if ctx.invoked_subcommand in NO_PROJECT_COMMANDS or ctx.resilient_parsing:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
if _find_config_file() is None:
|
|
44
|
+
console.print(
|
|
45
|
+
Panel(
|
|
46
|
+
"No [bold]phoxtail.toml[/bold] found in this directory or any parent.\n"
|
|
47
|
+
"Run this command from the root of a Phoxtail project.",
|
|
48
|
+
title="[red]Not a Phoxtail project[/red]",
|
|
49
|
+
border_style="red",
|
|
50
|
+
expand=False,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
raise typer.Exit(code=1)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Command groups
|
|
57
|
+
app.add_typer(docker.app, name="docker", help="Docker lifecycle and configuration")
|
|
58
|
+
app.add_typer(db.app, name="db", help="Database operations")
|
|
59
|
+
app.add_typer(media.app, name="media", help="Media file management")
|
|
60
|
+
app.add_typer(nginx.app, name="nginx", help="Nginx configuration")
|
|
61
|
+
app.add_typer(env.app, name="env", help="Environment configuration")
|
|
62
|
+
app.add_typer(requirements.app, name="requirements", help="Python requirements")
|
|
63
|
+
app.add_typer(ssl.app, name="ssl", help="SSL certificate management")
|
|
64
|
+
|
|
65
|
+
# Top-level commands
|
|
66
|
+
app.add_typer(manage.app, name="manage", help="Run Django management commands")
|
|
67
|
+
app.add_typer(test.app, name="test", help="Run the test suite")
|
|
68
|
+
app.add_typer(lint.app, name="lint", help="Run linting and formatting")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command()
|
|
72
|
+
def version():
|
|
73
|
+
"""Show the CLI version."""
|
|
74
|
+
from phoxtail import __version__
|
|
75
|
+
|
|
76
|
+
console.print(f"Phoxtail CLI v{__version__}", style="bold green")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command modules for Phoxtail CLI."""
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Database management commands for Phoxtail."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
12
|
+
|
|
13
|
+
from phoxtail.utils.config import get_cluster_names, get_clusters, resolve_cluster_order
|
|
14
|
+
from phoxtail.utils.docker import docker_db, docker_manage
|
|
15
|
+
from phoxtail.utils.env import read_env_value
|
|
16
|
+
|
|
17
|
+
app = typer.Typer()
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_data_cluster_enum():
|
|
22
|
+
"""Build DataCluster enum from phoxtail.toml cluster names + 'all'."""
|
|
23
|
+
members = {name.upper(): name for name in get_cluster_names()}
|
|
24
|
+
members["ALL"] = "all"
|
|
25
|
+
return Enum("DataCluster", members, type=str)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DataCluster = _build_data_cluster_enum()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _deep_localize(val, hostname_map: dict[str, str]):
|
|
32
|
+
"""Recursively replace hostnames in a data structure.
|
|
33
|
+
|
|
34
|
+
Handles plain dicts/lists and JSON-encoded strings that may
|
|
35
|
+
contain hostname references (e.g. StreamField data).
|
|
36
|
+
"""
|
|
37
|
+
if isinstance(val, dict):
|
|
38
|
+
return {k: _deep_localize(v, hostname_map) for k, v in val.items()}
|
|
39
|
+
elif isinstance(val, list):
|
|
40
|
+
# Natural key format: ["hostname", port]
|
|
41
|
+
if len(val) == 2 and isinstance(val[0], str) and isinstance(val[1], int):
|
|
42
|
+
h = val[0].lower()
|
|
43
|
+
if h in hostname_map:
|
|
44
|
+
return [hostname_map[h], val[1]]
|
|
45
|
+
return [_deep_localize(i, hostname_map) for i in val]
|
|
46
|
+
elif isinstance(val, str):
|
|
47
|
+
if (val.startswith("{") and val.endswith("}")) or (
|
|
48
|
+
val.startswith("[") and val.endswith("]")
|
|
49
|
+
):
|
|
50
|
+
try:
|
|
51
|
+
parsed = json.loads(val)
|
|
52
|
+
translated = _deep_localize(parsed, hostname_map)
|
|
53
|
+
return json.dumps(translated)
|
|
54
|
+
except (json.JSONDecodeError, TypeError):
|
|
55
|
+
pass
|
|
56
|
+
return val
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _localize_hostnames(data: list, target_domain: str) -> list:
|
|
60
|
+
"""Translate remote hostnames to the target domain.
|
|
61
|
+
|
|
62
|
+
Builds a hostname map from wagtailcore.site entries, then applies it
|
|
63
|
+
across the entire fixture data including JSON-encoded string fields.
|
|
64
|
+
"""
|
|
65
|
+
hostname_map = {}
|
|
66
|
+
for entry in data:
|
|
67
|
+
if entry.get("model") == "wagtailcore.site":
|
|
68
|
+
fields = entry.get("fields", {})
|
|
69
|
+
old_h = fields.get("hostname", "").lower()
|
|
70
|
+
if (
|
|
71
|
+
not old_h
|
|
72
|
+
or old_h == target_domain
|
|
73
|
+
or old_h.endswith(f".{target_domain}")
|
|
74
|
+
):
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
ipaddress.ip_address(old_h)
|
|
78
|
+
continue
|
|
79
|
+
except ValueError:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
parts = old_h.split(".")
|
|
83
|
+
if len(parts) > 2:
|
|
84
|
+
subdomain = ".".join(parts[:-2])
|
|
85
|
+
new_h = f"{subdomain}.{target_domain}"
|
|
86
|
+
else:
|
|
87
|
+
new_h = target_domain
|
|
88
|
+
hostname_map[old_h] = new_h
|
|
89
|
+
fields["hostname"] = new_h
|
|
90
|
+
|
|
91
|
+
if not hostname_map:
|
|
92
|
+
return data
|
|
93
|
+
|
|
94
|
+
for entry in data:
|
|
95
|
+
entry["fields"] = _deep_localize(entry.get("fields", {}), hostname_map)
|
|
96
|
+
|
|
97
|
+
return data
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _pull_single_cluster(
|
|
101
|
+
cluster_name: str,
|
|
102
|
+
remote_host: str,
|
|
103
|
+
remote_dir: str,
|
|
104
|
+
localize: bool,
|
|
105
|
+
progress,
|
|
106
|
+
task,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Pull and load a single cluster from the remote server."""
|
|
109
|
+
clusters = get_clusters()
|
|
110
|
+
selected_apps = clusters[cluster_name]["apps"]
|
|
111
|
+
dump_filename = f"remote_{cluster_name}_pull.json"
|
|
112
|
+
local_dump_path = Path.cwd() / dump_filename
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
remote_cmd = (
|
|
116
|
+
f"cd {remote_dir} && "
|
|
117
|
+
f"docker compose exec web python manage.py dumpdata "
|
|
118
|
+
f"{' '.join(selected_apps)} "
|
|
119
|
+
f"--natural-foreign --indent 2 > {dump_filename}"
|
|
120
|
+
)
|
|
121
|
+
progress.update(
|
|
122
|
+
task, description=f"Dumping {cluster_name.upper()} on remote..."
|
|
123
|
+
)
|
|
124
|
+
subprocess.run(
|
|
125
|
+
["ssh", remote_host, remote_cmd],
|
|
126
|
+
check=True,
|
|
127
|
+
capture_output=True,
|
|
128
|
+
text=True,
|
|
129
|
+
)
|
|
130
|
+
progress.update(
|
|
131
|
+
task, description=f"Downloading {cluster_name.upper()} dump..."
|
|
132
|
+
)
|
|
133
|
+
subprocess.run(
|
|
134
|
+
[
|
|
135
|
+
"scp",
|
|
136
|
+
f"{remote_host}:{remote_dir}/{dump_filename}",
|
|
137
|
+
str(local_dump_path),
|
|
138
|
+
],
|
|
139
|
+
check=True,
|
|
140
|
+
capture_output=True,
|
|
141
|
+
text=True,
|
|
142
|
+
)
|
|
143
|
+
# Clean up remote dump file (failure is non-critical)
|
|
144
|
+
subprocess.run(
|
|
145
|
+
["ssh", remote_host, f"rm -f {remote_dir}/{dump_filename}"],
|
|
146
|
+
capture_output=True,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if localize:
|
|
150
|
+
target_domain = read_env_value("DOMAIN") or "localhost"
|
|
151
|
+
progress.update(
|
|
152
|
+
task,
|
|
153
|
+
description=(
|
|
154
|
+
f"Localizing {cluster_name.upper()} "
|
|
155
|
+
f"hostnames to {target_domain}..."
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
with open(local_dump_path) as f:
|
|
159
|
+
data = json.load(f)
|
|
160
|
+
data = _localize_hostnames(data, target_domain)
|
|
161
|
+
with open(local_dump_path, "w") as f:
|
|
162
|
+
json.dump(data, f, indent=2)
|
|
163
|
+
|
|
164
|
+
progress.update(
|
|
165
|
+
task, description=f"Loading {cluster_name.upper()} into database..."
|
|
166
|
+
)
|
|
167
|
+
docker_manage("loaddata", local_dump_path.name)
|
|
168
|
+
finally:
|
|
169
|
+
if local_dump_path.exists():
|
|
170
|
+
local_dump_path.unlink()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.command(name="pull")
|
|
174
|
+
def pull(
|
|
175
|
+
cluster: DataCluster = typer.Argument(
|
|
176
|
+
...,
|
|
177
|
+
help="The data cluster to pull from the remote server",
|
|
178
|
+
),
|
|
179
|
+
remote_host: str = typer.Argument(
|
|
180
|
+
...,
|
|
181
|
+
help="SSH connection string (e.g., user@example.com)",
|
|
182
|
+
),
|
|
183
|
+
remote_dir: str = typer.Argument(
|
|
184
|
+
...,
|
|
185
|
+
help="Path to the project on the remote server",
|
|
186
|
+
),
|
|
187
|
+
localize: bool = typer.Option(
|
|
188
|
+
True,
|
|
189
|
+
"--localize/--no-localize",
|
|
190
|
+
help="Whether to translate remote hostnames to localhost/subdomains.localhost",
|
|
191
|
+
),
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Pull data clusters from a remote server to the local environment.
|
|
194
|
+
|
|
195
|
+
DANGER: This will FLUSH your local database before loading.
|
|
196
|
+
A safety backup is created automatically before flushing.
|
|
197
|
+
|
|
198
|
+
Dependencies are resolved automatically. For example, pulling 'booking'
|
|
199
|
+
will pull 'cms' first since booking data depends on it.
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
cluster_order = resolve_cluster_order(cluster.value)
|
|
203
|
+
except ValueError as e:
|
|
204
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
205
|
+
raise typer.Exit(1)
|
|
206
|
+
|
|
207
|
+
if len(cluster_order) > 1:
|
|
208
|
+
names = " → ".join(c.upper() for c in cluster_order)
|
|
209
|
+
console.print(f"[dim]Resolved pull order: {names}[/dim]")
|
|
210
|
+
|
|
211
|
+
with Progress(
|
|
212
|
+
SpinnerColumn(),
|
|
213
|
+
TextColumn("[progress.description]{task.description}"),
|
|
214
|
+
transient=True,
|
|
215
|
+
) as progress:
|
|
216
|
+
task = progress.add_task(description="Starting...", total=None)
|
|
217
|
+
try:
|
|
218
|
+
progress.update(task, description="Creating safety backup...")
|
|
219
|
+
docker_db(
|
|
220
|
+
"PGPASSWORD=$POSTGRES_PASSWORD pg_dump -h localhost"
|
|
221
|
+
" -U $POSTGRES_USER $POSTGRES_DB"
|
|
222
|
+
" > /db-backups/pre_pull_safety.sql"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
progress.update(task, description="Flushing local database...")
|
|
226
|
+
docker_manage("flush", "--no-input")
|
|
227
|
+
|
|
228
|
+
for cluster_name in cluster_order:
|
|
229
|
+
_pull_single_cluster(
|
|
230
|
+
cluster_name, remote_host, remote_dir, localize, progress, task
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
pulled = ", ".join(c.upper() for c in cluster_order)
|
|
234
|
+
console.print(
|
|
235
|
+
f"[green bold]✓ {pulled} data pulled"
|
|
236
|
+
" and loaded[/green bold]"
|
|
237
|
+
)
|
|
238
|
+
except subprocess.CalledProcessError as e:
|
|
239
|
+
stderr = e.stderr if e.stderr else str(e)
|
|
240
|
+
console.print(f"[red]Error:[/red] {stderr}")
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@app.command(name="backup")
|
|
245
|
+
def backup() -> None:
|
|
246
|
+
"""Create a database backup.
|
|
247
|
+
|
|
248
|
+
Saves a timestamped SQL dump to the db-backups volume.
|
|
249
|
+
"""
|
|
250
|
+
with Progress(
|
|
251
|
+
SpinnerColumn(),
|
|
252
|
+
TextColumn("[progress.description]{task.description}"),
|
|
253
|
+
transient=True,
|
|
254
|
+
) as progress:
|
|
255
|
+
progress.add_task(description="Creating database backup...", total=None)
|
|
256
|
+
try:
|
|
257
|
+
docker_db(
|
|
258
|
+
"PGPASSWORD=$POSTGRES_PASSWORD pg_dump -h localhost"
|
|
259
|
+
" -U $POSTGRES_USER $POSTGRES_DB"
|
|
260
|
+
" > /db-backups/$(date +%F_%H-%M-%S).sql"
|
|
261
|
+
)
|
|
262
|
+
console.print("[green bold]✓ Database backup created[/green bold]")
|
|
263
|
+
except subprocess.CalledProcessError as e:
|
|
264
|
+
stderr = e.stderr if e.stderr else str(e)
|
|
265
|
+
console.print(f"[red]Error:[/red] {stderr}")
|
|
266
|
+
raise typer.Exit(1)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@app.command(name="restore")
|
|
270
|
+
def restore(
|
|
271
|
+
backup_file: str = typer.Argument(
|
|
272
|
+
...,
|
|
273
|
+
help="Backup filename to restore (e.g., 2026-03-15_12-00-00.sql)",
|
|
274
|
+
),
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Restore the database from a backup file.
|
|
277
|
+
|
|
278
|
+
A safety backup of the current state is created automatically before restoring.
|
|
279
|
+
The restore runs in a single transaction and will roll back on error.
|
|
280
|
+
"""
|
|
281
|
+
from rich.prompt import Confirm
|
|
282
|
+
|
|
283
|
+
if not Confirm.ask(
|
|
284
|
+
"This will replace the current database with "
|
|
285
|
+
f"[bold]{backup_file}[/bold]. Continue?",
|
|
286
|
+
default=False,
|
|
287
|
+
):
|
|
288
|
+
raise typer.Exit(0)
|
|
289
|
+
|
|
290
|
+
with Progress(
|
|
291
|
+
SpinnerColumn(),
|
|
292
|
+
TextColumn("[progress.description]{task.description}"),
|
|
293
|
+
transient=True,
|
|
294
|
+
) as progress:
|
|
295
|
+
try:
|
|
296
|
+
progress.add_task(
|
|
297
|
+
description="Creating safety backup of current state...", total=None
|
|
298
|
+
)
|
|
299
|
+
docker_db(
|
|
300
|
+
"PGPASSWORD=$POSTGRES_PASSWORD pg_dump -h localhost"
|
|
301
|
+
" -U $POSTGRES_USER $POSTGRES_DB"
|
|
302
|
+
" > /db-backups/pre_restore_safety.sql"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
progress.add_task(
|
|
306
|
+
description=f"Restoring from {backup_file}...", total=None
|
|
307
|
+
)
|
|
308
|
+
docker_db(
|
|
309
|
+
"PGPASSWORD=$POSTGRES_PASSWORD psql -h localhost"
|
|
310
|
+
" -U $POSTGRES_USER -d $POSTGRES_DB"
|
|
311
|
+
" -v ON_ERROR_STOP=1 --single-transaction"
|
|
312
|
+
' -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
|
|
313
|
+
' GRANT ALL ON SCHEMA public TO \\"$POSTGRES_USER\\";'
|
|
314
|
+
' GRANT ALL ON SCHEMA public TO public;"'
|
|
315
|
+
f" -f /db-backups/{backup_file}"
|
|
316
|
+
)
|
|
317
|
+
console.print(
|
|
318
|
+
f"[green bold]✓ Database restored from {backup_file}[/green bold]"
|
|
319
|
+
)
|
|
320
|
+
except subprocess.CalledProcessError as e:
|
|
321
|
+
stderr = e.stderr if e.stderr else str(e)
|
|
322
|
+
console.print(f"[red]Error:[/red] {stderr}")
|
|
323
|
+
raise typer.Exit(1)
|