From f9d02cc03d9778bda22255a578fe4e46b0139f79 Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:15:29 +0200 Subject: [PATCH 1/2] Refuse Node runtimes at validate time and in the Render importer The deploy role runs only Python with uv, so a Component with runtime.type node or pm2, or packageManager npm, pnpm or yarn, passed config validate and then failed in the role. Validate now refuses it with component_runtime_unsupported (S-002-14), and the Render importer records a Node service as an unsupported gap instead of writing such a Component (S-002-15). The schema enums keep the values as reserved. Fixes #114 Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 7 +-- ROADMAP.md | 6 +-- changelog.d/114-refuse-node-runtimes.md | 3 ++ docs/render-migration-guide.md | 3 +- sdk/src/cloudfall/importer.py | 59 ++++++++++--------------- sdk/src/cloudfall/validation.py | 29 ++++++++++++ sdk/tests/test_importer.py | 31 +++++++++++++ sdk/tests/test_validation.py | 46 +++++++++++++++++++ 8 files changed, 141 insertions(+), 43 deletions(-) create mode 100644 changelog.d/114-refuse-node-runtimes.md 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..4ef5dfe --- /dev/null +++ b/changelog.d/114-refuse-node-runtimes.md @@ -0,0 +1,3 @@ +### Changed + +- `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..a8acddc 100644 --- a/sdk/tests/test_importer.py +++ b/sdk/tests/test_importer.py @@ -174,6 +174,37 @@ def test_importer_reports_gaps_for_unsupported_services( assert "## Required actions" in report +def test_s002_15_node_web_service_is_a_gap_not_a_component( + tmp_path: Path, +) -> None: + blueprint = tmp_path / "render.yaml" + blueprint.write_text( + BLUEPRINT.replace( + "envVarGroups:", + " - type: web\n" + " name: acme-web\n" + " runtime: 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 "acme-web" not in result.components + 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: From c94e2f47ff2306cd5ad17cc0e802476b3ce43bad Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:30:40 +0200 Subject: [PATCH 2/2] Cover Render's env: node key and the surviving services in S-002-15 The importer reads runtime or the legacy env key; the S-002-15 test now runs both shapes and checks the Python services and their Domain of the same blueprint still import. The changelog fragment marks the validate refusal Breaking inline, as earlier 0.x entries under Changed do. Co-Authored-By: Claude Opus 5.5 --- changelog.d/114-refuse-node-runtimes.md | 2 +- sdk/tests/test_importer.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/changelog.d/114-refuse-node-runtimes.md b/changelog.d/114-refuse-node-runtimes.md index 4ef5dfe..e23ee31 100644 --- a/changelog.d/114-refuse-node-runtimes.md +++ b/changelog.d/114-refuse-node-runtimes.md @@ -1,3 +1,3 @@ ### Changed -- `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) +- **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/sdk/tests/test_importer.py b/sdk/tests/test_importer.py index a8acddc..380dce8 100644 --- a/sdk/tests/test_importer.py +++ b/sdk/tests/test_importer.py @@ -174,8 +174,9 @@ 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, + tmp_path: Path, runtime_key: str ) -> None: blueprint = tmp_path / "render.yaml" blueprint.write_text( @@ -183,7 +184,7 @@ def test_s002_15_node_web_service_is_a_gap_not_a_component( "envVarGroups:", " - type: web\n" " name: acme-web\n" - " runtime: node\n" + f" {runtime_key}: node\n" " repo: https://github.com/example/acme-web.git\n" " startCommand: node server.js\n" " domains:\n" @@ -196,7 +197,8 @@ def test_s002_15_node_web_service_is_a_gap_not_a_component( result = import_render_blueprint(blueprint, targets, SCHEMAS) - assert "acme-web" not in result.components + 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"]