Skip to content
Closed
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
9 changes: 8 additions & 1 deletion crates/registry-relay/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4010,12 +4010,19 @@ consultation:
.await
.expect("test listener binds");
let addr = listener.local_addr().expect("listener has local addr");
drop(listener);
let url = format!("http://{addr}/healthz");
let peer = tokio::spawn(async move {
let (connection, _) = listener.accept().await.expect("test peer accepts");
drop(connection);
});

let err = run_healthcheck(&url, Duration::from_millis(200))
.await
.expect_err("healthcheck fails");
tokio::time::timeout(Duration::from_secs(1), peer)
.await
.expect("healthcheck contacts the test peer")
.expect("test peer joins");
assert!(
err.to_string().contains("request failed"),
"unexpected error: {err}"
Expand Down
17 changes: 17 additions & 0 deletions crates/registryctl/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

### Changed

- The maintained project-authoring fixtures now keep Relay source adaptation,
Notary evidence policy, and consumer decisions separate. The
DHIS2 Tracker starter retains its bounded health-evidence contract while
distinguishing positive, negative, unknown, no-match, ambiguity, and source
failure. Snapshot and custom-system examples now model reusable evidence or
explicitly source-owned decisions instead of Notary-owned eligibility.
- Smoke reports now carry the `registryctl.smoke.v1` schema version and are
validated against a committed JSON Schema.

### Removed

- Removed the hidden `registryctl init spreadsheet-api` compatibility alias.
Use `registryctl init relay` for the local spreadsheet tutorial or
`registryctl init --from` for Registry Stack project authoring.

## [0.12.2] - 2026-07-20

- No user-visible Registryctl changes. This release fixes forward from the
Expand Down
15 changes: 8 additions & 7 deletions crates/registryctl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ Interactive report commands print concise human-readable results. Add `--format
another program needs a versioned report. Artifact and protocol commands, including authoring
schemas, editor metadata, the language server, and logs, retain their native output formats.

The generated project contains a local Registry Relay configuration, sample
XLSX workbook, Compose file, project manifest, local demo credentials, and an
optional Bruno API collection.
The initialization report identifies the project root and supported generated
entry points. Automation must use the versioned JSON report rather than
hard-code the local tutorial's generated directory layout, which remains an
implementation detail.

Run `registryctl doctor` before starting a generated stack or after editing config. It calls the
product-owned validators and redacts local secret values. Add `--format json` when another program
Expand Down Expand Up @@ -131,10 +132,10 @@ Registryctl never searches the current working directory for a lock, and
rejects a missing, mismatched, oversized, symlinked, or structurally invalid
file.

Existing projects do not need the lock for `start`, `stop`, `status`, or other
runtime commands. They keep using the immutable image references already stored
in `registryctl.yaml` and `compose.yaml`. A later `init` or `add` is a generation
operation and requires the lock for that registryctl version.
Existing local tutorial projects do not need the lock for `start`, `stop`,
`status`, or other runtime commands. Those commands use the immutable image
references already written into the project. A later `init` or `add` is a
generation operation and requires the lock for that registryctl version.

## Update checks

Expand Down
71 changes: 71 additions & 0 deletions crates/registryctl/schemas/registryctl.smoke.v1.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://id.registrystack.org/schemas/registryctl/registryctl.smoke.v1.schema.json",
"title": "registryctl smoke report v1",
"type": "object",
"required": ["schema_version", "base_url", "passed", "checks"],
"additionalProperties": false,
"properties": {
"schema_version": {
"const": "registryctl.smoke.v1"
},
"base_url": {
"type": "string",
"minLength": 1
},
"passed": {
"type": "boolean"
},
"checks": {
"type": "array",
"items": {
"$ref": "#/$defs/check"
}
}
},
"$defs": {
"check": {
"type": "object",
"required": [
"name",
"method",
"path",
"expected_status",
"actual_status",
"passed",
"error"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"method": {
"type": "string",
"minLength": 1
},
"path": {
"type": "string",
"pattern": "^/"
},
"expected_status": {
"type": "integer",
"minimum": 100,
"maximum": 599
},
"actual_status": {
"type": ["integer", "null"],
"minimum": 100,
"maximum": 599
},
"passed": {
"type": "boolean"
},
"error": {
"type": ["string", "null"]
}
}
}
}
}
46 changes: 46 additions & 0 deletions crates/registryctl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ const CONFIG_BUNDLE_SIGNATURE_SCHEMA: &str = "registry.platform.config_bundle_si
const CONFIG_TRUST_ANCHOR_SCHEMA: &str = "registry.platform.config_trust_anchor.v1";
const INIT_REPORT_SCHEMA_VERSION: &str = "registryctl.init.v1";
const ADD_NOTARY_REPORT_SCHEMA_VERSION: &str = "registryctl.add_notary.v1";
pub const SMOKE_REPORT_SCHEMA_V1: &str =
include_str!("../schemas/registryctl.smoke.v1.schema.json");

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
Expand Down Expand Up @@ -3667,13 +3669,22 @@ fn relay_config(credentials: &LocalCredentials) -> String {
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct SmokeReport {
schema_version: SmokeReportSchema,
base_url: String,
passed: bool,
checks: Vec<SmokeCheck>,
}

#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
enum SmokeReportSchema {
#[serde(rename = "registryctl.smoke.v1")]
V1,
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct SmokeCheck {
name: String,
method: String,
Expand Down Expand Up @@ -3795,6 +3806,7 @@ fn run_smoke_checks(base_url: &str, secrets: &LocalEnv) -> SmokeReport {
);

SmokeReport {
schema_version: SmokeReportSchema::V1,
base_url: base_url.to_string(),
passed: checks.iter().all(|check| check.passed),
checks,
Expand Down Expand Up @@ -5416,9 +5428,42 @@ mod tests {
assert!(!json.contains("row-secret"));
assert!(!json.contains("identity-secret"));
assert!(!report.passed);
assert_eq!(parsed.schema_version, SmokeReportSchema::V1);
assert_eq!(parsed.checks.len(), 11);
}

#[test]
fn smoke_report_rejects_another_schema_version() {
let secrets = LocalEnv {
values: BTreeMap::new(),
};
let report = run_smoke_checks("http://127.0.0.1:1", &secrets);
let mut document = serde_json::to_value(report).unwrap();
document["schema_version"] = serde_json::json!("registryctl.smoke.v2");

assert!(parse_smoke_report(&document.to_string()).is_err());
}

#[test]
fn smoke_report_json_matches_committed_schema() {
let secrets = LocalEnv {
values: BTreeMap::new(),
};
let report = run_smoke_checks("http://127.0.0.1:1", &secrets);
let document = serde_json::to_value(report).unwrap();
let schema: JsonValue = serde_json::from_str(SMOKE_REPORT_SCHEMA_V1).unwrap();
let compiled = jsonschema::JSONSchema::compile(&schema).expect("schema compiles");
let validation_errors = match compiled.validate(&document) {
Ok(()) => Vec::new(),
Err(errors) => errors.map(|error| error.to_string()).collect::<Vec<_>>(),
};

assert!(
validation_errors.is_empty(),
"registryctl smoke report must satisfy its schema: {validation_errors:?}"
);
}

#[test]
fn smoke_project_writes_redacted_failure_report() {
let temp = TempDir::new().unwrap();
Expand All @@ -5435,6 +5480,7 @@ mod tests {
for (_, secret) in env.lines().filter_map(|line| line.split_once('=')) {
assert!(!report.contains(secret));
}
assert!(report.contains("\"schema_version\": \"registryctl.smoke.v1\""));
assert!(report.contains("\"passed\": false"));
}

Expand Down
32 changes: 14 additions & 18 deletions crates/registryctl/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::path::PathBuf;

use anyhow::{Context, Result};
use clap::{Parser, Subcommand, ValueEnum};
use clap::{error::ErrorKind, CommandFactory, Parser, Subcommand, ValueEnum};

use registryctl::{
AddNotaryReport, AnchorReport, BundleInspectReport, BundleSignOptions, BundleSignReport,
Expand Down Expand Up @@ -39,8 +39,7 @@ fn main() -> Result<()> {
} => {
let destination = match (&from, command.as_deref()) {
(Some(_), None) => Some(project_dir.as_path()),
(None, Some(InitCommand::Relay { dir, .. }))
| (None, Some(InitCommand::SpreadsheetApi { dir, .. })) => Some(dir.as_path()),
(None, Some(InitCommand::Relay { dir, .. })) => Some(dir.as_path()),
_ => None,
};
if format == OutputFormat::Json
Expand All @@ -59,14 +58,20 @@ fn main() -> Result<()> {
InitCommand::Relay { dir, sample } => {
registryctl::init_spreadsheet_api(&dir, sample, &image_lock)?
}
InitCommand::SpreadsheetApi { dir, sample } => {
registryctl::init_spreadsheet_api(&dir, sample, &image_lock)?
}
}
}
_ => anyhow::bail!(
"init requires exactly one of --from or a legacy product subcommand"
),
(None, None) => Cli::command()
.error(
ErrorKind::MissingRequiredArgument,
"init requires exactly one of --from or the relay subcommand",
)
.exit(),
(Some(_), Some(_)) => Cli::command()
.error(
ErrorKind::ArgumentConflict,
"init accepts only one of --from or the relay subcommand",
)
.exit(),
};
match format {
OutputFormat::Human => println!("{}", render_init_report(&report)?),
Expand Down Expand Up @@ -2168,15 +2173,6 @@ enum InitCommand {
#[arg(long, value_enum, default_value_t = Sample::Benefits)]
sample: Sample,
},
/// Create a local Relay-backed spreadsheet API project.
#[command(name = "spreadsheet-api", hide = true)]
SpreadsheetApi {
/// Directory to create.
dir: PathBuf,
/// Sample project to generate.
#[arg(long, value_enum, default_value_t = Sample::Benefits)]
sample: Sample,
},
}

#[derive(Debug, Subcommand)]
Expand Down
2 changes: 1 addition & 1 deletion crates/registryctl/src/project_authoring/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1737,7 +1737,7 @@ mod fixture_interface_tests {
let (_, fixture) = loaded.integrations["health-record"]
.fixtures
.iter()
.find(|(_, fixture)| fixture.name == "complete-health-match")
.find(|(_, fixture)| fixture.name == "complete-child-health-evidence")
.expect("match fixture exists");
let input = offline_fixture_input(fixture).expect("fixture input is valid");
let interactions =
Expand Down
7 changes: 5 additions & 2 deletions crates/registryctl/src/project_authoring/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -782,11 +782,14 @@ outputs:
&loaded,
&json!({
"purpose": "household-support-screening",
"claims": ["household-category", "household-eligible"],
"claims": ["household-category", "source-household-approval-decision"],
}),
)
.expect("claims from both same-purpose services are valid");
assert_eq!(claims, ["household-category", "household-eligible"]);
assert_eq!(
claims,
["household-category", "source-household-approval-decision"]
);
}

#[test]
Expand Down
57 changes: 57 additions & 0 deletions crates/registryctl/tests/exit_status.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
use std::process::Command;

fn run_registryctl(args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_registryctl"))
.args(args)
.env("REGISTRYCTL_NO_UPDATE_CHECK", "1")
.output()
.expect("registryctl runs")
}

#[test]
fn success_uses_exit_status_zero() {
let output = run_registryctl(&["--version"]);

assert_eq!(output.status.code(), Some(0));
}

#[test]
fn command_failure_uses_exit_status_one() {
let temporary = tempfile::tempdir().expect("temporary directory");
let output = Command::new(env!("CARGO_BIN_EXE_registryctl"))
.arg("restart")
.current_dir(temporary.path())
.env("REGISTRYCTL_NO_UPDATE_CHECK", "1")
.output()
.expect("registryctl runs");

assert_eq!(output.status.code(), Some(1));
}

#[test]
fn usage_failure_uses_exit_status_two() {
let output = run_registryctl(&["not-a-command"]);

assert_eq!(output.status.code(), Some(2));
}

#[test]
fn removed_spreadsheet_alias_is_a_usage_failure() {
let output = run_registryctl(&["init", "spreadsheet-api", "project"]);

assert_eq!(output.status.code(), Some(2));
}

#[test]
fn missing_init_mode_is_a_usage_failure() {
let output = run_registryctl(&["init"]);

assert_eq!(output.status.code(), Some(2));
}

#[test]
fn conflicting_init_modes_are_a_usage_failure() {
let output = run_registryctl(&["init", "--from", "http", "relay", "project"]);

assert_eq!(output.status.code(), Some(2));
}
Loading
Loading