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.
Files changed (35) hide show
  1. bluefox_cloud-0.1.0/.github/workflows/ci.yml +11 -0
  2. bluefox_cloud-0.1.0/.github/workflows/publish.yml +20 -0
  3. bluefox_cloud-0.1.0/.gitignore +88 -0
  4. bluefox_cloud-0.1.0/Dockerfile.docs +15 -0
  5. bluefox_cloud-0.1.0/Makefile +45 -0
  6. bluefox_cloud-0.1.0/PKG-INFO +22 -0
  7. bluefox_cloud-0.1.0/README.md +3 -0
  8. bluefox_cloud-0.1.0/bluefox_cloud/__init__.py +0 -0
  9. bluefox_cloud-0.1.0/bluefox_cloud/cloudflare/__init__.py +4 -0
  10. bluefox_cloud-0.1.0/bluefox_cloud/cloudflare/client.py +203 -0
  11. bluefox_cloud-0.1.0/bluefox_cloud/cloudflare/dns.py +29 -0
  12. bluefox_cloud-0.1.0/bluefox_cloud/cloudflare/domains.py +49 -0
  13. bluefox_cloud-0.1.0/bluefox_cloud/config.py +44 -0
  14. bluefox_cloud-0.1.0/bluefox_cloud/ssh/__init__.py +3 -0
  15. bluefox_cloud-0.1.0/bluefox_cloud/ssh/client.py +106 -0
  16. bluefox_cloud-0.1.0/bluefox_cloud/ssh/scripts/harden.sh +5 -0
  17. bluefox_cloud-0.1.0/bluefox_cloud/ssh/scripts/install_docker.sh +5 -0
  18. bluefox_cloud-0.1.0/bluefox_cloud/swarm/__init__.py +3 -0
  19. bluefox_cloud-0.1.0/bluefox_cloud/swarm/client.py +163 -0
  20. bluefox_cloud-0.1.0/bluefox_cloud/traefik/__init__.py +3 -0
  21. bluefox_cloud-0.1.0/bluefox_cloud/traefik/config.py +85 -0
  22. bluefox_cloud-0.1.0/bluefox_cloud/traefik/tls.py +21 -0
  23. bluefox_cloud-0.1.0/docs/index.md +54 -0
  24. bluefox_cloud-0.1.0/docs/stylesheets/extra.css +26 -0
  25. bluefox_cloud-0.1.0/landing/index.html +608 -0
  26. bluefox_cloud-0.1.0/mkdocs.yml +50 -0
  27. bluefox_cloud-0.1.0/pyproject.toml +49 -0
  28. bluefox_cloud-0.1.0/tests/__init__.py +0 -0
  29. bluefox_cloud-0.1.0/tests/test_cloudflare.py +257 -0
  30. bluefox_cloud-0.1.0/tests/test_config.py +98 -0
  31. bluefox_cloud-0.1.0/tests/test_placeholder.py +2 -0
  32. bluefox_cloud-0.1.0/tests/test_ssh.py +82 -0
  33. bluefox_cloud-0.1.0/tests/test_swarm.py +133 -0
  34. bluefox_cloud-0.1.0/tests/test_traefik.py +128 -0
  35. bluefox_cloud-0.1.0/uv.lock +538 -0
@@ -0,0 +1,11 @@
1
+ name: CI
2
+ on: [push, pull_request]
3
+
4
+ jobs:
5
+ ci:
6
+ runs-on: ubuntu-latest
7
+ steps:
8
+ - uses: actions/checkout@v4
9
+ - uses: astral-sh/setup-uv@v5
10
+ - run: make install
11
+ - run: make ci
@@ -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.
@@ -0,0 +1,3 @@
1
+ # bluefox-cloud
2
+
3
+ Infrastructure logic for BlueFox Cloud — Docker Swarm, Traefik, Cloudflare, and release pipeline.
File without changes
@@ -0,0 +1,4 @@
1
+ from bluefox_cloud.cloudflare.client import CloudflareClient
2
+ from bluefox_cloud.cloudflare.dns import DNSRecord, DNSRecordType, Zone
3
+
4
+ __all__ = ["CloudflareClient", "DNSRecord", "DNSRecordType", "Zone"]
@@ -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,3 @@
1
+ from bluefox_cloud.ssh.client import SSHResult, ssh_run, ssh_upload_and_run
2
+
3
+ __all__ = ["SSHResult", "ssh_run", "ssh_upload_and_run"]
@@ -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)
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ # Server hardening script — placeholder for now.
3
+ # Will be fleshed out during Step 14 (server hardening).
4
+ set -euo pipefail
5
+ echo "harden.sh: placeholder"
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ # Docker installation script — placeholder for now.
3
+ # Will be fleshed out during Step 15 (Docker + Swarm + Network).
4
+ set -euo pipefail
5
+ echo "install_docker.sh: placeholder"
@@ -0,0 +1,3 @@
1
+ from bluefox_cloud.swarm.client import SwarmClient
2
+
3
+ __all__ = ["SwarmClient"]