cloudfall 0.2.0__py3-none-any.whl
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.
- cloudfall/__init__.py +19 -0
- cloudfall/__main__.py +5 -0
- cloudfall/_bundled/schemas/v1/alert-rule.schema.json +63 -0
- cloudfall/_bundled/schemas/v1/application.schema.json +60 -0
- cloudfall/_bundled/schemas/v1/artifact.schema.json +71 -0
- cloudfall/_bundled/schemas/v1/backup-receipt.schema.json +58 -0
- cloudfall/_bundled/schemas/v1/common.schema.json +115 -0
- cloudfall/_bundled/schemas/v1/component.schema.json +234 -0
- cloudfall/_bundled/schemas/v1/deployment-receipt.schema.json +53 -0
- cloudfall/_bundled/schemas/v1/domain.schema.json +208 -0
- cloudfall/_bundled/schemas/v1/environment-receipt.schema.json +55 -0
- cloudfall/_bundled/schemas/v1/logging-stack.schema.json +473 -0
- cloudfall/_bundled/schemas/v1/observed-domain.schema.json +158 -0
- cloudfall/_bundled/schemas/v1/observed-server.schema.json +627 -0
- cloudfall/_bundled/schemas/v1/operator-policy.schema.json +97 -0
- cloudfall/_bundled/schemas/v1/operator-proposal.schema.json +253 -0
- cloudfall/_bundled/schemas/v1/release-receipt.schema.json +66 -0
- cloudfall/_bundled/schemas/v1/server-type.schema.json +289 -0
- cloudfall/_bundled/schemas/v1/server.schema.json +146 -0
- cloudfall/_bundled/schemas/v1/service.schema.json +222 -0
- cloudfall/_bundled/schemas/v1/ssh-public-key.schema.json +55 -0
- cloudfall/agent_tools.py +868 -0
- cloudfall/arguments.py +92 -0
- cloudfall/audit.py +905 -0
- cloudfall/authoring.py +341 -0
- cloudfall/cli.py +1748 -0
- cloudfall/commands.py +257 -0
- cloudfall/cutover.py +370 -0
- cloudfall/dashboard.py +407 -0
- cloudfall/dashboard_server.py +201 -0
- cloudfall/domain.py +1157 -0
- cloudfall/importer.py +906 -0
- cloudfall/inventory.py +2116 -0
- cloudfall/lifecycle.py +874 -0
- cloudfall/mcp_server.py +704 -0
- cloudfall/migrate.py +730 -0
- cloudfall/observation.py +143 -0
- cloudfall/operations.py +1228 -0
- cloudfall/operator.py +1099 -0
- cloudfall/project.py +841 -0
- cloudfall/py.typed +0 -0
- cloudfall/render_api.py +338 -0
- cloudfall/resources.py +67 -0
- cloudfall/secrets.py +262 -0
- cloudfall/service_evidence.py +659 -0
- cloudfall/validation.py +1340 -0
- cloudfall-0.2.0.dist-info/METADATA +408 -0
- cloudfall-0.2.0.dist-info/RECORD +165 -0
- cloudfall-0.2.0.dist-info/WHEEL +4 -0
- cloudfall-0.2.0.dist-info/entry_points.txt +4 -0
- cloudfall-0.2.0.dist-info/licenses/LICENSE +661 -0
- cloudfall_engine/__init__.py +5 -0
- cloudfall_engine/__main__.py +5 -0
- cloudfall_engine/_bundled/ansible/ansible.cfg +7 -0
- cloudfall_engine/_bundled/ansible/playbooks/backup.yml +86 -0
- cloudfall_engine/_bundled/ansible/playbooks/baseline.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/bootstrap.yml +24 -0
- cloudfall_engine/_bundled/ansible/playbooks/data.yml +28 -0
- cloudfall_engine/_bundled/ansible/playbooks/deploy.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/domains.yml +21 -0
- cloudfall_engine/_bundled/ansible/playbooks/health.yml +31 -0
- cloudfall_engine/_bundled/ansible/playbooks/inspect.yml +15 -0
- cloudfall_engine/_bundled/ansible/playbooks/logging.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/restart.yml +31 -0
- cloudfall_engine/_bundled/ansible/playbooks/rollback.yml +33 -0
- cloudfall_engine/_bundled/ansible/playbooks/services.yml +37 -0
- cloudfall_engine/_bundled/ansible/playbooks/time.yml +9 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/argument_specs.yml +34 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/tasks/main.yml +59 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/defaults/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +52 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/tasks/main.yml +99 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/defaults/main.yml +15 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +36 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/tasks/main.yml +201 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/defaults/main.yml +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +125 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health.yml +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +37 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/main.yml +285 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/restart.yml +17 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/rollback.yml +52 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/templates/component.service.j2 +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +33 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/tasks/main.yml +51 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +31 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/tasks/main.yml +643 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/handlers/main.yml +29 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +227 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/tasks/main.yml +487 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +33 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +20 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +49 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +45 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +20 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/defaults/main.yml +7 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/handlers/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +122 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/tasks/main.yml +224 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +127 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/defaults/main.yml +4 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +69 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/tasks/main.yml +212 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +54 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/handlers/main.yml +9 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +91 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/tasks/main.yml +295 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +17 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +27 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/handlers/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/argument_specs.yml +68 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/tasks/main.yml +148 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +14 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +15 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/argument_specs.yml +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/tasks/main.yml +119 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +8 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +37 -0
- cloudfall_engine/ansible_inventory.py +253 -0
- cloudfall_engine/artifact.py +258 -0
- cloudfall_engine/cli.py +273 -0
- cloudfall_engine/playbook.py +175 -0
- cloudfall_engine/py.typed +0 -0
cloudfall/project.py
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
"""Create Cloudfall projects.
|
|
2
|
+
|
|
3
|
+
A project is one directory holding everything Cloudfall needs to run one
|
|
4
|
+
user's stack: the fleet (servers, server types, SSH public keys), the
|
|
5
|
+
applications (applications, components, services, domains), the operating
|
|
6
|
+
declarations (alert rules, operator policies, logging stacks), and the
|
|
7
|
+
``pyproject.toml`` that pins the Cloudfall revision the project runs with.
|
|
8
|
+
Every ``cloudfall`` command runs inside a project: the current directory
|
|
9
|
+
when it is one, else ``--project``, else ``CLOUDFALL_PROJECT``.
|
|
10
|
+
Evidence, receipts, and rendered files land under ``tmp/`` inside the
|
|
11
|
+
project and are never committed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import textwrap
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from importlib import metadata
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
from urllib.parse import urlparse
|
|
29
|
+
|
|
30
|
+
from cloudfall.commands import CommandEffect, commands_with_effect
|
|
31
|
+
from cloudfall.domain import ResourceKind
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
35
|
+
|
|
36
|
+
_GIT_REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
|
37
|
+
_PROJECT_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$")
|
|
38
|
+
_SCP_LIKE_SOURCE_PATTERN = re.compile(
|
|
39
|
+
r"^(?P<user>[\w.-]+)@(?P<host>[\w.-]+):(?P<path>[\w./-]+)$"
|
|
40
|
+
)
|
|
41
|
+
_PROJECT_NAME_MAX_LENGTH = 64
|
|
42
|
+
_PROJECT_DESCRIPTION_MAX_LENGTH = 512
|
|
43
|
+
_GIT_TIMEOUT_SECONDS = 30
|
|
44
|
+
_GIT_NOT_A_REPOSITORY_EXIT_CODE = 128
|
|
45
|
+
|
|
46
|
+
DEFAULT_SOURCE_URL = "https://github.com/romamo/cloudfall.git"
|
|
47
|
+
|
|
48
|
+
SECRETS_DIRECTORY = "secrets"
|
|
49
|
+
"""Project directory holding sops-encrypted secret fragments."""
|
|
50
|
+
|
|
51
|
+
SOPS_CONFIGURATION_FILE = ".sops.yaml"
|
|
52
|
+
"""Project file telling sops which key encrypts the secret fragments."""
|
|
53
|
+
|
|
54
|
+
SECRETS_GUIDE_PATH = "docs/secrets-guide.md"
|
|
55
|
+
"""Path of the secrets guide inside the Cloudfall repository."""
|
|
56
|
+
|
|
57
|
+
EXAMPLES_PATH = "config/examples"
|
|
58
|
+
"""Path of the reference resource set inside the Cloudfall repository."""
|
|
59
|
+
|
|
60
|
+
AGENT_CONTRACT_FILE = "AGENTS.md"
|
|
61
|
+
"""Project file stating the terms on which an AI agent operates it."""
|
|
62
|
+
|
|
63
|
+
CLAUDE_POINTER_FILE = "CLAUDE.md"
|
|
64
|
+
"""One-line project file pointing Claude Code at the agent contract."""
|
|
65
|
+
|
|
66
|
+
_ERROR_NAME_UNDERIVED = "project_name_underived"
|
|
67
|
+
_ERROR_DIRECTORY_MISSING = "project_directory_missing"
|
|
68
|
+
_ERROR_DIRECTORY_UNRESOLVED = "project_directory_unresolved"
|
|
69
|
+
_ERROR_REVISION_UNRESOLVED = "project_revision_unresolved"
|
|
70
|
+
_ERROR_REVISION_UNCOMMITTED = "project_revision_uncommitted"
|
|
71
|
+
_ERROR_REVISION_UNPUBLISHED = "project_revision_unpublished"
|
|
72
|
+
_ERROR_NOT_A_DIRECTORY = "project_directory_not_a_directory"
|
|
73
|
+
_ERROR_NOT_EMPTY = "project_directory_not_empty"
|
|
74
|
+
_ERROR_GIT_UNAVAILABLE = "project_git_unavailable"
|
|
75
|
+
_ERROR_GIT_FAILED = "project_git_failed"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
RUNTIME_DIRECTORY = "tmp"
|
|
79
|
+
"""Project directory holding evidence and receipts; never a resource source."""
|
|
80
|
+
|
|
81
|
+
PROJECT_DIRECTORY_VARIABLE = "CLOUDFALL_PROJECT"
|
|
82
|
+
"""Environment variable naming the project when ``--project`` is absent."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ProjectError(RuntimeError):
|
|
86
|
+
"""Fail-fast project error with a stable machine-readable code."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, code: str, message: str) -> None:
|
|
89
|
+
"""Record the failure code and human-readable detail."""
|
|
90
|
+
self.code = code
|
|
91
|
+
self.detail = message
|
|
92
|
+
super().__init__(f"{code}: {message}")
|
|
93
|
+
|
|
94
|
+
def as_dict(self) -> dict[str, object]:
|
|
95
|
+
"""Serialize the error envelope for system boundaries."""
|
|
96
|
+
return {
|
|
97
|
+
"status": "error",
|
|
98
|
+
"error": {"code": self.code, "message": self.detail},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, slots=True)
|
|
103
|
+
class GitRevision:
|
|
104
|
+
"""Full 40-character commit hash a project pins Cloudfall to."""
|
|
105
|
+
|
|
106
|
+
value: str
|
|
107
|
+
|
|
108
|
+
def __post_init__(self) -> None:
|
|
109
|
+
"""Enforce the full-hash invariant at construction time."""
|
|
110
|
+
if not _GIT_REVISION_PATTERN.fullmatch(self.value):
|
|
111
|
+
message = f"git revision must be a full 40-hex commit hash: {self.value!r}"
|
|
112
|
+
raise ValueError(message)
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_boundary(cls, value: object) -> GitRevision:
|
|
116
|
+
"""Coerce a boundary value while keeping internal APIs strictly typed."""
|
|
117
|
+
if not isinstance(value, str):
|
|
118
|
+
message = f"git revision must be a string, got {type(value).__name__}"
|
|
119
|
+
raise TypeError(message)
|
|
120
|
+
return cls(value.strip().lower())
|
|
121
|
+
|
|
122
|
+
def __str__(self) -> str:
|
|
123
|
+
"""Return the serialized hash."""
|
|
124
|
+
return self.value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, slots=True)
|
|
128
|
+
class GitSourceUrl:
|
|
129
|
+
"""Location of the Cloudfall repository a project installs from."""
|
|
130
|
+
|
|
131
|
+
value: str
|
|
132
|
+
|
|
133
|
+
def __post_init__(self) -> None:
|
|
134
|
+
"""Accept https and ssh git locations only."""
|
|
135
|
+
parsed = urlparse(self.value)
|
|
136
|
+
https = parsed.scheme == "https" and bool(parsed.netloc)
|
|
137
|
+
ssh = parsed.scheme == "ssh" and bool(parsed.netloc)
|
|
138
|
+
scp_like = _SCP_LIKE_SOURCE_PATTERN.fullmatch(self.value) is not None
|
|
139
|
+
if not (https or ssh or scp_like):
|
|
140
|
+
message = f"git source must be an https or ssh location: {self.value!r}"
|
|
141
|
+
raise ValueError(message)
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def from_boundary(cls, value: object) -> GitSourceUrl:
|
|
145
|
+
"""Coerce a boundary value while keeping internal APIs strictly typed."""
|
|
146
|
+
if not isinstance(value, str):
|
|
147
|
+
message = f"git source must be a string, got {type(value).__name__}"
|
|
148
|
+
raise TypeError(message)
|
|
149
|
+
return cls(value.strip())
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def browse_url(self) -> str:
|
|
153
|
+
"""Return the https location of the repository for links to its files.
|
|
154
|
+
|
|
155
|
+
``https://host/owner/repo.git``, ``ssh://git@host/owner/repo.git``, and
|
|
156
|
+
``git@host:owner/repo.git`` all browse at ``https://host/owner/repo``.
|
|
157
|
+
"""
|
|
158
|
+
scp_like = _SCP_LIKE_SOURCE_PATTERN.fullmatch(self.value)
|
|
159
|
+
if scp_like is not None:
|
|
160
|
+
host, path = scp_like.group("host"), scp_like.group("path")
|
|
161
|
+
else:
|
|
162
|
+
parsed = urlparse(self.value)
|
|
163
|
+
host, path = parsed.hostname or "", parsed.path
|
|
164
|
+
path = path.strip("/").removesuffix(".git")
|
|
165
|
+
return f"https://{host}/{path}"
|
|
166
|
+
|
|
167
|
+
def __str__(self) -> str:
|
|
168
|
+
"""Return the serialized location."""
|
|
169
|
+
return self.value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True, slots=True)
|
|
173
|
+
class ProjectName:
|
|
174
|
+
"""Package name of a project, as written into its ``pyproject.toml``."""
|
|
175
|
+
|
|
176
|
+
value: str
|
|
177
|
+
|
|
178
|
+
def __post_init__(self) -> None:
|
|
179
|
+
"""Enforce a normalized PEP 503 style name."""
|
|
180
|
+
if not _PROJECT_NAME_PATTERN.fullmatch(self.value):
|
|
181
|
+
message = (
|
|
182
|
+
"project name must be lowercase letters, digits, '.', '_' or '-' "
|
|
183
|
+
f"and start and end with a letter or digit: {self.value!r}"
|
|
184
|
+
)
|
|
185
|
+
raise ValueError(message)
|
|
186
|
+
if len(self.value) > _PROJECT_NAME_MAX_LENGTH:
|
|
187
|
+
message = f"project name exceeds 64 characters: {self.value!r}"
|
|
188
|
+
raise ValueError(message)
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def from_boundary(cls, value: object) -> ProjectName:
|
|
192
|
+
"""Coerce a boundary value while keeping internal APIs strictly typed."""
|
|
193
|
+
if not isinstance(value, str):
|
|
194
|
+
message = f"project name must be a string, got {type(value).__name__}"
|
|
195
|
+
raise TypeError(message)
|
|
196
|
+
return cls(value.strip().lower())
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
def from_directory(cls, directory: Path) -> ProjectName:
|
|
200
|
+
"""Derive the name from the directory, lowercased and dash-joined."""
|
|
201
|
+
raw = directory.resolve().name
|
|
202
|
+
normalized = re.sub(r"[^a-z0-9._-]+", "-", raw.lower()).strip("-._")
|
|
203
|
+
if not normalized:
|
|
204
|
+
message = f"cannot derive a project name from directory {directory}"
|
|
205
|
+
raise ProjectError(_ERROR_NAME_UNDERIVED, message)
|
|
206
|
+
return cls(normalized)
|
|
207
|
+
|
|
208
|
+
def __str__(self) -> str:
|
|
209
|
+
"""Return the serialized name."""
|
|
210
|
+
return self.value
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass(frozen=True, slots=True)
|
|
214
|
+
class ProjectDescription:
|
|
215
|
+
"""One line saying what a project manages, for its README and metadata."""
|
|
216
|
+
|
|
217
|
+
value: str
|
|
218
|
+
|
|
219
|
+
def __post_init__(self) -> None:
|
|
220
|
+
"""Require one trimmed, printable line that TOML can hold unescaped."""
|
|
221
|
+
if not self.value or self.value != self.value.strip():
|
|
222
|
+
message = f"project description must be a trimmed line: {self.value!r}"
|
|
223
|
+
raise ValueError(message)
|
|
224
|
+
if not self.value.isprintable() or any(c in self.value for c in '"\\'):
|
|
225
|
+
message = (
|
|
226
|
+
"project description must be one printable line without "
|
|
227
|
+
f"quotes or backslashes: {self.value!r}"
|
|
228
|
+
)
|
|
229
|
+
raise ValueError(message)
|
|
230
|
+
if len(self.value) > _PROJECT_DESCRIPTION_MAX_LENGTH:
|
|
231
|
+
message = (
|
|
232
|
+
"project description exceeds "
|
|
233
|
+
f"{_PROJECT_DESCRIPTION_MAX_LENGTH} characters"
|
|
234
|
+
)
|
|
235
|
+
raise ValueError(message)
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def from_boundary(cls, value: object) -> ProjectDescription:
|
|
239
|
+
"""Coerce a boundary value while keeping internal APIs strictly typed."""
|
|
240
|
+
if not isinstance(value, str):
|
|
241
|
+
message = (
|
|
242
|
+
f"project description must be a string, got {type(value).__name__}"
|
|
243
|
+
)
|
|
244
|
+
raise TypeError(message)
|
|
245
|
+
return cls(value.strip())
|
|
246
|
+
|
|
247
|
+
def __str__(self) -> str:
|
|
248
|
+
"""Return the serialized description."""
|
|
249
|
+
return self.value
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True, slots=True)
|
|
253
|
+
class InitOptions:
|
|
254
|
+
"""Everything ``cloudfall init`` needs to lay out one project."""
|
|
255
|
+
|
|
256
|
+
directory: Path
|
|
257
|
+
name: ProjectName
|
|
258
|
+
revision: GitRevision
|
|
259
|
+
source: GitSourceUrl = GitSourceUrl(DEFAULT_SOURCE_URL)
|
|
260
|
+
description: ProjectDescription | None = None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class GitSetup(Enum):
|
|
264
|
+
"""What ``cloudfall init`` did about version control for the project."""
|
|
265
|
+
|
|
266
|
+
INITIALIZED = "initialized"
|
|
267
|
+
"""A new repository was created in the project directory."""
|
|
268
|
+
|
|
269
|
+
ENCLOSED = "enclosed"
|
|
270
|
+
"""The directory already lies inside a repository, which was left alone."""
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass(frozen=True, slots=True)
|
|
274
|
+
class ProjectScaffold:
|
|
275
|
+
"""Files written by ``cloudfall init``, for the CLI and agent envelope."""
|
|
276
|
+
|
|
277
|
+
directory: Path
|
|
278
|
+
name: ProjectName
|
|
279
|
+
revision: GitRevision
|
|
280
|
+
source: GitSourceUrl
|
|
281
|
+
files: tuple[str, ...]
|
|
282
|
+
git: GitSetup
|
|
283
|
+
|
|
284
|
+
def as_dict(self) -> dict[str, object]:
|
|
285
|
+
"""Serialize the scaffold result for system boundaries."""
|
|
286
|
+
return {
|
|
287
|
+
"status": "ok",
|
|
288
|
+
"project": {
|
|
289
|
+
"directory": str(self.directory),
|
|
290
|
+
"name": str(self.name),
|
|
291
|
+
"revision": str(self.revision),
|
|
292
|
+
"source": str(self.source),
|
|
293
|
+
"git": self.git.value,
|
|
294
|
+
},
|
|
295
|
+
"files": list(self.files),
|
|
296
|
+
"next": [
|
|
297
|
+
f"cd {self.directory} && uv sync",
|
|
298
|
+
"uv run cloudfall add ssh-key ~/.ssh/id_ed25519.pub --owner <you>",
|
|
299
|
+
"uv run cloudfall add server h1 --address <ip-or-hostname>",
|
|
300
|
+
"uv run cloudfall config validate",
|
|
301
|
+
],
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass(frozen=True, slots=True)
|
|
306
|
+
class CheckoutState:
|
|
307
|
+
"""What a source checkout says about the commit it is running."""
|
|
308
|
+
|
|
309
|
+
head: str
|
|
310
|
+
clean: bool
|
|
311
|
+
"""No tracked file differs from ``HEAD``, so ``HEAD`` is the running code."""
|
|
312
|
+
published: bool
|
|
313
|
+
"""``HEAD`` is reachable from a remote-tracking branch, so it can be fetched."""
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def is_project(directory: Path) -> bool:
|
|
317
|
+
"""Return whether a directory holds at least one resource kind directory."""
|
|
318
|
+
return directory.is_dir() and any(
|
|
319
|
+
(directory / kind.directory).is_dir() for kind in ResourceKind
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def resolve_project_directory(
|
|
324
|
+
explicit: Path | None, environment: Mapping[str, str], current: Path
|
|
325
|
+
) -> Path:
|
|
326
|
+
"""Pick the project directory a command runs in.
|
|
327
|
+
|
|
328
|
+
Precedence: ``--project``, then ``CLOUDFALL_PROJECT``, then the
|
|
329
|
+
current directory when it is a project. An explicitly named directory
|
|
330
|
+
must exist; whether it holds resources is validation's concern.
|
|
331
|
+
"""
|
|
332
|
+
if explicit is not None:
|
|
333
|
+
candidate, origin = explicit, "--project"
|
|
334
|
+
elif environment.get(PROJECT_DIRECTORY_VARIABLE):
|
|
335
|
+
candidate = Path(environment[PROJECT_DIRECTORY_VARIABLE])
|
|
336
|
+
origin = PROJECT_DIRECTORY_VARIABLE
|
|
337
|
+
elif is_project(current):
|
|
338
|
+
return current.resolve()
|
|
339
|
+
else:
|
|
340
|
+
message = (
|
|
341
|
+
f"{current} is not a project: it has no resource directory such as "
|
|
342
|
+
f"{ResourceKind.SERVER.directory}/. Run from inside a project, pass "
|
|
343
|
+
f"--project, or set {PROJECT_DIRECTORY_VARIABLE}"
|
|
344
|
+
)
|
|
345
|
+
raise ProjectError(_ERROR_DIRECTORY_UNRESOLVED, message)
|
|
346
|
+
if not candidate.is_dir():
|
|
347
|
+
message = f"project directory from {origin} does not exist: {candidate}"
|
|
348
|
+
raise ProjectError(_ERROR_DIRECTORY_MISSING, message)
|
|
349
|
+
return candidate.resolve()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@contextmanager
|
|
353
|
+
def project_context(
|
|
354
|
+
explicit: Path | None, environment: Mapping[str, str]
|
|
355
|
+
) -> Iterator[Path]:
|
|
356
|
+
"""Run the body as if started inside the resolved project directory.
|
|
357
|
+
|
|
358
|
+
Relative paths, including every ``tmp/`` default, then land in the
|
|
359
|
+
project, whatever directory the command was launched from. The previous
|
|
360
|
+
working directory is restored afterwards.
|
|
361
|
+
"""
|
|
362
|
+
previous = Path.cwd()
|
|
363
|
+
directory = resolve_project_directory(explicit, environment, previous)
|
|
364
|
+
os.chdir(directory)
|
|
365
|
+
try:
|
|
366
|
+
yield directory
|
|
367
|
+
finally:
|
|
368
|
+
os.chdir(previous)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def project_path(value: str) -> Path:
|
|
372
|
+
"""Return a runtime path, refusing a relative path that leaves the project.
|
|
373
|
+
|
|
374
|
+
Relative paths resolve against the project (see ``project_context``), so
|
|
375
|
+
one whose normalized form climbs above it (``../../x``, ``tmp/../../x``)
|
|
376
|
+
would read or write outside the project without saying so. Locations
|
|
377
|
+
outside the project stay possible, but only as an explicit absolute path.
|
|
378
|
+
"""
|
|
379
|
+
path = Path(value)
|
|
380
|
+
if not path.is_absolute() and Path(os.path.normpath(value)).parts[:1] == (
|
|
381
|
+
"..",
|
|
382
|
+
):
|
|
383
|
+
message = (
|
|
384
|
+
f"relative path {value!r} leaves the project directory; "
|
|
385
|
+
"pass an absolute path to use a location outside the project"
|
|
386
|
+
)
|
|
387
|
+
raise ValueError(message)
|
|
388
|
+
return path
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def resolve_installed_revision(
|
|
392
|
+
read_direct_url: Callable[[], str | None] | None = None,
|
|
393
|
+
inspect_checkout: Callable[[Path], CheckoutState] | None = None,
|
|
394
|
+
) -> GitRevision:
|
|
395
|
+
"""Return the commit the running Cloudfall was installed from.
|
|
396
|
+
|
|
397
|
+
A wheel installed from git records its commit in ``direct_url.json``; an
|
|
398
|
+
editable source checkout records the checkout location, whose ``HEAD`` is
|
|
399
|
+
the commit, provided the checkout is clean (else ``HEAD`` is not the code
|
|
400
|
+
that is running) and ``HEAD`` is on a remote branch (else ``uv sync``
|
|
401
|
+
cannot fetch it). Anything else cannot be pinned and must be given
|
|
402
|
+
explicitly.
|
|
403
|
+
"""
|
|
404
|
+
direct_url = (read_direct_url or _installed_direct_url)()
|
|
405
|
+
if direct_url is None:
|
|
406
|
+
message = (
|
|
407
|
+
"the installed cloudfall distribution records no origin; "
|
|
408
|
+
"pass --rev with the commit to pin"
|
|
409
|
+
)
|
|
410
|
+
raise ProjectError(_ERROR_REVISION_UNRESOLVED, message)
|
|
411
|
+
record = json.loads(direct_url)
|
|
412
|
+
if not isinstance(record, dict):
|
|
413
|
+
message = f"direct_url.json is not an object: {direct_url!r}"
|
|
414
|
+
raise ProjectError(_ERROR_REVISION_UNRESOLVED, message)
|
|
415
|
+
vcs_info = record.get("vcs_info")
|
|
416
|
+
if isinstance(vcs_info, dict) and "commit_id" in vcs_info:
|
|
417
|
+
return GitRevision.from_boundary(vcs_info["commit_id"])
|
|
418
|
+
dir_info = record.get("dir_info")
|
|
419
|
+
url = record.get("url")
|
|
420
|
+
if (
|
|
421
|
+
isinstance(dir_info, dict)
|
|
422
|
+
and isinstance(url, str)
|
|
423
|
+
and url.startswith("file://")
|
|
424
|
+
):
|
|
425
|
+
checkout = Path(urlparse(url).path)
|
|
426
|
+
inspect = inspect_checkout or _inspect_checkout
|
|
427
|
+
return _pinnable_head(checkout, inspect(checkout))
|
|
428
|
+
message = (
|
|
429
|
+
"the installed cloudfall distribution is neither a git install nor a "
|
|
430
|
+
f"source checkout ({url!r}); pass --rev with the commit to pin"
|
|
431
|
+
)
|
|
432
|
+
raise ProjectError(_ERROR_REVISION_UNRESOLVED, message)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _pinnable_head(checkout: Path, state: CheckoutState) -> GitRevision:
|
|
436
|
+
head = GitRevision.from_boundary(state.head)
|
|
437
|
+
if not state.clean:
|
|
438
|
+
message = (
|
|
439
|
+
f"the source checkout {checkout} has uncommitted changes, so HEAD "
|
|
440
|
+
f"{head} is not the code that is running; commit them or pass --rev"
|
|
441
|
+
)
|
|
442
|
+
raise ProjectError(_ERROR_REVISION_UNCOMMITTED, message)
|
|
443
|
+
if not state.published:
|
|
444
|
+
message = (
|
|
445
|
+
f"HEAD {head} of the source checkout {checkout} is on no remote "
|
|
446
|
+
"branch, so `uv sync` could not fetch it; push it or pass --rev"
|
|
447
|
+
)
|
|
448
|
+
raise ProjectError(_ERROR_REVISION_UNPUBLISHED, message)
|
|
449
|
+
return head
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _installed_direct_url() -> str | None:
|
|
453
|
+
return metadata.distribution("cloudfall").read_text("direct_url.json")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _git_binary() -> str:
|
|
457
|
+
git = shutil.which("git")
|
|
458
|
+
if git is None:
|
|
459
|
+
message = "git is required but is not installed"
|
|
460
|
+
raise ProjectError(_ERROR_GIT_UNAVAILABLE, message)
|
|
461
|
+
return git
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _run_git(directory: Path, *arguments: str) -> subprocess.CompletedProcess[str]:
|
|
465
|
+
try:
|
|
466
|
+
return subprocess.run( # noqa: S603 - fixed binary, fixed arguments.
|
|
467
|
+
[_git_binary(), "-C", str(directory), *arguments],
|
|
468
|
+
check=False,
|
|
469
|
+
capture_output=True,
|
|
470
|
+
text=True,
|
|
471
|
+
timeout=_GIT_TIMEOUT_SECONDS,
|
|
472
|
+
)
|
|
473
|
+
except subprocess.TimeoutExpired as error:
|
|
474
|
+
message = f"git {arguments[0]} in {directory} timed out: {error}"
|
|
475
|
+
raise ProjectError(_ERROR_GIT_FAILED, message) from error
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _git_or_fail(directory: Path, *arguments: str) -> str:
|
|
479
|
+
completed = _run_git(directory, *arguments)
|
|
480
|
+
if completed.returncode != 0:
|
|
481
|
+
message = (
|
|
482
|
+
f"git {arguments[0]} in {directory} failed with exit code "
|
|
483
|
+
f"{completed.returncode}: {completed.stderr.strip()}"
|
|
484
|
+
)
|
|
485
|
+
raise ProjectError(_ERROR_GIT_FAILED, message)
|
|
486
|
+
return completed.stdout
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _inspect_checkout(checkout: Path) -> CheckoutState:
|
|
490
|
+
head = _git_or_fail(checkout, "rev-parse", "HEAD").strip()
|
|
491
|
+
changes = _git_or_fail(checkout, "status", "--porcelain", "--untracked-files=no")
|
|
492
|
+
remote_branches = _git_or_fail(checkout, "branch", "--remotes", "--contains", head)
|
|
493
|
+
return CheckoutState(
|
|
494
|
+
head=head, clean=not changes.strip(), published=bool(remote_branches.strip())
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _initialize_git(directory: Path) -> GitSetup:
|
|
499
|
+
"""Create a repository in the directory unless one already encloses it."""
|
|
500
|
+
enclosing = _run_git(directory, "rev-parse", "--is-inside-work-tree")
|
|
501
|
+
if enclosing.returncode == 0 and enclosing.stdout.strip() == "true":
|
|
502
|
+
return GitSetup.ENCLOSED
|
|
503
|
+
if enclosing.returncode != _GIT_NOT_A_REPOSITORY_EXIT_CODE:
|
|
504
|
+
message = (
|
|
505
|
+
f"git rev-parse in {directory} failed with exit code "
|
|
506
|
+
f"{enclosing.returncode}: {enclosing.stderr.strip()}"
|
|
507
|
+
)
|
|
508
|
+
raise ProjectError(_ERROR_GIT_FAILED, message)
|
|
509
|
+
_git_or_fail(directory, "init", "--quiet")
|
|
510
|
+
return GitSetup.INITIALIZED
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def init_project(
|
|
514
|
+
options: InitOptions,
|
|
515
|
+
initialize_git: Callable[[Path], GitSetup] | None = None,
|
|
516
|
+
) -> ProjectScaffold:
|
|
517
|
+
"""Lay out a new project in an empty or absent directory."""
|
|
518
|
+
directory = options.directory
|
|
519
|
+
if directory.exists() and not directory.is_dir():
|
|
520
|
+
message = f"project path exists and is not a directory: {directory}"
|
|
521
|
+
raise ProjectError(_ERROR_NOT_A_DIRECTORY, message)
|
|
522
|
+
if directory.is_dir() and any(directory.iterdir()):
|
|
523
|
+
message = f"project directory is not empty: {directory}"
|
|
524
|
+
raise ProjectError(_ERROR_NOT_EMPTY, message)
|
|
525
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
526
|
+
git = (initialize_git or _initialize_git)(directory)
|
|
527
|
+
|
|
528
|
+
files: dict[str, str] = {
|
|
529
|
+
"pyproject.toml": _pyproject(options),
|
|
530
|
+
".gitignore": _GITIGNORE,
|
|
531
|
+
SOPS_CONFIGURATION_FILE: _SOPS_CONFIGURATION,
|
|
532
|
+
"README.md": _readme(options, git),
|
|
533
|
+
AGENT_CONTRACT_FILE: _agent_contract(options),
|
|
534
|
+
CLAUDE_POINTER_FILE: _CLAUDE_POINTER,
|
|
535
|
+
}
|
|
536
|
+
for kind in ResourceKind:
|
|
537
|
+
files[f"{kind.directory}/.gitkeep"] = ""
|
|
538
|
+
files[f"{SECRETS_DIRECTORY}/.gitkeep"] = ""
|
|
539
|
+
for relative, content in files.items():
|
|
540
|
+
path = directory / relative
|
|
541
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
542
|
+
path.write_text(content, encoding="utf-8")
|
|
543
|
+
return ProjectScaffold(
|
|
544
|
+
directory=directory,
|
|
545
|
+
name=options.name,
|
|
546
|
+
revision=options.revision,
|
|
547
|
+
source=options.source,
|
|
548
|
+
files=tuple(files),
|
|
549
|
+
git=git,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _pyproject(options: InitOptions) -> str:
|
|
554
|
+
description = (
|
|
555
|
+
str(options.description)
|
|
556
|
+
if options.description is not None
|
|
557
|
+
else "Cloudfall project: fleet, applications, and operating declarations"
|
|
558
|
+
)
|
|
559
|
+
return f"""[project]
|
|
560
|
+
name = "{options.name}"
|
|
561
|
+
version = "0"
|
|
562
|
+
description = "{description}"
|
|
563
|
+
requires-python = ">=3.14"
|
|
564
|
+
dependencies = ["cloudfall"]
|
|
565
|
+
|
|
566
|
+
[tool.uv]
|
|
567
|
+
package = false
|
|
568
|
+
|
|
569
|
+
# Cloudfall is an installed package: the schema catalog and the Ansible engine
|
|
570
|
+
# ship inside the wheel. Bump `rev` to move this project to a newer Cloudfall
|
|
571
|
+
# commit, then run `uv sync`. Add the `mcp` extra (`cloudfall[mcp]`) to run
|
|
572
|
+
# the `cloudfall-mcp` agent server.
|
|
573
|
+
[tool.uv.sources]
|
|
574
|
+
cloudfall = {{ git = "{options.source}", rev = "{options.revision}" }}
|
|
575
|
+
"""
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
_GITIGNORE = f"""# Runtime state: evidence, receipts, rendered inventory, built
|
|
579
|
+
# artifacts, rendered environment files, and the persisted migration plan
|
|
580
|
+
{RUNTIME_DIRECTORY}/
|
|
581
|
+
|
|
582
|
+
# Local environment and tooling
|
|
583
|
+
.venv/
|
|
584
|
+
__pycache__/
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
_SOPS_CONFIGURATION = f"""\
|
|
589
|
+
# sops encrypts every file matching a rule below with the listed age keys,
|
|
590
|
+
# so fragments under {SECRETS_DIRECTORY}/ are safe to commit. One-time setup:
|
|
591
|
+
#
|
|
592
|
+
# age-keygen -o ~/.config/cloudfall/age.key
|
|
593
|
+
# export SOPS_AGE_KEY_FILE=~/.config/cloudfall/age.key
|
|
594
|
+
#
|
|
595
|
+
# Replace the `age` value with the public key (age1...) that age-keygen
|
|
596
|
+
# printed, then write fragments with `sops {SECRETS_DIRECTORY}/production/<app>.env`.
|
|
597
|
+
creation_rules:
|
|
598
|
+
- path_regex: {SECRETS_DIRECTORY}/.*\\.env$
|
|
599
|
+
age: age1replace-with-your-public-key
|
|
600
|
+
"""
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _readme(options: InitOptions, git: GitSetup) -> str:
|
|
604
|
+
tmp = RUNTIME_DIRECTORY
|
|
605
|
+
secrets = SECRETS_DIRECTORY
|
|
606
|
+
browse = options.source.browse_url
|
|
607
|
+
revision = options.revision
|
|
608
|
+
secrets_guide = f"{browse}/blob/{revision}/{SECRETS_GUIDE_PATH}"
|
|
609
|
+
examples = f"{browse}/tree/{revision}/{EXAMPLES_PATH}"
|
|
610
|
+
about = (
|
|
611
|
+
str(options.description)
|
|
612
|
+
if options.description is not None
|
|
613
|
+
else (
|
|
614
|
+
"Say what this project manages: which servers, which applications "
|
|
615
|
+
"run on them, and who operates them. Pass `--description` to "
|
|
616
|
+
"`cloudfall init` to fill this in at creation time."
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
git_note = (
|
|
620
|
+
"This directory is a fresh git repository: add a private remote and\n"
|
|
621
|
+
"make the first commit, then install Cloudfall and validate:"
|
|
622
|
+
if git is GitSetup.INITIALIZED
|
|
623
|
+
else "This directory lies inside an existing git repository, so commit\n"
|
|
624
|
+
"it there. Install Cloudfall and validate:"
|
|
625
|
+
)
|
|
626
|
+
rows = "\n".join(
|
|
627
|
+
f"| `{kind.directory}/` | `{kind.value}` |" for kind in ResourceKind
|
|
628
|
+
)
|
|
629
|
+
return f"""# {options.name}
|
|
630
|
+
|
|
631
|
+
A [Cloudfall][cloudfall] project: the fleet, the applications that run on
|
|
632
|
+
it, and the declarations that operate them, in one private repository. Every
|
|
633
|
+
`cloudfall` command run from inside this directory finds it on its own; from
|
|
634
|
+
elsewhere, pass `--project` or set `CLOUDFALL_PROJECT`.
|
|
635
|
+
|
|
636
|
+
## About
|
|
637
|
+
|
|
638
|
+
{about}
|
|
639
|
+
|
|
640
|
+
## Layout
|
|
641
|
+
|
|
642
|
+
| Directory | Resource kind |
|
|
643
|
+
|---|---|
|
|
644
|
+
{rows}
|
|
645
|
+
|
|
646
|
+
Only these directories are read as resources, so playbooks, roles, docs, and
|
|
647
|
+
tooling files may live anywhere else in the project. `{tmp}/` holds evidence,
|
|
648
|
+
receipts, rendered inventory, built artifacts, and rendered environment files
|
|
649
|
+
and is ignored by git. Secret values never enter the resources: declare
|
|
650
|
+
`secretRefs` and keep sops-encrypted fragments in `{secrets}/`, which
|
|
651
|
+
`.sops.yaml` encrypts to your age key once you put the key there (see the
|
|
652
|
+
[secrets guide][secrets-guide]).
|
|
653
|
+
|
|
654
|
+
## Start
|
|
655
|
+
|
|
656
|
+
{git_note}
|
|
657
|
+
|
|
658
|
+
```console
|
|
659
|
+
uv sync
|
|
660
|
+
uv run cloudfall config validate
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
Validation fails until the project declares its first resources. Declare
|
|
664
|
+
your SSH key and your first server; the server's type is created from the
|
|
665
|
+
bundled Debian 13 baseline when it does not exist yet:
|
|
666
|
+
|
|
667
|
+
```console
|
|
668
|
+
uv run cloudfall add ssh-key ~/.ssh/id_ed25519.pub --owner <you>
|
|
669
|
+
uv run cloudfall add server h1 --address <ip-or-hostname>
|
|
670
|
+
uv run cloudfall config validate
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
Edit the written files freely; the [reference set][examples] in the
|
|
674
|
+
Cloudfall repository shows every kind. Then bring the host under management
|
|
675
|
+
and prove it:
|
|
676
|
+
|
|
677
|
+
```console
|
|
678
|
+
uv run cloudfall-engine inventory render --output {tmp}/ansible-inventory.json
|
|
679
|
+
uv run cloudfall-engine playbook run inspect --inventory {tmp}/ansible-inventory.json \\
|
|
680
|
+
--extra-vars cloudfall_inspect_output_directory=$PWD/{tmp}/observed
|
|
681
|
+
uv run cloudfall audit --observed {tmp}/observed
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
Add applications by hand or with `cloudfall import render`, then drive the
|
|
685
|
+
whole migration with the resumable plan:
|
|
686
|
+
|
|
687
|
+
```console
|
|
688
|
+
uv run cloudfall migrate --build <component>=main
|
|
689
|
+
uv run cloudfall migrate --build <component>=main --yes
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
Cloudfall is pinned to one commit in `pyproject.toml`; bump `rev` and run
|
|
693
|
+
`uv sync` to move the project to a newer release. The links below point at
|
|
694
|
+
the pinned commit.
|
|
695
|
+
|
|
696
|
+
[cloudfall]: {browse}
|
|
697
|
+
[secrets-guide]: {secrets_guide}
|
|
698
|
+
[examples]: {examples}
|
|
699
|
+
"""
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
_CLAUDE_POINTER = f"""\
|
|
703
|
+
Read `{AGENT_CONTRACT_FILE}` before doing anything in this project; it is the
|
|
704
|
+
operating contract for AI agents here.
|
|
705
|
+
"""
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _agent_contract(options: InitOptions) -> str:
|
|
709
|
+
tmp = RUNTIME_DIRECTORY
|
|
710
|
+
secrets = SECRETS_DIRECTORY
|
|
711
|
+
kinds = textwrap.fill(
|
|
712
|
+
", ".join(f"`{kind.directory}/`" for kind in ResourceKind), width=72
|
|
713
|
+
)
|
|
714
|
+
purpose = (
|
|
715
|
+
str(options.description)
|
|
716
|
+
if options.description is not None
|
|
717
|
+
else (
|
|
718
|
+
"Not stated yet: the project was created without `--description`.\n"
|
|
719
|
+
"Ask the human what this project manages before planning any\n"
|
|
720
|
+
"change; do not infer it from the resources."
|
|
721
|
+
)
|
|
722
|
+
)
|
|
723
|
+
reads = _command_rows(CommandEffect.READ)
|
|
724
|
+
writes = _command_rows(CommandEffect.PROJECT)
|
|
725
|
+
mutations = _command_rows(CommandEffect.SERVERS, with_gate=True)
|
|
726
|
+
return f"""# Agent operating contract
|
|
727
|
+
|
|
728
|
+
This is a [Cloudfall](https://cloudfall.dev) project: `{options.name}`.
|
|
729
|
+
Humans and AI agents both edit the resources and both run the CLI. An
|
|
730
|
+
agent drafts and validates; a human approves anything that changes a
|
|
731
|
+
server. These are the terms.
|
|
732
|
+
|
|
733
|
+
## What this project manages
|
|
734
|
+
|
|
735
|
+
{purpose}
|
|
736
|
+
|
|
737
|
+
## Invocation
|
|
738
|
+
|
|
739
|
+
Run every command as `uv run cloudfall …` (or `uv run cloudfall-engine …`)
|
|
740
|
+
from this directory, which the CLI recognizes as the project. From
|
|
741
|
+
elsewhere, pass `--project <dir>` or set `CLOUDFALL_PROJECT=<dir>`.
|
|
742
|
+
Relative paths, including every `{tmp}/` default, resolve against the
|
|
743
|
+
project either way. Run `uv sync` once after cloning and after any change
|
|
744
|
+
to the `rev` pin in `pyproject.toml`.
|
|
745
|
+
|
|
746
|
+
Resources are plain YAML, one document per file, in the kind directories,
|
|
747
|
+
and only those directories are read as resources:
|
|
748
|
+
|
|
749
|
+
{kinds}
|
|
750
|
+
|
|
751
|
+
Write or edit them freely, then run `uv run cloudfall config validate`
|
|
752
|
+
before anything else. Never write a value you would have to guess: ask for
|
|
753
|
+
addresses, key owners, git refs, and the like.
|
|
754
|
+
|
|
755
|
+
## Commands that change nothing on servers
|
|
756
|
+
|
|
757
|
+
Run these whenever they help. They read the project and evidence, may
|
|
758
|
+
probe servers and public endpoints read-only, and write only under
|
|
759
|
+
`{tmp}/`.
|
|
760
|
+
|
|
761
|
+
| Command | Does |
|
|
762
|
+
|---|---|
|
|
763
|
+
{reads}
|
|
764
|
+
|
|
765
|
+
## Commands that write project files
|
|
766
|
+
|
|
767
|
+
They write resources into this project on the controller; commit the
|
|
768
|
+
result. Servers are untouched.
|
|
769
|
+
|
|
770
|
+
| Command | Does |
|
|
771
|
+
|---|---|
|
|
772
|
+
{writes}
|
|
773
|
+
|
|
774
|
+
## Commands that change servers
|
|
775
|
+
|
|
776
|
+
Every command below changes servers. Do not run one, and do not add its
|
|
777
|
+
gate, unless a human has approved that specific run. The agent's job is to
|
|
778
|
+
prepare it: draft the resources, run the read-only checks, run the command
|
|
779
|
+
without its gate to obtain the plan, and show the plan to the human. The
|
|
780
|
+
human then runs the gated command, or tells the agent to run it. Nothing
|
|
781
|
+
on this list is safe to run on your own initiative.
|
|
782
|
+
|
|
783
|
+
| Command | Does | Gate |
|
|
784
|
+
|---|---|---|
|
|
785
|
+
{mutations}
|
|
786
|
+
|
|
787
|
+
There is no other way to change a server from this project: no ad hoc SSH,
|
|
788
|
+
no hand-written playbook run outside `cloudfall-engine playbook run`.
|
|
789
|
+
|
|
790
|
+
## Output contract
|
|
791
|
+
|
|
792
|
+
Every command prints one JSON document on stdout and nothing else. A
|
|
793
|
+
failure prints a JSON error envelope on stderr, `{{"status": "error",
|
|
794
|
+
"error": {{"code": …, "message": …}}}}`, and the `code` is stable: branch on
|
|
795
|
+
it, not on the message. Read the exit code first:
|
|
796
|
+
|
|
797
|
+
| Exit | Meaning |
|
|
798
|
+
|---|---|
|
|
799
|
+
| `0` | the command ran and its result is positive (`status: ok` or `status: plan`) |
|
|
800
|
+
| `1` | the command ran and its result is negative: drift, unhealthy, a failed step |
|
|
801
|
+
| `2` | invalid input, usage, or an unmet precondition; nothing ran |
|
|
802
|
+
| `3` | compliance unknown: observations missing or stale (`audit`, `migrate`) |
|
|
803
|
+
|
|
804
|
+
## Evidence
|
|
805
|
+
|
|
806
|
+
`{tmp}/` holds everything derived or produced: observations, receipts,
|
|
807
|
+
rendered inventory, built artifacts, rendered environment files, and the
|
|
808
|
+
persisted migration plan. It is ignored by git. Read anything there to
|
|
809
|
+
answer questions about state. Exception: `{tmp}/env/` and any other
|
|
810
|
+
rendered environment file contain secret values; do not read or quote them.
|
|
811
|
+
|
|
812
|
+
Status comes from evidence, never from assumption. A deployment counts as
|
|
813
|
+
done when its receipt exists; a server counts as compliant when `audit`
|
|
814
|
+
says so against a fresh observation. Do not report success you cannot
|
|
815
|
+
point to a receipt or observation for.
|
|
816
|
+
|
|
817
|
+
## Secrets
|
|
818
|
+
|
|
819
|
+
Resources declare `secretRefs`; they never hold values. Secret values live
|
|
820
|
+
only in sops-encrypted fragments under `{secrets}/`, which a human writes
|
|
821
|
+
and commits. Never read, decrypt, write, or echo a secret value, and never
|
|
822
|
+
place one in a resource, a command line, a commit, or a message. If a
|
|
823
|
+
command needs a secret file, ask the human to provide the path.
|
|
824
|
+
|
|
825
|
+
## Git
|
|
826
|
+
|
|
827
|
+
Resources, `{secrets}/`, `.sops.yaml`, `pyproject.toml`, `uv.lock`, and the
|
|
828
|
+
documents in this directory are committed. `{tmp}/` and `.venv/` are not.
|
|
829
|
+
Commit resource changes with a message saying what changed and why; do not
|
|
830
|
+
commit on the human's behalf unless asked.
|
|
831
|
+
"""
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _command_rows(effect: CommandEffect, *, with_gate: bool = False) -> str:
|
|
835
|
+
rows = []
|
|
836
|
+
for command in commands_with_effect(effect):
|
|
837
|
+
cells = [f"`{command.invocation}`", command.summary]
|
|
838
|
+
if with_gate:
|
|
839
|
+
cells.append(command.gate or "")
|
|
840
|
+
rows.append(f"| {' | '.join(cells)} |")
|
|
841
|
+
return "\n".join(rows)
|