Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,10 @@ automatic or manual per application.
## Supported workloads

Applications run as native systemd units on long-lived Debian hosts, not
in containers. The primary stacks are Python (uv, Gunicorn/Uvicorn, Celery)
and Node.js; anything that runs as a systemd service with a health endpoint
fits the model.
in containers. The deployable stack today is Python (uv, Gunicorn/Uvicorn,
Celery); Node.js runtimes are reserved in the schema and refused by
`cloudfall config validate` until they ship. Anything that runs as a systemd
service with a health endpoint fits the model.

## Infrastructure model

Expand Down
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,9 @@ records an explicit origin skip when origin and proxy share a host.
[guided data-migration report](docs/proving-runs/2026-09-08-guided-data-migration.md))
- Cutover generator: DNS TTL lowering, parallel-run verification, switch, and
a rollback window (DNS steps surface in the import report today)
- v1 limitation: native Python and Node builds; container runtimes are a
tracked decision ✔ (documented and enforced — container services land in
the gap report)
- v1 limitation: native Python (uv) services; Node and container runtimes
are not deployable ✔ (documented and enforced: `config validate` refuses a
Node runtime, and Node and container services land in the gap report)

Exit: a real PaaS application migrated onto one host with its data, TLS,
monitoring, and rollback path. **Met on 2026-09-08** in two stages: a
Expand Down
3 changes: 3 additions & 0 deletions changelog.d/114-refuse-node-runtimes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Changed

