bluefox-cloud 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.
- bluefox_cloud-0.1.0/.github/workflows/ci.yml +11 -0
- bluefox_cloud-0.1.0/.github/workflows/publish.yml +20 -0
- bluefox_cloud-0.1.0/.gitignore +88 -0
- bluefox_cloud-0.1.0/Dockerfile.docs +15 -0
- bluefox_cloud-0.1.0/Makefile +45 -0
- bluefox_cloud-0.1.0/PKG-INFO +22 -0
- bluefox_cloud-0.1.0/README.md +3 -0
- bluefox_cloud-0.1.0/bluefox_cloud/__init__.py +0 -0
- bluefox_cloud-0.1.0/bluefox_cloud/cloudflare/__init__.py +4 -0
- bluefox_cloud-0.1.0/bluefox_cloud/cloudflare/client.py +203 -0
- bluefox_cloud-0.1.0/bluefox_cloud/cloudflare/dns.py +29 -0
- bluefox_cloud-0.1.0/bluefox_cloud/cloudflare/domains.py +49 -0
- bluefox_cloud-0.1.0/bluefox_cloud/config.py +44 -0
- bluefox_cloud-0.1.0/bluefox_cloud/ssh/__init__.py +3 -0
- bluefox_cloud-0.1.0/bluefox_cloud/ssh/client.py +106 -0
- bluefox_cloud-0.1.0/bluefox_cloud/ssh/scripts/harden.sh +5 -0
- bluefox_cloud-0.1.0/bluefox_cloud/ssh/scripts/install_docker.sh +5 -0
- bluefox_cloud-0.1.0/bluefox_cloud/swarm/__init__.py +3 -0
- bluefox_cloud-0.1.0/bluefox_cloud/swarm/client.py +163 -0
- bluefox_cloud-0.1.0/bluefox_cloud/traefik/__init__.py +3 -0
- bluefox_cloud-0.1.0/bluefox_cloud/traefik/config.py +85 -0
- bluefox_cloud-0.1.0/bluefox_cloud/traefik/tls.py +21 -0
- bluefox_cloud-0.1.0/docs/index.md +54 -0
- bluefox_cloud-0.1.0/docs/stylesheets/extra.css +26 -0
- bluefox_cloud-0.1.0/landing/index.html +608 -0
- bluefox_cloud-0.1.0/mkdocs.yml +50 -0
- bluefox_cloud-0.1.0/pyproject.toml +49 -0
- bluefox_cloud-0.1.0/tests/__init__.py +0 -0
- bluefox_cloud-0.1.0/tests/test_cloudflare.py +257 -0
- bluefox_cloud-0.1.0/tests/test_config.py +98 -0
- bluefox_cloud-0.1.0/tests/test_placeholder.py +2 -0
- bluefox_cloud-0.1.0/tests/test_ssh.py +82 -0
- bluefox_cloud-0.1.0/tests/test_swarm.py +133 -0
- bluefox_cloud-0.1.0/tests/test_traefik.py +128 -0
- bluefox_cloud-0.1.0/uv.lock +538 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
tags: ["v*"]
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
publish:
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
environment: pypi
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- uses: astral-sh/setup-uv@v5
|
|
19
|
+
- run: uv build
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
*.manifest
|
|
31
|
+
*.spec
|
|
32
|
+
|
|
33
|
+
# Installer logs
|
|
34
|
+
pip-log.txt
|
|
35
|
+
pip-delete-this-directory.txt
|
|
36
|
+
|
|
37
|
+
# Unit test / coverage reports
|
|
38
|
+
htmlcov/
|
|
39
|
+
.tox/
|
|
40
|
+
.nox/
|
|
41
|
+
.coverage
|
|
42
|
+
.coverage.*
|
|
43
|
+
.cache
|
|
44
|
+
nosetests.xml
|
|
45
|
+
coverage.xml
|
|
46
|
+
*.cover
|
|
47
|
+
*.py.cover
|
|
48
|
+
.hypothesis/
|
|
49
|
+
.pytest_cache/
|
|
50
|
+
cover/
|
|
51
|
+
|
|
52
|
+
# Translations
|
|
53
|
+
*.mo
|
|
54
|
+
*.pot
|
|
55
|
+
|
|
56
|
+
# Environments
|
|
57
|
+
.env
|
|
58
|
+
.envrc
|
|
59
|
+
.venv
|
|
60
|
+
env/
|
|
61
|
+
venv/
|
|
62
|
+
ENV/
|
|
63
|
+
env.bak/
|
|
64
|
+
venv.bak/
|
|
65
|
+
|
|
66
|
+
# mkdocs documentation
|
|
67
|
+
/site
|
|
68
|
+
|
|
69
|
+
# mypy
|
|
70
|
+
.mypy_cache/
|
|
71
|
+
.dmypy.json
|
|
72
|
+
dmypy.json
|
|
73
|
+
|
|
74
|
+
# Ruff
|
|
75
|
+
.ruff_cache/
|
|
76
|
+
|
|
77
|
+
# PyPI configuration file
|
|
78
|
+
.pypirc
|
|
79
|
+
|
|
80
|
+
# macOS
|
|
81
|
+
.DS_Store
|
|
82
|
+
|
|
83
|
+
# UV
|
|
84
|
+
#uv.lock
|
|
85
|
+
|
|
86
|
+
# Cursor
|
|
87
|
+
.cursorignore
|
|
88
|
+
.cursorindexingignore
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
FROM python:3.12-slim AS builder
|
|
2
|
+
|
|
3
|
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
4
|
+
|
|
5
|
+
WORKDIR /app
|
|
6
|
+
RUN uv pip install --system mkdocs-material mkdocs-minify-plugin
|
|
7
|
+
|
|
8
|
+
COPY mkdocs.yml ./
|
|
9
|
+
COPY docs/ docs/
|
|
10
|
+
RUN mkdocs build
|
|
11
|
+
|
|
12
|
+
FROM nginx:alpine
|
|
13
|
+
COPY landing/ /usr/share/nginx/html/
|
|
14
|
+
COPY --from=builder /app/site /usr/share/nginx/html/docs/
|
|
15
|
+
EXPOSE 80
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
.PHONY: install test lint format type-check ci fix clean docs docs-build landing
|
|
2
|
+
|
|
3
|
+
# Install all dependencies (dev + test)
|
|
4
|
+
install:
|
|
5
|
+
uv sync --group dev --extra test
|
|
6
|
+
|
|
7
|
+
# Run all tests
|
|
8
|
+
test:
|
|
9
|
+
uv run pytest tests/ -v --tb=short
|
|
10
|
+
|
|
11
|
+
# Lint with ruff
|
|
12
|
+
lint:
|
|
13
|
+
uv run ruff check bluefox_cloud/ tests/
|
|
14
|
+
|
|
15
|
+
# Type check with ty
|
|
16
|
+
type-check:
|
|
17
|
+
uv run ty check bluefox_cloud/ tests/
|
|
18
|
+
|
|
19
|
+
# Check formatting
|
|
20
|
+
format:
|
|
21
|
+
uv run ruff format --check bluefox_cloud/ tests/
|
|
22
|
+
|
|
23
|
+
# Full CI pipeline: lint, format, type-check, tests
|
|
24
|
+
ci: lint format type-check test
|
|
25
|
+
|
|
26
|
+
# Auto-fix lint issues
|
|
27
|
+
fix:
|
|
28
|
+
uv run ruff check --fix bluefox_cloud/ tests/
|
|
29
|
+
uv run ruff format bluefox_cloud/ tests/
|
|
30
|
+
|
|
31
|
+
# Serve docs locally
|
|
32
|
+
docs:
|
|
33
|
+
uv run mkdocs serve
|
|
34
|
+
|
|
35
|
+
# Build docs
|
|
36
|
+
docs-build:
|
|
37
|
+
uv run mkdocs build
|
|
38
|
+
|
|
39
|
+
# Serve landing page locally
|
|
40
|
+
landing:
|
|
41
|
+
python -m http.server 8090 --directory landing
|
|
42
|
+
|
|
43
|
+
# Remove build artifacts
|
|
44
|
+
clean:
|
|
45
|
+
rm -rf site/ dist/ *.egg-info .pytest_cache
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bluefox-cloud
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Infrastructure logic for BlueFox Cloud — Docker Swarm, Traefik, Cloudflare, and release pipeline
|
|
5
|
+
Project-URL: Homepage, https://bluefox-cloud.bluefox.software
|
|
6
|
+
Project-URL: Documentation, https://bluefox-cloud.bluefox.software/docs/
|
|
7
|
+
Project-URL: Repository, https://github.com/blue-fox-software/bluefox-cloud
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: anyio>=4.0
|
|
11
|
+
Requires-Dist: bluefox-yml>=0.1.0
|
|
12
|
+
Requires-Dist: docker>=7.0
|
|
13
|
+
Requires-Dist: httpx>=0.27
|
|
14
|
+
Requires-Dist: pyyaml>=6.0
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
17
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# bluefox-cloud
|
|
21
|
+
|
|
22
|
+
Infrastructure logic for BlueFox Cloud — Docker Swarm, Traefik, Cloudflare, and release pipeline.
|
|
File without changes
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from bluefox_cloud.cloudflare.dns import DNSRecord, DNSRecordType, Zone
|
|
6
|
+
|
|
7
|
+
BASE_URL = "https://api.cloudflare.com/client/v4"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CloudflareError(Exception):
|
|
11
|
+
"""Raised when the Cloudflare API returns an error."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, status_code: int, errors: list[dict]) -> None:
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
self.errors = errors
|
|
16
|
+
messages = "; ".join(e.get("message", str(e)) for e in errors)
|
|
17
|
+
super().__init__(f"Cloudflare API error ({status_code}): {messages}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CloudflareClient:
|
|
21
|
+
"""Async Cloudflare API client using httpx."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
api_token: str,
|
|
26
|
+
*,
|
|
27
|
+
client: httpx.AsyncClient | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._client = client or httpx.AsyncClient(
|
|
30
|
+
base_url=BASE_URL,
|
|
31
|
+
headers={
|
|
32
|
+
"Authorization": f"Bearer {api_token}",
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
},
|
|
35
|
+
timeout=30.0,
|
|
36
|
+
)
|
|
37
|
+
self._owns_client = client is None
|
|
38
|
+
|
|
39
|
+
async def close(self) -> None:
|
|
40
|
+
if self._owns_client:
|
|
41
|
+
await self._client.aclose()
|
|
42
|
+
|
|
43
|
+
async def __aenter__(self) -> CloudflareClient:
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
async def __aexit__(self, *args: object) -> None:
|
|
47
|
+
await self.close()
|
|
48
|
+
|
|
49
|
+
def _check_response(self, response: httpx.Response) -> dict:
|
|
50
|
+
data = response.json()
|
|
51
|
+
if not data.get("success", False):
|
|
52
|
+
raise CloudflareError(
|
|
53
|
+
status_code=response.status_code,
|
|
54
|
+
errors=data.get("errors", [{"message": "Unknown error"}]),
|
|
55
|
+
)
|
|
56
|
+
return data
|
|
57
|
+
|
|
58
|
+
# --- Zones ---
|
|
59
|
+
|
|
60
|
+
async def get_zone(self, zone_id: str) -> Zone:
|
|
61
|
+
"""Get a zone by ID."""
|
|
62
|
+
resp = await self._client.get(f"/zones/{zone_id}")
|
|
63
|
+
data = self._check_response(resp)
|
|
64
|
+
result = data["result"]
|
|
65
|
+
return Zone(id=result["id"], name=result["name"], status=result["status"])
|
|
66
|
+
|
|
67
|
+
async def list_zones(self, *, name: str | None = None) -> list[Zone]:
|
|
68
|
+
"""List zones, optionally filtered by name."""
|
|
69
|
+
params: dict[str, str] = {}
|
|
70
|
+
if name:
|
|
71
|
+
params["name"] = name
|
|
72
|
+
resp = await self._client.get("/zones", params=params)
|
|
73
|
+
data = self._check_response(resp)
|
|
74
|
+
return [Zone(id=z["id"], name=z["name"], status=z["status"]) for z in data["result"]]
|
|
75
|
+
|
|
76
|
+
# --- DNS Records ---
|
|
77
|
+
|
|
78
|
+
async def list_records(
|
|
79
|
+
self,
|
|
80
|
+
zone_id: str,
|
|
81
|
+
*,
|
|
82
|
+
name: str | None = None,
|
|
83
|
+
record_type: DNSRecordType | None = None,
|
|
84
|
+
) -> list[DNSRecord]:
|
|
85
|
+
"""List DNS records in a zone."""
|
|
86
|
+
params: dict[str, str] = {}
|
|
87
|
+
if name:
|
|
88
|
+
params["name"] = name
|
|
89
|
+
if record_type:
|
|
90
|
+
params["type"] = record_type.value
|
|
91
|
+
resp = await self._client.get(f"/zones/{zone_id}/dns_records", params=params)
|
|
92
|
+
data = self._check_response(resp)
|
|
93
|
+
return [
|
|
94
|
+
DNSRecord(
|
|
95
|
+
id=r["id"],
|
|
96
|
+
zone_id=zone_id,
|
|
97
|
+
name=r["name"],
|
|
98
|
+
type=DNSRecordType(r["type"]),
|
|
99
|
+
content=r["content"],
|
|
100
|
+
proxied=r.get("proxied", False),
|
|
101
|
+
ttl=r.get("ttl", 1),
|
|
102
|
+
)
|
|
103
|
+
for r in data["result"]
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
async def create_record(
|
|
107
|
+
self,
|
|
108
|
+
zone_id: str,
|
|
109
|
+
*,
|
|
110
|
+
name: str,
|
|
111
|
+
record_type: DNSRecordType,
|
|
112
|
+
content: str,
|
|
113
|
+
proxied: bool = False,
|
|
114
|
+
ttl: int = 1,
|
|
115
|
+
) -> DNSRecord:
|
|
116
|
+
"""Create a DNS record."""
|
|
117
|
+
payload = {
|
|
118
|
+
"type": record_type.value,
|
|
119
|
+
"name": name,
|
|
120
|
+
"content": content,
|
|
121
|
+
"proxied": proxied,
|
|
122
|
+
"ttl": ttl,
|
|
123
|
+
}
|
|
124
|
+
resp = await self._client.post(f"/zones/{zone_id}/dns_records", json=payload)
|
|
125
|
+
data = self._check_response(resp)
|
|
126
|
+
r = data["result"]
|
|
127
|
+
return DNSRecord(
|
|
128
|
+
id=r["id"],
|
|
129
|
+
zone_id=zone_id,
|
|
130
|
+
name=r["name"],
|
|
131
|
+
type=DNSRecordType(r["type"]),
|
|
132
|
+
content=r["content"],
|
|
133
|
+
proxied=r.get("proxied", False),
|
|
134
|
+
ttl=r.get("ttl", 1),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def update_record(
|
|
138
|
+
self,
|
|
139
|
+
zone_id: str,
|
|
140
|
+
record_id: str,
|
|
141
|
+
*,
|
|
142
|
+
name: str,
|
|
143
|
+
record_type: DNSRecordType,
|
|
144
|
+
content: str,
|
|
145
|
+
proxied: bool = False,
|
|
146
|
+
ttl: int = 1,
|
|
147
|
+
) -> DNSRecord:
|
|
148
|
+
"""Update a DNS record (full replace)."""
|
|
149
|
+
payload = {
|
|
150
|
+
"type": record_type.value,
|
|
151
|
+
"name": name,
|
|
152
|
+
"content": content,
|
|
153
|
+
"proxied": proxied,
|
|
154
|
+
"ttl": ttl,
|
|
155
|
+
}
|
|
156
|
+
resp = await self._client.put(f"/zones/{zone_id}/dns_records/{record_id}", json=payload)
|
|
157
|
+
data = self._check_response(resp)
|
|
158
|
+
r = data["result"]
|
|
159
|
+
return DNSRecord(
|
|
160
|
+
id=r["id"],
|
|
161
|
+
zone_id=zone_id,
|
|
162
|
+
name=r["name"],
|
|
163
|
+
type=DNSRecordType(r["type"]),
|
|
164
|
+
content=r["content"],
|
|
165
|
+
proxied=r.get("proxied", False),
|
|
166
|
+
ttl=r.get("ttl", 1),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
async def delete_record(self, zone_id: str, record_id: str) -> None:
|
|
170
|
+
"""Delete a DNS record."""
|
|
171
|
+
resp = await self._client.delete(f"/zones/{zone_id}/dns_records/{record_id}")
|
|
172
|
+
self._check_response(resp)
|
|
173
|
+
|
|
174
|
+
async def upsert_record(
|
|
175
|
+
self,
|
|
176
|
+
zone_id: str,
|
|
177
|
+
*,
|
|
178
|
+
name: str,
|
|
179
|
+
record_type: DNSRecordType,
|
|
180
|
+
content: str,
|
|
181
|
+
proxied: bool = False,
|
|
182
|
+
ttl: int = 1,
|
|
183
|
+
) -> DNSRecord:
|
|
184
|
+
"""Create or update a DNS record. Matches on name + type."""
|
|
185
|
+
existing = await self.list_records(zone_id, name=name, record_type=record_type)
|
|
186
|
+
if existing:
|
|
187
|
+
return await self.update_record(
|
|
188
|
+
zone_id,
|
|
189
|
+
existing[0].id,
|
|
190
|
+
name=name,
|
|
191
|
+
record_type=record_type,
|
|
192
|
+
content=content,
|
|
193
|
+
proxied=proxied,
|
|
194
|
+
ttl=ttl,
|
|
195
|
+
)
|
|
196
|
+
return await self.create_record(
|
|
197
|
+
zone_id,
|
|
198
|
+
name=name,
|
|
199
|
+
record_type=record_type,
|
|
200
|
+
content=content,
|
|
201
|
+
proxied=proxied,
|
|
202
|
+
ttl=ttl,
|
|
203
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DNSRecordType(StrEnum):
|
|
9
|
+
A = "A"
|
|
10
|
+
AAAA = "AAAA"
|
|
11
|
+
CNAME = "CNAME"
|
|
12
|
+
TXT = "TXT"
|
|
13
|
+
MX = "MX"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Zone(BaseModel):
|
|
17
|
+
id: str
|
|
18
|
+
name: str
|
|
19
|
+
status: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DNSRecord(BaseModel):
|
|
23
|
+
id: str
|
|
24
|
+
zone_id: str
|
|
25
|
+
name: str
|
|
26
|
+
type: DNSRecordType
|
|
27
|
+
content: str
|
|
28
|
+
proxied: bool = False
|
|
29
|
+
ttl: int = 1 # 1 = automatic
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from bluefox_cloud.cloudflare.client import CloudflareClient
|
|
4
|
+
from bluefox_cloud.cloudflare.dns import DNSRecordType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def setup_subdomain(
|
|
8
|
+
client: CloudflareClient,
|
|
9
|
+
zone_id: str,
|
|
10
|
+
*,
|
|
11
|
+
subdomain: str,
|
|
12
|
+
server_ip: str,
|
|
13
|
+
) -> None:
|
|
14
|
+
"""Point a subdomain A record at the server IP."""
|
|
15
|
+
await client.upsert_record(
|
|
16
|
+
zone_id,
|
|
17
|
+
name=subdomain,
|
|
18
|
+
record_type=DNSRecordType.A,
|
|
19
|
+
content=server_ip,
|
|
20
|
+
proxied=False,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def setup_wildcard(
|
|
25
|
+
client: CloudflareClient,
|
|
26
|
+
zone_id: str,
|
|
27
|
+
*,
|
|
28
|
+
server_ip: str,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Create a wildcard A record for *.domain."""
|
|
31
|
+
await client.upsert_record(
|
|
32
|
+
zone_id,
|
|
33
|
+
name="*",
|
|
34
|
+
record_type=DNSRecordType.A,
|
|
35
|
+
content=server_ip,
|
|
36
|
+
proxied=False,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def verify_custom_domain(
|
|
41
|
+
client: CloudflareClient,
|
|
42
|
+
zone_id: str,
|
|
43
|
+
*,
|
|
44
|
+
domain: str,
|
|
45
|
+
expected_cname: str,
|
|
46
|
+
) -> bool:
|
|
47
|
+
"""Check if a custom domain has a CNAME pointing to the expected target."""
|
|
48
|
+
records = await client.list_records(zone_id, name=domain, record_type=DNSRecordType.CNAME)
|
|
49
|
+
return any(r.content == expected_cname for r in records)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".bluefox"
|
|
9
|
+
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "cloud.yml"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ServerConfig(BaseModel):
|
|
13
|
+
ip: str = ""
|
|
14
|
+
ssh_port: int = 22
|
|
15
|
+
domain: str = ""
|
|
16
|
+
user: str = "deploy"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CloudflareConfig(BaseModel):
|
|
20
|
+
api_token: str = ""
|
|
21
|
+
zone_id: str = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CloudConfig(BaseModel):
|
|
25
|
+
platform_url: str = ""
|
|
26
|
+
api_key: str = ""
|
|
27
|
+
local: bool = False
|
|
28
|
+
server: ServerConfig = ServerConfig()
|
|
29
|
+
cloudflare: CloudflareConfig = CloudflareConfig()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> CloudConfig:
|
|
33
|
+
"""Load cloud config from YAML file. Raises FileNotFoundError if missing."""
|
|
34
|
+
raw = yaml.safe_load(path.read_text())
|
|
35
|
+
if raw is None:
|
|
36
|
+
return CloudConfig()
|
|
37
|
+
return CloudConfig.model_validate(raw)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_config(config: CloudConfig, path: Path = DEFAULT_CONFIG_PATH) -> None:
|
|
41
|
+
"""Save cloud config to YAML file. Creates parent directories if needed."""
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
data = config.model_dump()
|
|
44
|
+
path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SSHResult(BaseModel):
|
|
11
|
+
stdout: str
|
|
12
|
+
stderr: str
|
|
13
|
+
returncode: int
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def ok(self) -> bool:
|
|
17
|
+
return self.returncode == 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ssh_base_args(host: str, user: str, port: int) -> list[str]:
|
|
21
|
+
return [
|
|
22
|
+
"ssh",
|
|
23
|
+
"-o",
|
|
24
|
+
"StrictHostKeyChecking=accept-new",
|
|
25
|
+
"-o",
|
|
26
|
+
"ConnectTimeout=10",
|
|
27
|
+
"-p",
|
|
28
|
+
str(port),
|
|
29
|
+
f"{user}@{host}",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ssh_run(
|
|
34
|
+
host: str,
|
|
35
|
+
user: str,
|
|
36
|
+
port: int,
|
|
37
|
+
command: str,
|
|
38
|
+
*,
|
|
39
|
+
timeout: int = 300,
|
|
40
|
+
) -> SSHResult:
|
|
41
|
+
"""Execute a command on a remote host via SSH."""
|
|
42
|
+
args = [*_ssh_base_args(host, user, port), command]
|
|
43
|
+
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
|
44
|
+
return SSHResult(stdout=result.stdout, stderr=result.stderr, returncode=result.returncode)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _scp_args(host: str, user: str, port: int, local_path: str, remote_path: str) -> list[str]:
|
|
48
|
+
return [
|
|
49
|
+
"scp",
|
|
50
|
+
"-o",
|
|
51
|
+
"StrictHostKeyChecking=accept-new",
|
|
52
|
+
"-o",
|
|
53
|
+
"ConnectTimeout=10",
|
|
54
|
+
"-P",
|
|
55
|
+
str(port),
|
|
56
|
+
local_path,
|
|
57
|
+
f"{user}@{host}:{remote_path}",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ssh_upload_and_run(
|
|
62
|
+
host: str,
|
|
63
|
+
user: str,
|
|
64
|
+
port: int,
|
|
65
|
+
script_path: Path | str,
|
|
66
|
+
*,
|
|
67
|
+
timeout: int = 600,
|
|
68
|
+
) -> SSHResult:
|
|
69
|
+
"""Upload a script via SCP and execute it on the remote host."""
|
|
70
|
+
script_path = Path(script_path)
|
|
71
|
+
remote_path = f"/tmp/{script_path.name}"
|
|
72
|
+
|
|
73
|
+
# Upload
|
|
74
|
+
scp_args = _scp_args(host, user, port, str(script_path), remote_path)
|
|
75
|
+
scp_result = subprocess.run(scp_args, capture_output=True, text=True, timeout=60)
|
|
76
|
+
if scp_result.returncode != 0:
|
|
77
|
+
return SSHResult(
|
|
78
|
+
stdout=scp_result.stdout,
|
|
79
|
+
stderr=f"SCP upload failed: {scp_result.stderr}",
|
|
80
|
+
returncode=scp_result.returncode,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Execute
|
|
84
|
+
return ssh_run(host, user, port, f"bash {remote_path}", timeout=timeout)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_bundled_script(name: str) -> Path:
|
|
88
|
+
"""Get the path to a bundled shell script in the ssh/scripts directory."""
|
|
89
|
+
scripts = resources.files("bluefox_cloud.ssh") / "scripts"
|
|
90
|
+
script = scripts / name
|
|
91
|
+
if not script.is_file():
|
|
92
|
+
raise FileNotFoundError(f"Bundled script not found: {name}")
|
|
93
|
+
return Path(str(script))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def ssh_upload_and_run_bundled(
|
|
97
|
+
host: str,
|
|
98
|
+
user: str,
|
|
99
|
+
port: int,
|
|
100
|
+
script_name: str,
|
|
101
|
+
*,
|
|
102
|
+
timeout: int = 600,
|
|
103
|
+
) -> SSHResult:
|
|
104
|
+
"""Upload and run a bundled script from bluefox_cloud/ssh/scripts/."""
|
|
105
|
+
script_path = get_bundled_script(script_name)
|
|
106
|
+
return ssh_upload_and_run(host, user, port, script_path, timeout=timeout)
|