From 2e32e1ca1700bef133f47d90ad14d2ddcdee2ae5 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:47:17 -0600 Subject: [PATCH 1/2] Port doctor connection and compatibility diagnostics to Rust Preserve redacted connection provenance, server fixture summaries, human and machine output, recommendations and HTTP failure categories. Add 26 comparisons against published CLI 2.3.5 and normalize build identities only when they agree with each binary. Refs #72. --- rust/README.md | 1 + rust/parity/cases.json | 613 +++++++++++++++++++++++++++++++ rust/parity/parity.py | 42 ++- rust/src/build_info.rs | 19 +- rust/src/commands/doctor.rs | 385 +++++++++++++++++++ rust/src/commands/mod.rs | 1 + rust/src/compat.rs | 1 + rust/src/compat/external_task.rs | 51 +++ rust/src/config.rs | 174 +++++++-- rust/src/main.rs | 3 +- 10 files changed, 1254 insertions(+), 36 deletions(-) create mode 100644 rust/src/commands/doctor.rs diff --git a/rust/README.md b/rust/README.md index 40b6a34..2b364d0 100644 --- a/rust/README.md +++ b/rust/README.md @@ -21,6 +21,7 @@ every command group reaches parity. | `env:list`, `env:set`, `env:use`, `env:show`, `env:delete` | Ported | | `server:health`, `server:info` (and `cluster:info`) | Ported | | Compatibility preflight, session and `--version` warnings, exit 8 refusals | Ported | +| `doctor`: redacted connection sources, server metadata, warnings and next steps | Ported | | Workflow, namespace and schedule commands | Next slices | | `--tls-verify=false` against HTTPS | Refused until durable-workflow/sdk-rust#180 | diff --git a/rust/parity/cases.json b/rust/parity/cases.json index f33b347..6e0f8b5 100644 --- a/rust/parity/cases.json +++ b/rust/parity/cases.json @@ -1873,6 +1873,619 @@ "schema:list", "--ansi" ] + }, + { + "name": "doctor JSON", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor JSONL", + "argv": [ + "doctor", + "--output=jsonl", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor human", + "argv": [ + "doctor", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor ANSI", + "argv": [ + "doctor", + "--ansi", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor quiet", + "argv": [ + "doctor", + "-q", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor clean with flag token", + "argv": [ + "doctor", + "--token=parity-test-token", + "--output=json", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor environment connection", + "argv": [ + "doctor", + "--output=json" + ], + "mock": [], + "env": { + "DURABLE_WORKFLOW_SERVER_URL": "{mock}", + "DURABLE_WORKFLOW_NAMESPACE": "env-namespace", + "DURABLE_WORKFLOW_AUTH_TOKEN": "parity-test-token", + "DURABLE_WORKFLOW_TLS_VERIFY": "false" + } + }, + { + "name": "doctor flag profile", + "argv": [ + "doctor", + "--env=prod", + "--output=json" + ], + "mock": [], + "env": {}, + "setup": [ + [ + "env:set", + "prod", + "--server={mock}", + "--namespace=profile-ns", + "--token=profile-test-token", + "--tls-verify=false" + ] + ] + }, + { + "name": "doctor selected profile", + "argv": [ + "doctor", + "--output=json" + ], + "mock": [], + "env": {}, + "setup": [ + [ + "env:set", + "prod", + "--server={mock}", + "--namespace=profile-ns", + "--token=profile-test-token", + "--tls-verify=false" + ], + [ + "env:use", + "prod" + ] + ] + }, + { + "name": "doctor DW_ENV profile", + "argv": [ + "doctor", + "--output=json" + ], + "mock": [], + "env": { + "DW_ENV": "prod" + }, + "setup": [ + [ + "env:set", + "prod", + "--server={mock}", + "--namespace=profile-ns", + "--token=profile-test-token", + "--tls-verify=false" + ] + ] + }, + { + "name": "doctor profile env token present", + "argv": [ + "doctor", + "--env=prod", + "--output=json" + ], + "mock": [], + "env": { + "PARITY_DOCTOR_TOKEN": "profile-env-test-token" + }, + "setup": [ + [ + "env:set", + "prod", + "--server={mock}", + "--token-env=PARITY_DOCTOR_TOKEN" + ] + ] + }, + { + "name": "doctor profile env token missing", + "argv": [ + "doctor", + "--env=prod", + "--output=json" + ], + "mock": [], + "env": { + "PARITY_DOCTOR_TOKEN": "" + }, + "setup": [ + [ + "env:set", + "prod", + "--server={mock}", + "--token-env=PARITY_DOCTOR_TOKEN" + ] + ] + }, + { + "name": "doctor flag precedence", + "argv": [ + "doctor", + "--env=prod", + "--namespace=flag-ns", + "--token=flag-test-token", + "--tls-verify=true", + "--output=json" + ], + "mock": [], + "env": { + "DURABLE_WORKFLOW_NAMESPACE": "env-ns", + "DURABLE_WORKFLOW_AUTH_TOKEN": "env-test-token", + "DURABLE_WORKFLOW_TLS_VERIFY": "false" + }, + "setup": [ + [ + "env:set", + "prod", + "--server={mock}", + "--namespace=profile-ns", + "--token=profile-test-token", + "--tls-verify=false" + ] + ] + }, + { + "name": "doctor TLS disabled", + "argv": [ + "doctor", + "--tls-verify=false", + "--output=json", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor incompatible control plane", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [ + [ + [ + "control_plane", + "version" + ], + "3" + ] + ] + }, + { + "name": "doctor incompatible worker protocol", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [ + [ + [ + "worker_protocol", + "version" + ], + "99.0" + ] + ] + }, + { + "name": "doctor missing protocol metadata", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [ + [ + [ + "control_plane" + ], + null + ], + [ + [ + "worker_protocol" + ], + null + ], + [ + [ + "auth_composition_contract" + ], + null + ] + ] + }, + { + "name": "doctor malformed protocol metadata", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [ + [ + [ + "version" + ], + false + ], + [ + [ + "server_id" + ], + {} + ], + [ + [ + "auth_composition_contract" + ], + [] + ], + [ + [ + "control_plane" + ], + [] + ], + [ + [ + "worker_protocol" + ], + { + "version": 7, + "invocable_carrier_contract": { + "version": true, + "scope": { + "task_kinds": { + "x": "activity_task" + } + } + }, + "external_task_input_contract": { + "version": "2x", + "fixtures": { + "unknown": {}, + "activity_task": {} + } + } + } + ] + ] + }, + { + "name": "doctor native published metadata", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/cluster/info": { + "body": { + "server_id": "9b667a0d792b", + "version": "3.0.5", + "implementation": "rust", + "control_plane": { + "version": "2", + "request_contract": { + "schema": "durable-workflow.v2.control-plane-request.contract", + "version": 1, + "operations": { + "list": { + "fields": { + "status": { + "canonical_values": [ + "running", + "completed", + "failed" + ], + "rejected_aliases": { + "cancelled": "failed", + "terminated": "failed", + "pending": "running", + "waiting": "running" + } + } + } + }, + "update": { + "fields": { + "wait_for": { + "canonical_values": [ + "accepted", + "completed" + ] + }, + "wait_timeout_seconds": { + "minimum": 1, + "maximum": 60 + } + } + } + } + } + }, + "worker_protocol": { + "version": "1.20" + }, + "auth_composition_contract": { + "schema": "durable-workflow.v2.auth-composition.contract", + "version": 1, + "scope": "external_execution_carriers", + "unknown_field_policy": "ignore_additive_reject_unknown_required", + "precedence": { + "connection_values": [ + "flag", + "environment", + "selected_profile", + "default" + ], + "profile_selection": [ + "flag_env", + "DW_ENV", + "current_profile", + "default_profile" + ], + "notes": [ + "flags win only for the current invocation", + "environment values are portable carrier inputs and must not be persisted by default", + "selected profile values are persisted configuration, but secret fields should be references", + "defaults are explicit and must appear in effective-config diagnostics" + ] + }, + "canonical_environment": { + "server_url": "DURABLE_WORKFLOW_SERVER_URL", + "namespace": "DURABLE_WORKFLOW_NAMESPACE", + "auth_token": "DURABLE_WORKFLOW_AUTH_TOKEN", + "tls_verify": "DURABLE_WORKFLOW_TLS_VERIFY", + "profile": "DW_ENV" + }, + "auth_material": { + "token": { + "status": "supported", + "transport": "bearer_authorization_header", + "persisted_as": "secret_reference_or_profile_env_reference", + "effective_config_value": "redacted" + }, + "mtls": { + "status": "reserved", + "persisted_as": "certificate_and_key_references", + "effective_config_value": "references_only" + }, + "signed_headers": { + "status": "reserved", + "persisted_as": "key_reference_and_header_allowlist", + "effective_config_value": "references_only" + }, + "extensions": { + "status": "reserved", + "rule": "new auth material types must define redaction, persistence, and source precedence before use" + } + }, + "effective_config": { + "required_fields": [ + "server_url", + "namespace", + "profile", + "auth", + "tls", + "identity" + ], + "source_values": [ + "flag", + "environment", + "selected_profile", + "profile_env", + "default", + "server" + ], + "field_contracts": { + "server_url": { + "value": "normalized_absolute_url", + "source": "one_of_source_values" + }, + "namespace": { + "value": "non_empty_namespace_name", + "source": "one_of_source_values" + }, + "profile": { + "name": "nullable_string", + "source": "flag_env_or_DW_ENV_or_current_profile_or_default_profile" + }, + "auth": { + "type": "token_or_mtls_or_signed_headers_or_none", + "source": "one_of_source_values", + "value": "redacted_or_reference_only" + }, + "tls": { + "verify": "boolean", + "source": "one_of_source_values" + }, + "identity": { + "subject": "nullable_string", + "roles": "list_of_strings", + "source": "server_or_carrier" + } + } + }, + "redaction": { + "never_echo": [ + "bearer_tokens", + "private_keys", + "shared_signature_keys", + "client_certificate_private_key_material", + "raw_authorization_headers" + ], + "allowed_diagnostics": [ + "redacted", + "secret_reference_name", + "environment_variable_name", + "profile_name", + "certificate_reference_name", + "key_reference_name" + ] + }, + "resolution": { + "server_url": "carriers normalize and use the winning absolute URL without appending hidden path defaults", + "namespace": "carriers send the winning namespace explicitly when the protocol supports namespace headers", + "identity": "servers may report authenticated subject and roles; carriers must treat identity as server-asserted when present", + "tls": "carriers must fail closed on invalid TLS unless the winning tls.verify value is false" + } + } + } + } + } + }, + { + "name": "doctor HTTP 401", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/cluster/info": { + "status": 401, + "body": { + "message": "Synthetic diagnostic refusal" + } + } + } + }, + { + "name": "doctor HTTP 403", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/cluster/info": { + "status": 403, + "body": { + "message": "Synthetic diagnostic refusal" + } + } + } + }, + { + "name": "doctor HTTP 404", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/cluster/info": { + "status": 404, + "body": { + "message": "Synthetic diagnostic refusal" + } + } + } + }, + { + "name": "doctor HTTP 422", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/cluster/info": { + "status": 422, + "body": { + "message": "Synthetic diagnostic refusal" + } + } + } + }, + { + "name": "doctor HTTP 500", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/cluster/info": { + "status": 500, + "body": { + "message": "Synthetic diagnostic refusal" + } + } + } + }, + { + "name": "doctor unreachable JSON", + "argv": [ + "doctor", + "--server=http://127.0.0.1:9", + "--output=json" + ] + }, + { + "name": "doctor unreachable human", + "argv": [ + "doctor", + "--server=http://127.0.0.1:9" + ] } ] } diff --git a/rust/parity/parity.py b/rust/parity/parity.py index a6af970..af87486 100755 --- a/rust/parity/parity.py +++ b/rust/parity/parity.py @@ -49,6 +49,7 @@ BUILD_IDENTITY = re.compile(r"\(commit [^,]+, built [^)]+\)") MARKDOWN_TITLE = re.compile(r"^(dw )\n=+$", re.M) TRANSPORT_LINE = re.compile(r"^(Server unreachable|Request timed out): .*$", re.M) +DOCTOR_TRANSPORT_LINE = re.compile(r"^( Error: )(Server unreachable|Request timed out): .*$", re.M) LOOPBACK_PORT = re.compile(r"127\.0\.0\.1:\d+") SERVER_LABEL = re.compile(r"[a-z][a-z0-9-]{0,31}") # Transport errors name the HTTP stack that failed; only the category is @@ -75,11 +76,28 @@ def substitute(text, case): return text -def own_version(binary): - """The bare version a binary reports, which its messages embed.""" +def own_identity(binary): + """Build metadata from this binary, rather than a different release.""" output = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=60, check=False).stdout - match = re.match(r"dw (\S+) ", output) - return match.group(1) if match else None + match = re.match(r"dw (\S+) \(commit ([^,]+), built ([^)]+)\)", output) + return dict(zip(("version", "commit", "build_date"), match.groups())) if match else {} + + +def normalize_doctor_identity(stdout_json, stdout_text, identity): + """Normalize only metadata that agrees with the binary's --version. + + A wrong diagnostic commit or build date remains a comparison failure. + """ + for key, label in (("commit", "Commit"), ("build_date", "Built")): + if key not in identity: + continue + replacement = f"<{key}>" + stdout_text = re.sub(r"^ " + label + ": " + re.escape(identity[key]) + r"$", + f" {label}: {replacement}", stdout_text, flags=re.M) + for item in stdout_json or []: + if isinstance(item, dict) and isinstance(item.get("dw"), dict) and item["dw"].get(key) == identity[key]: + item["dw"][key] = replacement + return stdout_json, stdout_text def normalize_text(text, config_dir, version=None): @@ -93,6 +111,7 @@ def normalize_text(text, config_dir, version=None): text = MARKDOWN_TITLE.sub(r"\1\n", text) text = TIME.sub("