- **Breaking:** `cloudfall config validate` now refuses a Component with `runtime.type` `node` or `pm2`, or `runtime.packageManager` `npm`, `pnpm` or `yarn`, with `CONFIG_INVALID` and code `component_runtime_unsupported` naming the field: these configs validated before but could never deploy, since the deploy role runs only Python with uv. The schema keeps those values as reserved. The Render importer no longer writes a Component for a Node service; it lists the service as an unsupported gap instead (#114)
3 changes: 2 additions & 1 deletion docs/render-migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ Not imported today; each lands in the gap report instead of being guessed at:
| Cron job (`type: cron`) | Recreate the schedule as a systemd timer; timers are modeled and audited |
| Static site (`type: static`) | Serve via an Nginx site manually for now |
| Redis / key-value (`type: redis`, `keyvalue`) | Not mapped by the importer; declare a Redis `Service` by hand (loopback-only, receipted RDB backups) and point the component at it |
| Docker runtime (`runtime: docker` / `image`) | Not supported in v1; native Python and Node builds only |
| Docker runtime (`runtime: docker` / `image`) | Not supported in v1; native Python services only |
| Node service (`runtime: node`) | Not deployable yet: the server runs Python with uv only, so the importer writes no Component for it |

## What you need

Expand Down
59 changes: 23 additions & 36 deletions sdk/src/cloudfall/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
_FIRST_COMPONENT_PORT = 8100
_MAX_TCP_PORT = 65535
_DEFAULT_PYTHON_VERSION = "3.14"
_DEFAULT_NODE_VERSION = "22"
_DEFAULT_POSTGRES_MAJOR = "16"
_POSTGRES_SERVICE_ID = "postgresql-main"
_RESOURCE_SLUG_MAX = 63
Expand Down Expand Up @@ -285,7 +284,7 @@ def _import_service(
_CATEGORY_UNSUPPORTED,
name,
"container runtimes are a tracked decision; only native python "
"and node services import today",
"services import today",
)
return
if service_type not in {"web", "worker", "pserv"}:
Expand All @@ -295,23 +294,28 @@ def _import_service(
f"service type {service_type!r} is not supported by the importer",
)
return
if runtime not in {"python", "node"}:
if runtime == "node":
state.gap(
_CATEGORY_UNSUPPORTED,
name,
"node services do not deploy yet: only python with uv runs on the "
"server, so no Component is written for this service",
)
return
if runtime != "python":
state.gap(
_CATEGORY_UNSUPPORTED,
name,
f"runtime {runtime!r} is not supported by the importer",
)
return
_import_component(
raw_service, name, service_type, runtime, targets, groups, state
)
_import_component(raw_service, name, service_type, targets, groups, state)


def _import_component( # noqa: PLR0913 - one boundary mapping, many inputs.
raw_service: Mapping[str, object],
name: str,
service_type: str,
runtime: str,
targets: ImportTargets,
groups: Mapping[str, Sequence[Mapping[str, object]]],
state: _ImportState,
Expand Down Expand Up @@ -376,7 +380,7 @@ def _import_component( # noqa: PLR0913 - one boundary mapping, many inputs.
"spec": {
"application": targets.application_id.value,
"repository": _repository(raw_service, repo),
"runtime": _runtime(runtime, resolved, name, state),
"runtime": _python_runtime(resolved, name, state),
"deployment": {
"strategy": "artifact-symlink",
"servers": [targets.server_id.value],
Expand Down Expand Up @@ -409,46 +413,29 @@ def _repository(
return repository


def _runtime(
runtime: str,
def _python_runtime(
resolved: Mapping[str, str],
name: str,
state: _ImportState,
) -> dict[str, object]:
if runtime == "python":
version = resolved.get("PYTHON_VERSION", _DEFAULT_PYTHON_VERSION)
if "PYTHON_VERSION" not in resolved:
state.gap(
_CATEGORY_ASSUMPTION,
name,
f"no PYTHON_VERSION declared; assuming "
f"{_DEFAULT_PYTHON_VERSION}",
)
state.gap(
_CATEGORY_ACTION,
name,
"Cloudfall deploys python components with uv sync --frozen; the "
"repository needs pyproject.toml and uv.lock",
)
return {
"type": "python",
"version": version,
"packageManager": "uv",
}
version = resolved.get("NODE_VERSION", _DEFAULT_NODE_VERSION)
if "NODE_VERSION" not in resolved:
version = resolved.get("PYTHON_VERSION", _DEFAULT_PYTHON_VERSION)
if "PYTHON_VERSION" not in resolved:
state.gap(
_CATEGORY_ASSUMPTION,
name,
f"no NODE_VERSION declared; assuming {_DEFAULT_NODE_VERSION}",
f"no PYTHON_VERSION declared; assuming {_DEFAULT_PYTHON_VERSION}",
)
state.gap(
_CATEGORY_ACTION,
name,
"node components are modeled in state but the deploy role supports "
"uv-managed python only in this slice",
"Cloudfall deploys python components with uv sync --frozen; the "
"repository needs pyproject.toml and uv.lock",
)
return {"type": "node", "version": version, "packageManager": "npm"}
return {
"type": "python",
"version": version,
"packageManager": "uv",
}


def _health_check(
Expand Down
29 changes: 29 additions & 0 deletions sdk/src/cloudfall/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
ResourceId,
ResourceKey,
ResourceKind,
RuntimePackageManager,
RuntimeType,
SourceLocation,
)

Expand All @@ -43,6 +45,12 @@
ResourceKind.OPERATOR_POLICY: "operator-policy.schema.json",
}
_YAML_SUFFIXES = frozenset({".yaml", ".yml"})
# The schema enums keep node, pm2, npm, pnpm and yarn as reserved values; the
# deploy role runs only these (S-002-14).
_DEPLOYABLE_RUNTIME: Mapping[str, str] = {
"type": RuntimeType.PYTHON.value,
"packageManager": RuntimePackageManager.UV.value,
}


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -515,6 +523,7 @@ def _validate_component_references(
document.source,
("spec", "application"),
)
_validate_component_runtime(spec, document.source)
deployment = _required_mapping(spec, "deployment", document.source)
_validate_install_root(deployment, application_id, document.source)
_validate_component_build(spec, document.source)
Expand Down Expand Up @@ -1516,6 +1525,26 @@ def _validate_component_build(
raise ConfigValidationError(issue)


def _validate_component_runtime(
spec: Mapping[str, object], source: SourceLocation
) -> None:
runtime = _required_mapping(spec, "runtime", source)
for field, deployable in _DEPLOYABLE_RUNTIME.items():
value = runtime.get(field)
if value != deployable:
issue = ValidationIssue(
code="component_runtime_unsupported",
message=(
f"runtime.{field} {value!r} is reserved and cannot deploy: "
"only runtime.type 'python' with runtime.packageManager "
"'uv' deploys today"
),
source=source,
field_path=("spec", "runtime", field),
)
raise ConfigValidationError(issue)


def _validate_alert_rule_references(
document: ResourceDocument,
spec: Mapping[str, object],
Expand Down
33 changes: 33 additions & 0 deletions sdk/tests/test_importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,39 @@ def test_importer_reports_gaps_for_unsupported_services(
assert "## Required actions" in report


@pytest.mark.parametrize("runtime_key", ["runtime", "env"])
def test_s002_15_node_web_service_is_a_gap_not_a_component(
tmp_path: Path, runtime_key: str
) -> None:
blueprint = tmp_path / "render.yaml"
blueprint.write_text(
BLUEPRINT.replace(
"envVarGroups:",
" - type: web\n"
" name: acme-web\n"
f" {runtime_key}: node\n"
" repo: https://github.com/example/acme-web.git\n"
" startCommand: node server.js\n"
" domains:\n"
" - web.example.test\n"
"envVarGroups:",
),
encoding="utf-8",
)
targets = _targets(tmp_path)

result = import_render_blueprint(blueprint, targets, SCHEMAS)

assert result.components == ("acme-api", "acme-worker")
assert result.domains == ("acme-example-test",)
assert not (targets.project_directory / "components" / "acme-web.yaml").exists()
assert "web-example-test" not in result.domains
node_gaps = [gap for gap in result.gaps if gap.subject == "acme-web"]
assert [gap.category for gap in node_gaps] == ["unsupported"]
assert "node" in node_gaps[0].detail
assert "acme-web" in result.report.read_text(encoding="utf-8")


def test_importer_rejects_a_application_that_cannot_own_a_linux_user(
tmp_path: Path,
) -> None:
Expand Down
46 changes: 46 additions & 0 deletions sdk/tests/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,52 @@ def test_component_install_root_must_stay_below_application_root(
assert error.value.issue.code == "component_install_root_invalid"


@pytest.mark.parametrize(
("original", "replacement", "field"),
[
("type: python", "type: node", "type"),
("type: python", "type: pm2", "type"),
("packageManager: uv", "packageManager: npm", "packageManager"),
("packageManager: uv", "packageManager: pnpm", "packageManager"),
("packageManager: uv", "packageManager: yarn", "packageManager"),
],
ids=["node", "pm2", "npm", "pnpm", "yarn"],
)
def test_s002_14_reserved_runtimes_are_refused_as_config_invalid(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
original: str,
replacement: str,
field: str,
) -> None:
project_directory = tmp_path / "config"
shutil.copytree(ROOT / "config" / "examples", project_directory)
component_path = project_directory / "components" / "crm-backend.yaml"
component_path.write_text(
component_path.read_text(encoding="utf-8").replace(original, replacement, 1),
encoding="utf-8",
)

exit_code = main(
[
"config",
"validate",
"--project",
str(project_directory),
"--schemas",
str(SCHEMAS),
]
)

payload = json.loads(capsys.readouterr().out)
assert exit_code == 80
assert payload["error"]["code"] == "CONFIG_INVALID"
context = payload["error"]["context"]
assert context["code"] == "component_runtime_unsupported"
assert context["field"] == f"spec.runtime.{field}"
assert f"runtime.{field}" in context["message"]


def test_server_type_rejects_duplicate_configuration_paths(
tmp_path: Path,
) -> None:
Expand Down
Loading