diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dd83591..1418a47 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 11ae546..f01dcd8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/changelog.d/114-refuse-node-runtimes.md b/changelog.d/114-refuse-node-runtimes.md new file mode 100644 index 0000000..e23ee31 --- /dev/null +++ b/changelog.d/114-refuse-node-runtimes.md @@ -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) diff --git a/docs/render-migration-guide.md b/docs/render-migration-guide.md index 23f83c9..ff2edb2 100644 --- a/docs/render-migration-guide.md +++ b/docs/render-migration-guide.md @@ -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 diff --git a/sdk/src/cloudfall/importer.py b/sdk/src/cloudfall/importer.py index 1b2c32d..584409a 100644 --- a/sdk/src/cloudfall/importer.py +++ b/sdk/src/cloudfall/importer.py @@ -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 @@ -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"}: @@ -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, @@ -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], @@ -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( diff --git a/sdk/src/cloudfall/validation.py b/sdk/src/cloudfall/validation.py index c2f04d5..23b0288 100644 --- a/sdk/src/cloudfall/validation.py +++ b/sdk/src/cloudfall/validation.py @@ -27,6 +27,8 @@ ResourceId, ResourceKey, ResourceKind, + RuntimePackageManager, + RuntimeType, SourceLocation, ) @@ -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) @@ -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) @@ -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], diff --git a/sdk/tests/test_importer.py b/sdk/tests/test_importer.py index 25fdfeb..380dce8 100644 --- a/sdk/tests/test_importer.py +++ b/sdk/tests/test_importer.py @@ -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: diff --git a/sdk/tests/test_validation.py b/sdk/tests/test_validation.py index fb14c50..28506aa 100644 --- a/sdk/tests/test_validation.py +++ b/sdk/tests/test_validation.py @@ -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: