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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- uses: actions/checkout@v7.0.1
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: v1
toolchain: stable
components: rustfmt, clippy
- uses: actions/cache@v6.1.0
with:
Expand Down
5 changes: 5 additions & 0 deletions services/seamstressd/.formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
15 changes: 13 additions & 2 deletions services/seamstressd/lib/seamstressd/runner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,21 @@ defmodule Seamstressd.Runner do
"""

def validate!(root) do
repo_root = Path.expand("../../../..", __DIR__)
cmd = "cargo"
args = ["run", "--quiet", "--manifest-path", "tools/seamctl/Cargo.toml", "--", "validate", "--root", root]

{out, code} = System.cmd(cmd, args, stderr_to_stdout: true)
args = [
"run",
"--quiet",
"--manifest-path",
"tools/seamctl/Cargo.toml",
"--",
"validate",
"--root",
root
]

{out, code} = System.cmd(cmd, args, cd: repo_root, stderr_to_stdout: true)

IO.write(out)

Expand Down
2 changes: 1 addition & 1 deletion services/seamstressd/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ defmodule Seamstressd.MixProject do

defp aliases do
[
"seam.validate": ["run -e 'Seamstressd.Runner.validate!(".")'"]
"seam.validate": ["run -e 'Seamstressd.Runner.validate!(\".\")'"]
]
end
end
13 changes: 7 additions & 6 deletions services/seamstressd/test/e2e_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ defmodule Seamstressd.E2ETest do
# Create a minimal temporary workspace that mimics a real seamstress repo.
# Returns {:ok, root_path} where root_path contains seams/records/.
defp tmp_workspace(label) do
root = Path.join(System.tmp_dir!(), "seamstress_e2e_#{label}_#{System.unique_integer([:positive])}")
root =
Path.join(
System.tmp_dir!(),
"seamstress_e2e_#{label}_#{System.unique_integer([:positive])}"
)

seams_dir = Path.join([root, "seams", "records"])
schema_dir = Path.join([root, "seams", "schema"])
File.mkdir_p!(seams_dir)
Expand All @@ -26,12 +31,8 @@ defmodule Seamstressd.E2ETest do

# Write a placeholder schema so seamctl has something to compile against.
defp write_schema(root) do
schema = %{
"$schema" => "http://json-schema.org/draft-07/schema#",
"type" => "object"
}
path = Path.join([root, "seams", "schema", "seam-record.schema.json"])
File.write!(path, Jason.encode!(schema))
File.write!(path, ~s({"$schema":"http://json-schema.org/draft-07/schema#","type":"object"}))
path
end

Expand Down
15 changes: 13 additions & 2 deletions services/seamstressd/test/property_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ defmodule Seamstressd.PropertyTest do
# Generate a stream of empty temporary directories (exist, but no records).
defp empty_temp_dirs(count) do
for i <- 1..count do
path = Path.join(System.tmp_dir!(), "seamstress_prop_empty_#{i}_#{System.unique_integer([:positive])}")
path =
Path.join(
System.tmp_dir!(),
"seamstress_prop_empty_#{i}_#{System.unique_integer([:positive])}"
)

File.mkdir_p!(path)
path
end
Expand All @@ -38,7 +43,12 @@ defmodule Seamstressd.PropertyTest do
# Generate paths with seams/records/ present but no .seam.json files.
defp dirs_with_records_dir_but_no_files(count) do
for i <- 1..count do
root = Path.join(System.tmp_dir!(), "seamstress_prop_nofiles_#{i}_#{System.unique_integer([:positive])}")
root =
Path.join(
System.tmp_dir!(),
"seamstress_prop_nofiles_#{i}_#{System.unique_integer([:positive])}"
)

records = Path.join([root, "seams", "records"])
File.mkdir_p!(records)
# Add a decoy file that is NOT a .seam.json
Expand Down Expand Up @@ -144,6 +154,7 @@ defmodule Seamstressd.PropertyTest do
end

unique_classes = Enum.uniq(classes)

assert length(unique_classes) == 1,
"Expected a single error class across runs, got: #{inspect(unique_classes)}"
end
Expand Down
91 changes: 12 additions & 79 deletions services/seamstressd/test/runner_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,87 +10,16 @@ defmodule Seamstressd.RunnerTest do
# Helpers
# ---------------------------------------------------------------------------

# Build a temporary directory tree containing the minimal file structure
# that seamctl expects (seams/schema/ and seams/records/). Returns the
# root path as a string so tests can call Runner.validate!/1 against it.
# Build a temporary records tree accepted by the repository schema.
# Return its root path so tests can call Runner.validate!/1 against it.
defp make_valid_seam_tree(root) do
schema_dir = Path.join([root, "seams", "schema"])
records_dir = Path.join([root, "seams", "records"])
File.mkdir_p!(schema_dir)
File.mkdir_p!(records_dir)

# Minimal JSON Schema accepted by jsonschema Draft-7
schema = %{
"$schema" => "http://json-schema.org/draft-07/schema#",
"type" => "object",
"properties" => %{
"id" => %{"type" => "string"},
"title" => %{"type" => "string"},
"status" => %{"type" => "string"},
"owners" => %{"type" => "array"},
"side_a" => %{"type" => "string"},
"side_b" => %{"type" => "string"},
"boundary_type" => %{"type" => "string"},
"data_flows" => %{"type" => "array"},
"contract_artifacts" => %{"type" => "array"},
"compat_policy" => %{"type" => "object"},
"semantic_invariants" => %{"type" => "array"},
"security_invariants" => %{"type" => "array"},
"privacy_invariants" => %{"type" => "array"},
"test_vectors" => %{"type" => "array"},
"checks" => %{"type" => "object"},
"slo" => %{"type" => "object"},
"failure_behavior" => %{"type" => "object"},
"observability" => %{"type" => "object"},
"change_process" => %{"type" => "object"},
"rollout_backout" => %{"type" => "object"}
},
"required" => ["id", "title", "status", "owners", "side_a", "side_b",
"boundary_type", "data_flows", "contract_artifacts",
"compat_policy", "semantic_invariants", "security_invariants",
"privacy_invariants", "test_vectors", "checks", "slo",
"failure_behavior", "observability", "change_process",
"rollout_backout"]
}

schema_path = Path.join(schema_dir, "seam-record.schema.json")
File.write!(schema_path, Jason.encode!(schema))

# Minimal valid seam record
record = %{
"id" => "seam-001",
"title" => "Test Seam",
"status" => "active",
"owners" => [%{"name" => "Test Owner", "contact" => "test@example.com", "role" => "owner"}],
"side_a" => "service-a",
"side_b" => "service-b",
"boundary_type" => "api",
"data_flows" => [%{"name" => "request", "direction" => "a->b", "description" => "HTTP request"}],
"contract_artifacts" => [],
"compat_policy" => %{"strategy" => "semver", "rules" => ["no breaking changes"], "deprecation_window_days" => 30},
"semantic_invariants" => ["idempotent"],
"security_invariants" => ["authenticated"],
"privacy_invariants" => ["no PII in logs"],
"test_vectors" => [],
"checks" => %{
"conformance" => %{"status" => "pass", "notes" => ""},
"no_hidden_channels" => %{"status" => "pass", "notes" => ""},
"evolution" => %{"status" => "pass", "notes" => ""}
},
"slo" => %{"latency_ms_p95" => 100, "error_rate_max" => 0.01},
"failure_behavior" => %{"timeouts" => "5s", "retries" => "3", "backpressure" => "drop", "idempotency" => "yes"},
"observability" => %{
"metrics" => ["requests_total"],
"logs" => %{"required_fields" => ["trace_id"]},
"tracing" => %{"propagation" => ["W3C"]}
},
"change_process" => %{"required_reviewers" => ["owner"], "gates" => ["ci-pass"]},
"rollout_backout" => %{"rollout_steps" => ["deploy"], "backout_steps" => ["rollback"]}
}

record_path = Path.join(records_dir, "seam-001.seam.json")
File.write!(record_path, Jason.encode!(record))

# Use the example accepted by seamctl's repository schema, including
# its self-referencing contract artifact.
example = Path.expand("../../../seams/records/_example.seam.json", __DIR__)
File.cp!(example, Path.join(records_dir, "_example.seam.json"))
root
end

Expand All @@ -109,7 +38,9 @@ defmodule Seamstressd.RunnerTest do
# but the important thing is the function accepts a string argument.
# We capture the raise rather than letting it crash the test.
assert_raise RuntimeError, fn ->
Seamstressd.Runner.validate!("/tmp/seamstress_nonexistent_#{System.unique_integer([:positive])}")
Seamstressd.Runner.validate!(
"/tmp/seamstress_nonexistent_#{System.unique_integer([:positive])}"
)
end
end

Expand Down Expand Up @@ -169,7 +100,9 @@ defmodule Seamstressd.RunnerTest do
end

test "validate!/1 rejects path with only non-seam files" do
root = Path.join(System.tmp_dir!(), "seamstress_norecords_#{System.unique_integer([:positive])}")
root =
Path.join(System.tmp_dir!(), "seamstress_norecords_#{System.unique_integer([:positive])}")

File.mkdir_p!(Path.join([root, "seams", "records"]))
File.write!(Path.join([root, "seams", "records", "README.txt"]), "not a seam file")

Expand Down
29 changes: 20 additions & 9 deletions tools/seamctl/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ fn discover(root: &Utf8PathBuf) -> Vec<Utf8PathBuf> {
continue;
}
let s = p.to_string_lossy();
let is_candidate_dir = s.contains("/seams/records/") || (s.contains("/targets/") && s.contains("/seams/records/"));
let is_candidate_dir = s.contains("/seams/records/")
|| (s.contains("/targets/") && s.contains("/seams/records/"));
if !is_candidate_dir {
continue;
}
Expand Down Expand Up @@ -50,18 +51,25 @@ pub fn run(root: Option<&str>, format: &str, out: Option<&str>) -> Result<()> {
let raw = std::fs::read_to_string(&f).with_context(|| format!("read {f}"))?;
let rec: SeamRecord = serde_json::from_str(&raw).with_context(|| format!("decode {f}"))?;

let a_idx = *node_map.entry(rec.side_a.clone()).or_insert_with(|| g.add_node(rec.side_a.clone()));
let b_idx = *node_map.entry(rec.side_b.clone()).or_insert_with(|| g.add_node(rec.side_b.clone()));
let a_idx = *node_map
.entry(rec.side_a.clone())
.or_insert_with(|| g.add_node(rec.side_a.clone()));
let b_idx = *node_map
.entry(rec.side_b.clone())
.or_insert_with(|| g.add_node(rec.side_b.clone()));
g.add_edge(a_idx, b_idx, rec.id.clone());
seams.push(rec.id);
}

let components: Vec<&String> = g.node_weights().collect();
let edges: Vec<serde_json::Value> = g.edge_indices().map(|ei| {
let (a, b) = g.edge_endpoints(ei).unwrap();
let id = &g[ei];
json!({"from": g[a], "to": g[b], "seam": id})
}).collect();
let edges: Vec<serde_json::Value> = g
.edge_indices()
.map(|ei| {
let (a, b) = g.edge_endpoints(ei).unwrap();
let id = &g[ei];
json!({"from": g[a], "to": g[b], "seam": id})
})
.collect();

let payload = json!({
"components": components,
Expand All @@ -76,7 +84,10 @@ pub fn run(root: Option<&str>, format: &str, out: Option<&str>) -> Result<()> {
for ei in g.edge_indices() {
let (a, b) = g.edge_endpoints(ei).unwrap();
let id = &g[ei];
s.push_str(&format!(" \"{}\" -> \"{}\" [label=\"{}\"];\n", g[a], g[b], id));
s.push_str(&format!(
" \"{}\" -> \"{}\" [label=\"{}\"];\n",
g[a], g[b], id
));
}
s.push_str("}\n");
s
Expand Down
7 changes: 1 addition & 6 deletions tools/seamctl/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
mod cli;
mod graph;
mod model;
mod report;
mod validate;

use anyhow::Result;
use clap::Parser;
use seamctl::cli;

fn main() {
if let Err(e) = run() {
Expand Down
7 changes: 5 additions & 2 deletions tools/seamctl/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ fn discover(root: &Utf8PathBuf) -> Vec<Utf8PathBuf> {
continue;
}
let s = p.to_string_lossy();
let is_candidate_dir = s.contains("/seams/records/") || (s.contains("/targets/") && s.contains("/seams/records/"));
let is_candidate_dir = s.contains("/seams/records/")
|| (s.contains("/targets/") && s.contains("/seams/records/"));
if !is_candidate_dir {
continue;
}
Expand All @@ -44,7 +45,9 @@ fn risk_flags(rec: &SeamRecord) -> Vec<String> {
if rec.checks.no_hidden_channels.status.to_lowercase() != "done" {
flags.push("no_hidden_channels_not_done".into());
}
if rec.failure_behavior.backpressure.trim().is_empty() || rec.failure_behavior.backpressure == "TODO" {
if rec.failure_behavior.backpressure.trim().is_empty()
|| rec.failure_behavior.backpressure == "TODO"
{
flags.push("backpressure_unspecified".into());
}
if rec.observability.metrics.is_empty() {
Expand Down
Loading
Loading