diff --git a/components/github-issue/src/lib.rs b/components/github-issue/src/lib.rs index c359466c..baa481fc 100644 --- a/components/github-issue/src/lib.rs +++ b/components/github-issue/src/lib.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value; use sha2::{Digest as _, Sha256}; wit_bindgen::generate!({ @@ -12,57 +13,175 @@ wit_bindgen::generate!({ use compoundingtech::st2_github_issue::github_issue; use exports::st2::resource_provider::provider_api; +const SNAPSHOT_SCHEMA: &str = "dev.schickling.github-issue.snapshot.v1"; +const TOPICS: [&str; 5] = ["body", "state", "labels", "assignment", "discussion"]; const SELECTOR_SCHEMA: &str = r#"{ "type": "object", "properties": { - "owner": { "type": "string" }, - "repo": { "type": "string" }, - "number": { "type": "integer" }, - "etag": { "type": "string" }, "topics": { "type": "array", "items": { "type": "string" }, "uniqueItems": true } }, - "required": ["owner", "repo", "number"], "additionalProperties": false }"#; struct Component; #[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(deny_unknown_fields)] struct Selector { + #[serde(default)] + topics: Vec, +} + +#[derive(Clone)] +struct IssueRef { owner: String, repo: String, number: u64, - #[serde(default)] - etag: Option, - #[serde(default)] - topics: Vec, +} + +impl IssueRef { + fn parse_uri(uri: &str) -> Option { + let subject = uri.strip_prefix("github-issue://github.com/")?; + let mut parts = subject.split('/'); + let owner = parts.next()?; + let repo = parts.next()?; + if parts.next()? != "issues" { + return None; + } + let number = parts.next()?; + if parts.next().is_some() + || !valid_component(owner, 39) + || !valid_component(repo, 100) + || number.is_empty() + || number.len() > 10 + || number.starts_with('0') + || !number.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + Some(Self { + owner: owner.to_owned(), + repo: repo.to_owned(), + number: number.parse().ok()?, + }) + } } #[derive(Deserialize)] -struct GitHubIssue { - number: u64, - state: String, +struct IssueResponse { title: String, + body: Option, + state: String, + state_reason: Option, + user: Option, + html_url: String, + locked: bool, + labels: Vec, + assignees: Vec, + milestone: Option, + comments: u64, + created_at: String, updated_at: String, + closed_at: Option, + pull_request: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +enum IssueStateReason { + Completed, + Duplicate, + NotPlanned, + Reopened, +} + +#[derive(Deserialize)] +struct LabelResponse { + name: String, +} + +#[derive(Deserialize)] +struct AssigneeResponse { + login: String, +} + +#[derive(Deserialize)] +struct UserResponse { + login: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct MilestoneResponse { + number: u64, + title: String, + state: String, + #[serde(alias = "html_url")] html_url: String, + #[serde(alias = "due_on")] + due_on: Option, +} + +#[derive(Deserialize)] +struct CommentMetadata { + updated_at: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct Carrier<'a> { - resource: &'static str, - owner: &'a str, - repo: &'a str, +struct Snapshot<'a> { + schema: &'static str, + uri: &'a str, + observed_at: &'a str, + repository: RepositorySnapshot<'a>, number: u64, - state: &'a str, - title: &'a str, - updated_at: &'a str, - html_url: &'a str, + issue: IssueSnapshot, + discussion: DiscussionSnapshot, + facets: Facets, +} + +#[derive(Serialize)] +struct RepositorySnapshot<'a> { + owner: &'a str, + name: &'a str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct IssueSnapshot { + title: String, + body: Option, + state: String, + state_reason: Option, + author: Option, + html_url: String, + locked: bool, + labels: Vec, + assignees: Vec, + milestone: Option, + created_at: String, + updated_at: String, + closed_at: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DiscussionSnapshot { + comment_count: u64, + latest_updated_at: Option, +} + +#[derive(Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct Facets { + open: bool, + closed: bool, + assigned: bool, + has_discussion: bool, } impl provider_api::Guest for Component { @@ -71,16 +190,15 @@ impl provider_api::Guest for Component { capabilities: vec![provider_api::SchedulingCapability::Demand], selector_schema_json: SELECTOR_SCHEMA.into(), default_selector_json: "{}".into(), - topics: vec!["issue".into()], + topics: TOPICS.iter().map(|topic| (*topic).to_owned()).collect(), snapshot_media_type: "application/json".into(), - snapshot_schema_id: "st2.resource.github-issue.v1".into(), + snapshot_schema_id: SNAPSHOT_SCHEMA.into(), }) } fn observe(request: provider_api::ObserveRequest) -> provider_api::ObservationResult { - observe(request).unwrap_or_else(|diagnostic| { - provider_api::ObservationResult::Failed(Some(diagnostic)) - }) + observe(request) + .unwrap_or_else(|diagnostic| provider_api::ObservationResult::Failed(Some(diagnostic))) } } @@ -89,66 +207,254 @@ fn observe( ) -> Result { let selector: Selector = serde_json::from_str(&request.selector_json) .map_err(|_| "invalid GitHub issue selector".to_owned())?; - if selector.owner.is_empty() || selector.repo.is_empty() || selector.number == 0 { - return Err("GitHub issue selector fields must be non-empty".into()); - } + let reference = IssueRef::parse_uri(&request.uri) + .ok_or_else(|| "invalid canonical GitHub issue URI".to_owned())?; let response = github_issue::get(&github_issue::IssueRequest { - owner: selector.owner.clone(), - repo: selector.repo.clone(), - number: selector.number, - etag: selector.etag, + owner: reference.owner.clone(), + repo: reference.repo.clone(), + number: reference.number, }) .map_err(map_source_error)?; - let (etag, body) = match response { - github_issue::IssueResponse::NotModified(_) => { + let observation = match response { + github_issue::IssueResponse::NotModified => { return Ok(provider_api::ObservationResult::Unchanged); } - github_issue::IssueResponse::Ok(value) => value, + github_issue::IssueResponse::Ok(observation) => observation, }; - let issue: GitHubIssue = serde_json::from_slice(&body) - .map_err(|_| "GitHub response was invalid".to_owned())?; - if issue.number != selector.number { - return Err("GitHub response did not match the requested issue".into()); + + let current = build_snapshot(&request.uri, &reference, &observation.current)?; + let previous = observation + .previous + .as_ref() + .map(|source| build_snapshot(&request.uri, &reference, source)) + .transpose()?; + if previous + .as_ref() + .is_some_and(|previous| same_semantics(previous, ¤t)) + { + let prior_digest = request + .prior_digest + .as_deref() + .ok_or_else(|| "GitHub prior source lacked a snapshot digest".to_owned())?; + github_issue::bind_snapshot(prior_digest).map_err(map_source_error)?; + return Ok(provider_api::ObservationResult::Unchanged); } - let bytes = serde_json::to_vec(&Carrier { - resource: "github-issue", - owner: &selector.owner, - repo: &selector.repo, - number: issue.number, - state: &issue.state, - title: &issue.title, - updated_at: &issue.updated_at, - html_url: &issue.html_url, - }) - .map_err(|_| "GitHub response normalization failed".to_owned())?; + let bytes = serde_json::to_vec(¤t) + .map_err(|_| "GitHub issue snapshot normalization failed".to_owned())?; let digest = Sha256::digest(&bytes); + github_issue::bind_snapshot(digest.as_slice()).map_err(map_source_error)?; if request.prior_digest.as_deref() == Some(digest.as_slice()) { return Ok(provider_api::ObservationResult::Unchanged); } - let _ = (request.uri, request.demand_watermark, selector.topics); - let facts = vec![ - provider_api::Fact { - key: "state".into(), - before: provider_api::FactValue::Omitted, - after: provider_api::FactValue::Value(issue.state), - }, - provider_api::Fact { - key: "etag".into(), - before: provider_api::FactValue::Omitted, - after: etag.map_or(provider_api::FactValue::Null, provider_api::FactValue::Value), - }, - ]; + let publication_topics = topics(previous.as_ref(), ¤t); + let facts = facts(previous.as_ref(), ¤t, reference.number); + let _ = (request.demand_watermark, selector.topics); Ok(provider_api::ObservationResult::Published( provider_api::Publication { - schema_id: "st2.resource.github-issue.v1".into(), + schema_id: SNAPSHOT_SCHEMA.into(), media_type: "application/json".into(), bytes, - topics: vec!["issue".into()], + topics: publication_topics, facts: Some(facts), }, )) } +fn build_snapshot( + uri: &str, + reference: &IssueRef, + source: &github_issue::SourceSnapshot, +) -> Result { + let issue: IssueResponse = serde_json::from_slice(&source.issue.body) + .map_err(|_| "GitHub issue response was invalid".to_owned())?; + if issue.pull_request.is_some() { + return Err("GitHub issue response described a pull request".into()); + } + let latest_updated_at = match (source.latest_comment.as_ref(), issue.comments) { + (None, 0) => None, + (None, _) => return Err("GitHub issue comment metadata was missing".into()), + (Some(_), 0) => return Err("GitHub issue comment metadata was unexpected".into()), + (Some(comment), _) => { + let comments: Vec = serde_json::from_slice(&comment.body) + .map_err(|_| "GitHub issue comment metadata was invalid".to_owned())?; + match comments.as_slice() { + [comment] => Some(comment.updated_at.clone()), + _ => return Err("GitHub issue comment metadata was invalid".into()), + } + } + }; + let mut labels: Vec<_> = issue.labels.into_iter().map(|label| label.name).collect(); + labels.sort(); + let mut assignees: Vec<_> = issue + .assignees + .into_iter() + .map(|assignee| assignee.login) + .collect(); + assignees.sort(); + let facets = Facets { + open: issue.state == "open", + closed: issue.state == "closed", + assigned: !assignees.is_empty(), + has_discussion: issue.comments > 0, + }; + + serde_json::to_value(Snapshot { + schema: SNAPSHOT_SCHEMA, + uri, + observed_at: &source.observed_at, + repository: RepositorySnapshot { + owner: &reference.owner, + name: &reference.repo, + }, + number: reference.number, + issue: IssueSnapshot { + title: issue.title, + body: issue.body, + state: issue.state, + state_reason: issue.state_reason, + html_url: issue.html_url, + author: issue.user.map(|user| user.login), + locked: issue.locked, + labels, + assignees, + milestone: issue.milestone, + created_at: issue.created_at, + updated_at: issue.updated_at, + closed_at: issue.closed_at, + }, + discussion: DiscussionSnapshot { + comment_count: issue.comments, + latest_updated_at, + }, + facets, + }) + .map_err(|_| "GitHub issue snapshot normalization failed".to_owned()) +} + +fn same_semantics(before: &Value, after: &Value) -> bool { + let (Some(before), Some(after)) = (before.as_object(), after.as_object()) else { + return false; + }; + let before_len = before + .keys() + .filter(|key| key.as_str() != "observedAt") + .count(); + let after_len = after + .keys() + .filter(|key| key.as_str() != "observedAt") + .count(); + before_len == after_len + && before + .iter() + .filter(|(key, _)| key.as_str() != "observedAt") + .all(|(key, value)| after.get(key) == Some(value)) +} + +fn topics(previous: Option<&Value>, current: &Value) -> Vec { + let Some(previous) = previous else { + return TOPICS.iter().map(|topic| (*topic).to_owned()).collect(); + }; + [ + ("body", vec![["issue", "title"], ["issue", "body"]]), + ("state", vec![["issue", "state"], ["issue", "stateReason"]]), + ("labels", vec![["issue", "labels"]]), + ( + "assignment", + vec![["issue", "assignees"], ["issue", "milestone"]], + ), + ("discussion", vec![["issue", "locked"], ["discussion", ""]]), + ] + .into_iter() + .filter_map(|(topic, paths)| { + paths + .into_iter() + .any(|path| value_at(previous, path) != value_at(current, path)) + .then(|| topic.to_owned()) + }) + .collect() +} + +fn value_at<'a>(value: &'a Value, path: [&str; 2]) -> Option<&'a Value> { + let value = value.get(path[0])?; + if path[1].is_empty() { + Some(value) + } else { + value.get(path[1]) + } +} + +fn facts(previous: Option<&Value>, current: &Value, number: u64) -> Vec { + fn state(snapshot: &Value) -> Option<&str> { + snapshot + .get("issue") + .and_then(|issue| issue.get("state")) + .and_then(Value::as_str) + } + fn comments(snapshot: &Value) -> Option { + snapshot + .get("discussion") + .and_then(|discussion| discussion.get("commentCount")) + .and_then(Value::as_u64) + .map(|count| count.to_string()) + } + let current_state = state(current).unwrap_or("unknown"); + let current_comments = comments(current).unwrap_or_else(|| "unknown".to_owned()); + let mut facts = vec![current_fact("issue", format!("#{number}"))]; + match previous { + Some(previous) => { + facts.push(transition_fact("state", state(previous), current_state)); + facts.push(transition_fact_owned( + "comments", + comments(previous), + current_comments, + )); + } + None => { + facts.push(current_fact("state", current_state)); + facts.push(current_fact("comments", current_comments)); + } + } + facts +} + +fn current_fact(key: &str, value: impl Into) -> provider_api::Fact { + provider_api::Fact { + key: key.into(), + before: provider_api::FactValue::Omitted, + after: provider_api::FactValue::Value(value.into()), + } +} + +fn transition_fact(key: &str, before: Option<&str>, after: &str) -> provider_api::Fact { + provider_api::Fact { + key: key.into(), + before: before.map_or(provider_api::FactValue::Null, |value| { + provider_api::FactValue::Value(value.into()) + }), + after: provider_api::FactValue::Value(after.into()), + } +} + +fn transition_fact_owned(key: &str, before: Option, after: String) -> provider_api::Fact { + provider_api::Fact { + key: key.into(), + before: before.map_or( + provider_api::FactValue::Null, + provider_api::FactValue::Value, + ), + after: provider_api::FactValue::Value(after), + } +} + +fn valid_component(value: &str, maximum: usize) -> bool { + !value.is_empty() + && value.len() <= maximum + && !matches!(value, "." | "..") + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + fn map_source_error(error: github_issue::IssueError) -> String { match error { github_issue::IssueError::Denied => "GitHub issue scope denied", diff --git a/components/github-pr/src/lib.rs b/components/github-pr/src/lib.rs index 710ce8e0..1ac95e41 100644 --- a/components/github-pr/src/lib.rs +++ b/components/github-pr/src/lib.rs @@ -23,116 +23,171 @@ const TOPICS: [&str; 4] = [ const SELECTOR_SCHEMA: &str = r#"{ "type": "object", "properties": { - "owner": { "type": "string" }, - "repo": { "type": "string" }, - "number": { "type": "integer" }, "topics": { "type": "array", "items": { "type": "string" }, "uniqueItems": true } }, - "required": ["owner", "repo", "number"], "additionalProperties": false }"#; struct Component; #[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(deny_unknown_fields)] struct Selector { + #[serde(default)] + topics: Vec, +} + +#[derive(Clone)] +struct PullRequestRef { owner: String, repo: String, number: u64, - #[serde(default)] - topics: Vec, +} + +impl PullRequestRef { + fn parse_uri(uri: &str) -> Option { + Self::parse_subject(uri.strip_prefix("github-pr://github.com/")?, "pull") + } + + fn parse_html_url(url: &str) -> Option { + Self::parse_subject(url.strip_prefix("https://github.com/")?, "pull") + } + + fn parse_subject(subject: &str, collection: &str) -> Option { + let mut parts = subject.split('/'); + let owner = parts.next()?; + let repo = parts.next()?; + if parts.next()? != collection { + return None; + } + let number = parts.next()?; + if parts.next().is_some() + || !valid_component(owner, 39) + || !valid_component(repo, 100) + || number.is_empty() + || number.len() > 10 + || number.starts_with('0') + || !number.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + Some(Self { + owner: owner.to_owned(), + repo: repo.to_owned(), + number: number.parse().ok()?, + }) + } } #[derive(Deserialize)] -struct PullRequestResponse { - number: u64, - #[serde(default = "null")] - url: Value, - #[serde(default = "null")] - html_url: Value, - #[serde(default = "null")] - state: Value, - #[serde(default)] - draft: Option, - #[serde(default)] - merged: Option, - #[serde(default = "null")] - merged_at: Value, - #[serde(default = "null")] - closed_at: Value, - #[serde(default)] - mergeable: Option, - #[serde(default = "null")] - mergeable_state: Value, - head: PullRequestHeadResponse, - base: PullRequestBaseResponse, - #[serde(default)] - requested_reviewers: Option>, - #[serde(default)] - requested_teams: Option>, +struct GraphqlData { + repository: Option, } #[derive(Deserialize)] -struct PullRequestHeadResponse { - sha: String, - #[serde(default = "null")] - r#ref: Value, +#[serde(rename_all = "camelCase")] +struct RepositoryResponse { + pull_request: Option, } #[derive(Deserialize)] -struct PullRequestBaseResponse { - #[serde(default = "null")] - r#ref: Value, +#[serde(rename_all = "camelCase")] +struct PullRequestResponse { + url: String, + title: String, + body: String, + state: String, + is_draft: bool, + merged: bool, + merged_at: Option, + closed_at: Option, + mergeable: String, + author: Option, + head_ref_oid: String, + head_ref_name: String, + base_ref_name: String, + review_decision: Option, + review_requests: ReviewRequestsResponse, + commits: CommitsResponse, } #[derive(Deserialize)] -struct RequestedReviewerResponse { +struct ActorResponse { login: String, } #[derive(Deserialize)] -struct RequestedTeamResponse { - slug: String, +#[serde(rename_all = "camelCase")] +struct ReviewRequestsResponse { + total_count: u64, + nodes: Vec, } #[derive(Deserialize)] -struct CheckRunsResponse { - #[serde(default)] - check_runs: Option>, +#[serde(rename_all = "camelCase")] +struct ReviewRequestResponse { + requested_reviewer: Option, } #[derive(Deserialize)] -struct CheckRunResponse { - name: String, - #[serde(default = "null")] - status: Value, - #[serde(default = "null")] - conclusion: Value, - #[serde(default = "null")] - details_url: Value, +#[serde(tag = "__typename")] +enum RequestedReviewerResponse { + User { login: String }, + Team { slug: String }, + Bot { login: String }, + Mannequin { login: String }, } #[derive(Deserialize)] -struct CombinedStatusResponse { - #[serde(default)] - state: Option, - #[serde(default)] - statuses: Option>, +struct CommitsResponse { + nodes: Vec, } #[derive(Deserialize)] -struct StatusResponse { - context: String, +struct CommitNodeResponse { + commit: CommitResponse, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CommitResponse { + status_check_rollup: Option, +} + +#[derive(Deserialize)] +struct CheckRollupResponse { state: String, - #[serde(default = "null")] - target_url: Value, - #[serde(default = "null")] - description: Value, + contexts: CheckContextsResponse, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CheckContextsResponse { + total_count: u64, + nodes: Vec, +} + +#[derive(Deserialize)] +#[serde(tag = "__typename")] +enum CheckContextResponse { + CheckRun { + name: String, + status: String, + conclusion: Option, + #[serde(rename = "detailsUrl")] + details_url: Option, + }, + StatusContext { + context: String, + state: String, + #[serde(rename = "targetUrl")] + target_url: Option, + description: Option, + }, } #[derive(Serialize)] @@ -157,36 +212,44 @@ struct RepositorySnapshot<'a> { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct PullRequestSnapshot { - api_url: Value, - html_url: Value, - state: Value, + api_url: String, + html_url: String, + title: String, + body: String, + state: String, + author: Option, draft: bool, merged: bool, - merged_at: Value, - closed_at: Value, + merged_at: Option, + closed_at: Option, mergeable: Option, - mergeable_state: Value, + mergeable_state: String, head: HeadSnapshot, base: BaseSnapshot, + review_decision: Option, requested_reviewers: Vec, requested_teams: Vec, + review_request_total_count: u64, + review_requests_truncated: bool, } #[derive(Serialize)] struct HeadSnapshot { sha: String, - r#ref: Value, + r#ref: String, } #[derive(Serialize)] struct BaseSnapshot { - r#ref: Value, + r#ref: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct CiSnapshot { state: String, + total_count: u64, + truncated: bool, check_runs: Vec, statuses: Vec, } @@ -195,9 +258,9 @@ struct CiSnapshot { #[serde(rename_all = "camelCase")] struct CheckRunSnapshot { name: String, - status: Value, - conclusion: Value, - details_url: Value, + status: String, + conclusion: Option, + details_url: Option, } #[derive(Serialize)] @@ -205,8 +268,8 @@ struct CheckRunSnapshot { struct StatusSnapshot { context: String, state: String, - target_url: Value, - description: Value, + target_url: Option, + description: Option, } #[derive(Serialize, Clone, Copy, PartialEq, Eq)] @@ -231,9 +294,8 @@ impl provider_api::Guest for Component { } fn observe(request: provider_api::ObserveRequest) -> provider_api::ObservationResult { - observe(request).unwrap_or_else(|diagnostic| { - provider_api::ObservationResult::Failed(Some(diagnostic)) - }) + observe(request) + .unwrap_or_else(|diagnostic| provider_api::ObservationResult::Failed(Some(diagnostic))) } } @@ -242,38 +304,20 @@ fn observe( ) -> Result { let selector: Selector = serde_json::from_str(&request.selector_json) .map_err(|_| "invalid GitHub pull request selector".to_owned())?; - if selector.owner.is_empty() || selector.repo.is_empty() || selector.number == 0 { - return Err("GitHub pull request selector fields must be non-empty".into()); - } - let expected_uri = format!( - "github-pr://{}/{}/{}", - selector.owner, selector.repo, selector.number - ); - if request.uri != expected_uri { - return Err("GitHub pull request URI did not match the selector".into()); - } - let response = github_pr::get(&github_pr::PullRequestRequest { - owner: selector.owner.clone(), - repo: selector.repo.clone(), - number: selector.number, + let reference = PullRequestRef::parse_uri(&request.uri) + .ok_or_else(|| "invalid canonical GitHub pull request URI".to_owned())?; + let observation = github_pr::get(&github_pr::PullRequestRequest { + owner: reference.owner.clone(), + repo: reference.repo.clone(), + number: reference.number, }) .map_err(map_source_error)?; - let observation = match response { - github_pr::PullRequestResponse::NotModified => { - return Ok(provider_api::ObservationResult::Unchanged); - } - github_pr::PullRequestResponse::Ok(observation) => observation, - }; - let current = build_snapshot( - &request.uri, - &selector, - &observation.current, - )?; + let current = build_snapshot(&request.uri, &reference, &observation.current)?; let previous = observation .previous .as_ref() - .map(|source| build_snapshot(&request.uri, &selector, source)) + .map(|source| build_snapshot(&request.uri, &reference, source)) .transpose()?; if previous .as_ref() @@ -294,7 +338,7 @@ fn observe( return Ok(provider_api::ObservationResult::Unchanged); } let publication_topics = topics(previous.as_ref(), ¤t); - let facts = facet_facts(previous.as_ref(), ¤t); + let facts = facts(previous.as_ref(), ¤t, reference.number); let _ = (request.demand_watermark, selector.topics); Ok(provider_api::ObservationResult::Published( provider_api::Publication { @@ -309,62 +353,151 @@ fn observe( fn build_snapshot( uri: &str, - selector: &Selector, + reference: &PullRequestRef, source: &github_pr::SourceSnapshot, ) -> Result { - let pull: PullRequestResponse = serde_json::from_slice(&source.pull_request.body) + let response: GraphqlData = serde_json::from_slice(&source.graphql_data) .map_err(|_| "GitHub pull request response was invalid".to_owned())?; - if pull.number != selector.number || !valid_head_sha(&pull.head.sha) { - return Err("GitHub response did not match the requested pull request".into()); + let pull = response + .repository + .and_then(|repository| repository.pull_request) + .ok_or_else(|| "GitHub pull request was missing".to_owned())?; + if !valid_head_sha(&pull.head_ref_oid) { + return Err("GitHub pull request head SHA was invalid".into()); + } + let resolved = PullRequestRef::parse_html_url(&pull.url) + .filter(|resolved| resolved.number == reference.number) + .ok_or_else(|| "GitHub pull request URL was invalid".to_owned())?; + let api_url = format!( + "https://api.github.com/repos/{}/{}/pulls/{}", + resolved.owner, resolved.repo, resolved.number + ); + + let review_request_total_count = pull.review_requests.total_count; + let connection_count = pull.review_requests.nodes.len(); + if connection_count > 100 || review_request_total_count < connection_count as u64 { + return Err("GitHub pull request bounded connection was invalid".into()); + } + let mut requested_reviewers = Vec::new(); + let mut requested_teams = Vec::new(); + for request in pull.review_requests.nodes { + let Some(reviewer) = request.requested_reviewer else { + continue; + }; + match reviewer { + RequestedReviewerResponse::User { login } + | RequestedReviewerResponse::Bot { login } + | RequestedReviewerResponse::Mannequin { login } => requested_reviewers.push(login), + RequestedReviewerResponse::Team { slug } => requested_teams.push(slug), + } } - let checks: CheckRunsResponse = serde_json::from_slice(&source.check_runs.body) - .map_err(|_| "GitHub check runs response was invalid".to_owned())?; - let status: CombinedStatusResponse = serde_json::from_slice(&source.combined_status.body) - .map_err(|_| "GitHub combined status response was invalid".to_owned())?; - - let mut requested_reviewers: Vec<_> = pull - .requested_reviewers - .unwrap_or_default() - .into_iter() - .map(|reviewer| reviewer.login) - .collect(); requested_reviewers.sort(); - let mut requested_teams: Vec<_> = pull - .requested_teams - .unwrap_or_default() - .into_iter() - .map(|team| team.slug) - .collect(); requested_teams.sort(); - let mut check_runs: Vec<_> = checks - .check_runs - .unwrap_or_default() - .into_iter() - .map(|check| CheckRunSnapshot { - name: check.name, - status: check.status, - conclusion: check.conclusion, - details_url: check.details_url, - }) - .collect(); + let review_request_observed_count = requested_reviewers.len() + requested_teams.len(); + + let mut commits = pull.commits.nodes.into_iter(); + let commit = commits + .next() + .ok_or_else(|| "GitHub pull request commit was missing".to_owned())?; + if commits.next().is_some() { + return Err("GitHub pull request bounded connection was invalid".into()); + } + let (combined_state, total_count, truncated, mut check_runs, mut statuses) = + if let Some(rollup) = commit.commit.status_check_rollup { + let observed_count = rollup.contexts.nodes.len(); + if observed_count > 100 || rollup.contexts.total_count < observed_count as u64 { + return Err("GitHub pull request bounded connection was invalid".into()); + } + let mut check_runs = Vec::new(); + let mut statuses = Vec::new(); + for context in rollup.contexts.nodes { + match context { + CheckContextResponse::CheckRun { + name, + status, + conclusion, + details_url, + } => check_runs.push(CheckRunSnapshot { + name, + status: normalized_enum( + status, + &[ + "completed", + "in_progress", + "pending", + "queued", + "requested", + "waiting", + ], + )?, + conclusion: conclusion + .map(|value| { + normalized_enum( + value, + &[ + "action_required", + "cancelled", + "failure", + "neutral", + "skipped", + "stale", + "startup_failure", + "success", + "timed_out", + ], + ) + }) + .transpose()?, + details_url, + }), + CheckContextResponse::StatusContext { + context, + state, + target_url, + description, + } => statuses.push(StatusSnapshot { + context, + state: normalized_enum( + state, + &["error", "expected", "failure", "pending", "success"], + )?, + target_url, + description, + }), + } + } + ( + normalized_enum( + rollup.state, + &["error", "expected", "failure", "pending", "success"], + )?, + rollup.contexts.total_count, + rollup.contexts.total_count > observed_count as u64, + check_runs, + statuses, + ) + } else { + ("pending".to_owned(), 0, false, Vec::new(), Vec::new()) + }; check_runs.sort_by(|left, right| left.name.cmp(&right.name)); - let mut statuses: Vec<_> = status - .statuses - .unwrap_or_default() - .into_iter() - .map(|status| StatusSnapshot { - context: status.context, - state: status.state, - target_url: status.target_url, - description: status.description, - }) - .collect(); statuses.sort_by(|left, right| left.context.cmp(&right.context)); + let state = normalized_enum(pull.state, &["open", "closed", "merged"])?; + let review_decision = pull + .review_decision + .map(|value| normalized_enum(value, &["approved", "changes_requested", "review_required"])) + .transpose()? + .map(|value| value.to_ascii_uppercase()); + let (mergeable, mergeable_state) = match pull.mergeable.as_str() { + "MERGEABLE" => (Some(true), "clean"), + "CONFLICTING" => (Some(false), "dirty"), + "UNKNOWN" => (None, "unknown"), + _ => return Err("GitHub pull request enum was invalid".into()), + }; let check_failure = check_runs.iter().any(|check| { - check.status.as_str() == Some("completed") + check.status == "completed" && matches!( - check.conclusion.as_str(), + check.conclusion.as_deref(), Some( "failure" | "timed_out" @@ -375,13 +508,16 @@ fn build_snapshot( ) ) }); - let combined_state = status.state.unwrap_or_else(|| "pending".to_owned()); + let status_failure = statuses + .iter() + .any(|status| matches!(status.state.as_str(), "failure" | "error")); let facets = Facets { - review_requested: !requested_reviewers.is_empty() || !requested_teams.is_empty(), - ci_failure: check_failure || matches!(combined_state.as_str(), "failure" | "error"), - merge_conflict: pull.mergeable == Some(false) - || pull.mergeable_state.as_str() == Some("dirty"), - terminal: pull.merged == Some(true) || pull.state.as_str() == Some("closed"), + review_requested: review_request_total_count > 0, + ci_failure: check_failure + || status_failure + || matches!(combined_state.as_str(), "failure" | "error"), + merge_conflict: mergeable == Some(false), + terminal: pull.merged || matches!(state.as_str(), "closed" | "merged"), }; serde_json::to_value(Snapshot { @@ -389,32 +525,41 @@ fn build_snapshot( uri, observed_at: &source.observed_at, repository: RepositorySnapshot { - owner: &selector.owner, - name: &selector.repo, + owner: &reference.owner, + name: &reference.repo, }, - number: selector.number, + number: reference.number, pull_request: PullRequestSnapshot { - api_url: pull.url, - html_url: pull.html_url, - state: pull.state, - draft: pull.draft.unwrap_or(false), - merged: pull.merged.unwrap_or(false), + api_url, + html_url: pull.url, + title: pull.title, + body: pull.body, + state, + author: pull.author.map(|author| author.login), + draft: pull.is_draft, + merged: pull.merged, merged_at: pull.merged_at, closed_at: pull.closed_at, - mergeable: pull.mergeable, - mergeable_state: pull.mergeable_state, + mergeable, + mergeable_state: mergeable_state.to_owned(), head: HeadSnapshot { - sha: pull.head.sha, - r#ref: pull.head.r#ref, + sha: pull.head_ref_oid, + r#ref: pull.head_ref_name, }, base: BaseSnapshot { - r#ref: pull.base.r#ref, + r#ref: pull.base_ref_name, }, + review_decision, requested_reviewers, requested_teams, + review_request_total_count, + review_requests_truncated: review_request_total_count + > review_request_observed_count as u64, }, ci: CiSnapshot { state: combined_state, + total_count, + truncated, check_runs, statuses, }, @@ -423,6 +568,14 @@ fn build_snapshot( .map_err(|_| "GitHub pull request snapshot normalization failed".to_owned()) } +fn normalized_enum(value: String, allowed: &[&str]) -> Result { + let normalized = value.to_ascii_lowercase(); + allowed + .contains(&normalized.as_str()) + .then_some(normalized) + .ok_or_else(|| "GitHub pull request enum was invalid".to_owned()) +} + fn same_semantics(before: &Value, after: &Value) -> bool { let (Some(before), Some(after)) = (before.as_object(), after.as_object()) else { return false; @@ -459,28 +612,66 @@ fn topics(previous: Option<&Value>, current: &Value) -> Vec { .collect() } -fn facet_facts(previous: Option<&Value>, current: &Value) -> Vec { - [ - ("facets.ciFailure", "ciFailure"), - ("facets.mergeConflict", "mergeConflict"), - ("facets.reviewRequested", "reviewRequested"), - ("facets.terminal", "terminal"), - ] - .into_iter() - .filter_map(|(key, facet)| { - let before = previous.and_then(|value| facet_value(value, facet)); - let after = facet_value(current, facet); - (before != after).then(|| provider_api::Fact { - key: key.into(), - before: before.map_or(provider_api::FactValue::Omitted, |value| { - provider_api::FactValue::Value(value.to_string()) - }), - after: after.map_or(provider_api::FactValue::Null, |value| { - provider_api::FactValue::Value(value.to_string()) - }), - }) - }) - .collect() +fn facts(previous: Option<&Value>, current: &Value, number: u64) -> Vec { + fn state(snapshot: &Value) -> Option<&str> { + let pull_request = snapshot.get("pullRequest")?; + if pull_request.get("merged").and_then(Value::as_bool) == Some(true) { + Some("merged") + } else if pull_request.get("state").and_then(Value::as_str) == Some("closed") { + Some("closed") + } else if pull_request.get("draft").and_then(Value::as_bool) == Some(true) { + Some("draft") + } else { + pull_request.get("state").and_then(Value::as_str) + } + } + fn ci(snapshot: &Value) -> Option<&str> { + if snapshot + .get("facets") + .and_then(|facets| facets.get("ciFailure")) + .and_then(Value::as_bool) + == Some(true) + { + Some("failure") + } else { + snapshot + .get("ci") + .and_then(|ci| ci.get("state")) + .and_then(Value::as_str) + } + } + let current_state = state(current).unwrap_or("unknown"); + let current_ci = ci(current).unwrap_or("unknown"); + let mut facts = vec![current_fact("pr", format!("#{number}"))]; + match previous { + Some(previous) => { + facts.push(transition_fact("state", state(previous), current_state)); + facts.push(transition_fact("ci", ci(previous), current_ci)); + } + None => { + facts.push(current_fact("state", current_state)); + facts.push(current_fact("ci", current_ci)); + } + } + facts +} + +fn current_fact(key: &str, value: impl Into) -> provider_api::Fact { + provider_api::Fact { + key: key.into(), + before: provider_api::FactValue::Omitted, + after: provider_api::FactValue::Value(value.into()), + } +} + +fn transition_fact(key: &str, before: Option<&str>, after: &str) -> provider_api::Fact { + provider_api::Fact { + key: key.into(), + before: before.map_or(provider_api::FactValue::Null, |value| { + provider_api::FactValue::Value(value.into()) + }), + after: provider_api::FactValue::Value(after.into()), + } } fn facet_value(snapshot: &Value, facet: &str) -> Option { @@ -490,17 +681,25 @@ fn facet_value(snapshot: &Value, facet: &str) -> Option { .and_then(Value::as_bool) } -fn valid_head_sha(value: &str) -> bool { - value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +fn valid_component(value: &str, maximum: usize) -> bool { + !value.is_empty() + && value.len() <= maximum + && !matches!(value, "." | "..") + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) } -fn null() -> Value { - Value::Null +fn valid_head_sha(value: &str) -> bool { + value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) } fn map_source_error(error: github_pr::PullRequestError) -> String { match error { github_pr::PullRequestError::Denied => "GitHub pull request scope denied", + github_pr::PullRequestError::AuthenticationRequired => { + "GitHub authentication is unavailable" + } github_pr::PullRequestError::Unavailable => "GitHub is unavailable", github_pr::PullRequestError::ResourceExhausted => "GitHub response exceeded limits", github_pr::PullRequestError::DeadlineExceeded => "GitHub request deadline exceeded", diff --git a/components/pty-stats/src/lib.rs b/components/pty-stats/src/lib.rs index c032d3e4..8642524c 100644 --- a/components/pty-stats/src/lib.rs +++ b/components/pty-stats/src/lib.rs @@ -1,4 +1,6 @@ -use serde::Serialize; +use std::collections::BTreeSet; + +use serde_json::{Map, Number, Value}; use sha2::{Digest as _, Sha256}; wit_bindgen::generate!({ @@ -9,64 +11,50 @@ wit_bindgen::generate!({ }, }); -use compoundingtech::st2_pty_stats::pty_stats; +use compoundingtech::st2_pty_stats::pty_stats as host; use exports::st2::resource_provider::provider_api; +const SNAPSHOT_SCHEMA: &str = "dev.schickling.pty.snapshot.v1"; +const MAX_SNAPSHOT_BYTES: usize = 1024 * 1024; +const TOPICS: [&str; 3] = ["lifecycle", "metadata", "runtime"]; const SELECTOR_SCHEMA: &str = r#"{ "type": "object", "properties": { - "session": { "type": "string" }, "topics": { "type": "array", - "items": { "type": "string" }, + "items": { "type": "string", "enum": ["lifecycle", "metadata", "runtime"] }, + "minItems": 1, "uniqueItems": true } }, + "required": ["topics"], "additionalProperties": false }"#; +const DEFAULT_SELECTOR: &str = r#"{"topics":["lifecycle","metadata"]}"#; struct Component; #[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(deny_unknown_fields)] struct Selector { - #[serde(default)] - session: Option, - #[serde(default)] topics: Vec, } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct Carrier<'a> { - resource: &'static str, - scope: Scope<'a>, - stats: &'a serde_json::Value, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -enum Scope<'a> { - All, - Session(&'a str), -} - impl provider_api::Guest for Component { fn describe() -> Result { Ok(provider_api::ProviderDescriptor { capabilities: vec![provider_api::SchedulingCapability::Demand], selector_schema_json: SELECTOR_SCHEMA.into(), - default_selector_json: "{}".into(), - topics: vec!["stats".into()], + default_selector_json: DEFAULT_SELECTOR.into(), + topics: TOPICS.into_iter().map(str::to_owned).collect(), snapshot_media_type: "application/json".into(), - snapshot_schema_id: "st2.resource.pty-stats.v1".into(), + snapshot_schema_id: SNAPSHOT_SCHEMA.into(), }) } fn observe(request: provider_api::ObserveRequest) -> provider_api::ObservationResult { - observe(request).unwrap_or_else(|diagnostic| { - provider_api::ObservationResult::Failed(Some(diagnostic)) - }) + observe(request) + .unwrap_or_else(|diagnostic| provider_api::ObservationResult::Failed(Some(diagnostic))) } } @@ -74,67 +62,476 @@ fn observe( request: provider_api::ObserveRequest, ) -> Result { let selector: Selector = serde_json::from_str(&request.selector_json) - .map_err(|_| "invalid PTY stats selector".to_owned())?; - if selector.session.as_deref().is_some_and(str::is_empty) { - return Err("PTY session scope must be non-empty".into()); + .map_err(|_| "invalid PTY selector".to_owned())?; + validate_topics(&selector.topics)?; + let id = parse_uri(&request.uri).ok_or_else(|| "invalid PTY URI".to_owned())?; + + let observation = host::list_session(id).map_err(map_source_error)?; + let mut current = observation.current; + if current.id != id { + return Err("PTY list returned a different session identity".into()); } - let scope = selector.session.as_ref().map_or(pty_stats::Scope::All, |session| { - pty_stats::Scope::Session(session.clone()) - }); - let outcome = pty_stats::get(&scope).map_err(map_source_error)?; - if outcome.stdout_truncated || outcome.stderr_truncated { - return Err("PTY stats output exceeded limits".into()); - } - match outcome.exit { - pty_stats::ExitStatus::Code(0) => {} - pty_stats::ExitStatus::Code(_) | pty_stats::ExitStatus::Signal(_) => { - return Ok(provider_api::ObservationResult::Failed(Some( - "pty stats exited unsuccessfully".into(), - ))); + let previous = observation.previous.filter(|source| source.id == id); + + if matches!(¤t.lifecycle, host::Lifecycle::Running) { + current = host::stats(id).map_err(map_source_error)?; + if current.id != id { + return Err("PTY stats returned a different session identity".into()); } } - let stats: serde_json::Value = serde_json::from_slice(&outcome.stdout) - .map_err(|_| "pty stats returned invalid JSON".to_owned())?; - let carrier_scope = selector - .session - .as_deref() - .map_or(Scope::All, Scope::Session); - let scope_fact = selector.session.clone().unwrap_or_else(|| "all".into()); - let bytes = serde_json::to_vec(&Carrier { - resource: "pty-stats", - scope: carrier_scope, - stats: &stats, - }) - .map_err(|_| "PTY stats normalization failed".to_owned())?; - let digest = Sha256::digest(&bytes); - if request.prior_digest.as_deref() == Some(digest.as_slice()) { + + let current_snapshot = build_snapshot(&request.uri, id, ¤t)?; + let previous_snapshot = previous + .as_ref() + .map(|source| build_snapshot(&request.uri, id, source)) + .transpose()?; + let same_payload_semantics = previous_snapshot + .as_ref() + .is_some_and(|prior| same_semantics(prior, ¤t_snapshot)); + if same_payload_semantics { return Ok(provider_api::ObservationResult::Unchanged); } - let _ = (request.uri, request.demand_watermark, selector.topics); + + let topics = changed_topics(previous_snapshot.as_ref(), ¤t_snapshot); + let publication_snapshot = if same_payload_semantics { + previous_snapshot.expect("the semantically equal previous PTY snapshot was present") + } else { + current_snapshot + }; + let bytes = serde_json::to_vec(&publication_snapshot) + .map_err(|_| "PTY snapshot normalization failed".to_owned())?; + if bytes.len() > MAX_SNAPSHOT_BYTES { + return Err("PTY snapshot exceeded limits".into()); + } + let digest = Sha256::digest(&bytes); + host::bind_snapshot(digest.as_slice()).map_err(map_source_error)?; + + let topics = if same_payload_semantics { + Vec::new() + } else { + topics + }; + let facts = facts(previous.as_ref(), ¤t, id); + let _ = (request.prior_digest, request.demand_watermark); Ok(provider_api::ObservationResult::Published( provider_api::Publication { - schema_id: "st2.resource.pty-stats.v1".into(), + schema_id: SNAPSHOT_SCHEMA.into(), media_type: "application/json".into(), bytes, - topics: vec!["stats".into()], - facts: Some(vec![provider_api::Fact { - key: "scope".into(), - before: provider_api::FactValue::Omitted, - after: provider_api::FactValue::Value(scope_fact), - }]), + topics, + facts: Some(facts), }, )) } -fn map_source_error(error: pty_stats::PtyStatsError) -> String { +fn validate_topics(topics: &[String]) -> Result, String> { + let selected: BTreeSet<_> = topics.iter().map(String::as_str).collect(); + if topics.is_empty() + || selected.len() != topics.len() + || selected.iter().any(|topic| !TOPICS.contains(topic)) + { + return Err("invalid PTY selector topics".into()); + } + Ok(selected) +} + +fn parse_uri(uri: &str) -> Option<&str> { + let id = uri.strip_prefix("pty:")?; + if id.is_empty() + || id.len() > 255 + || matches!(id, "." | "..") + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return None; + } + Some(id) +} + +fn build_snapshot(uri: &str, id: &str, source: &host::SessionSource) -> Result { + let mut snapshot = Map::new(); + snapshot.insert("schema".into(), Value::String(SNAPSHOT_SCHEMA.into())); + snapshot.insert("uri".into(), Value::String(uri.into())); + snapshot.insert( + "observedAt".into(), + Value::String(source.observed_at.clone()), + ); + snapshot.insert("id".into(), Value::String(id.into())); + snapshot.insert( + "lifecycle".into(), + Value::String(lifecycle_str(&source.lifecycle).into()), + ); + snapshot.insert( + "generation".into(), + source + .generation + .as_ref() + .map_or(Value::Null, generation_value), + ); + snapshot.insert( + "metadata".into(), + source.metadata.as_ref().map_or(Value::Null, metadata_value), + ); + snapshot.insert( + "runtime".into(), + source + .runtime + .as_ref() + .map_or(Ok(Value::Null), runtime_value)?, + ); + Ok(Value::Object(snapshot)) +} + +fn generation_value(generation: &host::Generation) -> Value { + match generation { + host::Generation::Number(number) => Value::Number((*number).into()), + host::Generation::Timestamp(timestamp) => Value::String(timestamp.clone()), + } +} + +fn metadata_value(metadata: &host::Metadata) -> Value { + let mut value = Map::new(); + insert_string(&mut value, "displayName", metadata.display_name.as_ref()); + insert_string(&mut value, "command", metadata.command.as_ref()); + insert_string(&mut value, "cwd", metadata.cwd.as_ref()); + insert_string(&mut value, "createdAt", metadata.created_at.as_ref()); + if let Some(exit_code) = metadata.exit_code { + value.insert("exitCode".into(), Value::Number(exit_code.into())); + } + insert_string(&mut value, "exitedAt", metadata.exited_at.as_ref()); + if let Some(tags) = &metadata.tags { + let tags = tags + .iter() + .map(|tag| (tag.key.clone(), Value::String(tag.value.clone()))) + .collect(); + value.insert("tags".into(), Value::Object(tags)); + } + Value::Object(value) +} + +fn runtime_value(runtime: &host::Runtime) -> Result { + let terminal = &runtime.terminal; + let process = &runtime.process; + let clients = &runtime.clients; + let modes = &runtime.modes; + let mut process_value = Map::new(); + process_value.insert("alive".into(), Value::Bool(process.alive)); + process_value.insert( + "exitCode".into(), + process + .exit_code + .map_or(Value::Null, |code| Value::Number(code.into())), + ); + if let Some(resources) = &process.resources { + let cpu = Number::from_f64(resources.cpu_percent) + .ok_or_else(|| "PTY stats returned a non-finite CPU percentage".to_owned())?; + process_value.insert( + "resources".into(), + Value::Object(Map::from_iter([ + ("rssKb".into(), Value::Number(resources.rss_kb.into())), + ("cpuPercent".into(), Value::Number(cpu)), + ])), + ); + } else { + process_value.insert("resources".into(), Value::Null); + } + Ok(Value::Object(Map::from_iter([ + ( + "terminal".into(), + Value::Object(Map::from_iter([ + ("cols".into(), Value::Number(terminal.cols.into())), + ("rows".into(), Value::Number(terminal.rows.into())), + ("cursorX".into(), Value::Number(terminal.cursor_x.into())), + ("cursorY".into(), Value::Number(terminal.cursor_y.into())), + ( + "scrollbackUsed".into(), + Value::Number(terminal.scrollback_used.into()), + ), + ( + "scrollbackCapacity".into(), + Value::Number(terminal.scrollback_capacity.into()), + ), + ])), + ), + ("process".into(), Value::Object(process_value)), + ( + "clients".into(), + Value::Object(Map::from_iter([ + ("total".into(), Value::Number(clients.total.into())), + ("attached".into(), Value::Number(clients.attached.into())), + ("readOnly".into(), Value::Number(clients.read_only.into())), + ])), + ), + ( + "modes".into(), + Value::Object(Map::from_iter([ + ("sgrMouse".into(), Value::Bool(modes.sgr_mouse)), + ("cursorHidden".into(), Value::Bool(modes.cursor_hidden)), + ("kittyKeyboard".into(), Value::Bool(modes.kitty_keyboard)), + ( + "kittyKeyboardFlags".into(), + Value::Array( + modes + .kitty_keyboard_flags + .iter() + .map(|flag| Value::Number((*flag).into())) + .collect(), + ), + ), + ])), + ), + ( + "uptimeSeconds".into(), + runtime + .uptime_seconds + .map_or(Value::Null, |seconds| Value::Number(seconds.into())), + ), + ]))) +} + +fn insert_string(map: &mut Map, key: &str, value: Option<&String>) { + if let Some(value) = value { + map.insert(key.into(), Value::String(value.clone())); + } +} + +fn same_semantics(previous: &Value, current: &Value) -> bool { + semantic_projection(previous) == semantic_projection(current) +} + +fn semantic_projection(snapshot: &Value) -> Value { + let mut projected = snapshot.clone(); + if let Some(object) = projected.as_object_mut() { + object.remove("observedAt"); + } + projected +} + +fn changed_topics(previous: Option<&Value>, current: &Value) -> Vec { + let Some(previous) = previous else { + return TOPICS.into_iter().map(str::to_owned).collect(); + }; + TOPICS + .into_iter() + .filter(|topic| match *topic { + "lifecycle" => { + previous.get("lifecycle") != current.get("lifecycle") + || previous.get("generation") != current.get("generation") + } + "metadata" => previous.get("metadata") != current.get("metadata"), + "runtime" => previous.get("runtime") != current.get("runtime"), + _ => false, + }) + .map(str::to_owned) + .collect() +} + +fn facts( + previous: Option<&host::SessionSource>, + current: &host::SessionSource, + id: &str, +) -> Vec { + let mut facts = vec![current_fact("session", id)]; + facts.push(match previous { + Some(previous) => transition_fact( + "state", + Some(lifecycle_str(&previous.lifecycle)), + lifecycle_str(¤t.lifecycle), + ), + None => current_fact("state", lifecycle_str(¤t.lifecycle)), + }); + if let Some(exit) = exit_code(current) { + facts.push(match previous { + Some(previous) => transition_fact("exit", exit_code(previous).as_deref(), &exit), + None => current_fact("exit", &exit), + }); + } + facts +} + +fn exit_code(source: &host::SessionSource) -> Option { + source + .metadata + .as_ref() + .and_then(|metadata| metadata.exit_code) + .map(|code| code.to_string()) +} + +fn current_fact(key: &str, value: &str) -> provider_api::Fact { + provider_api::Fact { + key: key.into(), + before: provider_api::FactValue::Omitted, + after: provider_api::FactValue::Value(value.into()), + } +} + +fn transition_fact(key: &str, before: Option<&str>, after: &str) -> provider_api::Fact { + provider_api::Fact { + key: key.into(), + before: before.map_or(provider_api::FactValue::Null, |value| { + provider_api::FactValue::Value(value.into()) + }), + after: provider_api::FactValue::Value(after.into()), + } +} + +const fn lifecycle_str(lifecycle: &host::Lifecycle) -> &'static str { + match lifecycle { + host::Lifecycle::Running => "running", + host::Lifecycle::Exited => "exited", + host::Lifecycle::Vanished => "vanished", + host::Lifecycle::Absent => "absent", + } +} + +fn map_source_error(error: host::PtyStatsError) -> String { match error { - pty_stats::PtyStatsError::Denied => "PTY stats scope denied", - pty_stats::PtyStatsError::Unavailable => "PTY stats is unavailable", - pty_stats::PtyStatsError::ResourceExhausted => "PTY stats output exceeded limits", - pty_stats::PtyStatsError::DeadlineExceeded => "PTY stats deadline exceeded", - pty_stats::PtyStatsError::Cancelled => "PTY stats was cancelled", + host::PtyStatsError::Denied => "PTY session request denied", + host::PtyStatsError::Unavailable => "PTY control plane is unavailable", + host::PtyStatsError::ResourceExhausted => "PTY control-plane output exceeded limits", + host::PtyStatsError::DeadlineExceeded => "PTY control-plane deadline exceeded", + host::PtyStatsError::Cancelled => "PTY control-plane observation was cancelled", } .into() } +#[cfg(test)] +mod tests { + use super::*; + + fn source(lifecycle: host::Lifecycle) -> host::SessionSource { + host::SessionSource { + id: "session-1".into(), + observed_at: "2026-09-02T10:00:00Z".into(), + lifecycle, + generation: Some(host::Generation::Timestamp("created".into())), + metadata: Some(host::Metadata { + display_name: Some("Session".into()), + command: Some("agent".into()), + cwd: Some("/workspace".into()), + created_at: Some("created".into()), + exit_code: None, + exited_at: None, + tags: Some(vec![host::Tag { + key: "owner".into(), + value: "agent".into(), + }]), + }), + runtime: Some(host::Runtime { + terminal: host::Terminal { + cols: 120, + rows: 40, + cursor_x: 10, + cursor_y: 4, + scrollback_used: 20, + scrollback_capacity: 1_000, + }, + process: host::Process { + alive: true, + exit_code: None, + resources: None, + }, + clients: host::Clients { + total: 2, + attached: 1, + read_only: 1, + }, + modes: host::Modes { + sgr_mouse: true, + cursor_hidden: false, + kitty_keyboard: true, + kitty_keyboard_flags: vec![1, 2], + }, + uptime_seconds: Some(60), + }), + } + } + + #[test] + fn canonical_uri_validation_rejects_aliases_and_paths() { + assert_eq!(parse_uri("pty:stable.session-1"), Some("stable.session-1")); + for uri in [ + "pty:", + "pty://stable.session-1", + "pty:../session", + "pty:..", + "pty:display name", + "pty:stable%2Esession", + "PTY:stable.session-1", + ] { + assert_eq!(parse_uri(uri), None); + } + } + + #[test] + fn selectors_are_nonempty_unique_and_closed() { + assert!(validate_topics(&["lifecycle".into(), "metadata".into()]).is_ok()); + assert!(validate_topics(&[]).is_err()); + assert!(validate_topics(&["runtime".into(), "runtime".into()]).is_err()); + assert!(validate_topics(&["transcript".into()]).is_err()); + } + + #[test] + fn metadata_snapshot_is_transcript_and_process_identity_free() { + let snapshot = build_snapshot( + "pty:session-1", + "session-1", + &source(host::Lifecycle::Running), + ) + .unwrap(); + let bytes = serde_json::to_string(&snapshot).unwrap(); + assert!(bytes.contains("\"displayName\":\"Session\"")); + for forbidden in ["transcript", "screen", "lastLines", "pid", "args", "socket"] { + assert!(!bytes.contains(forbidden)); + } + } + + #[test] + fn selector_topics_do_not_change_the_canonical_carrier() { + fn carrier(topics: &[String], source: &host::SessionSource) -> (Vec, Vec) { + validate_topics(topics).unwrap(); + let snapshot = build_snapshot("pty:session-1", "session-1", source).unwrap(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let digest = Sha256::digest(&bytes).to_vec(); + (bytes, digest) + } + + let source = source(host::Lifecycle::Running); + let lifecycle = carrier(&["lifecycle".into()], &source); + let runtime = carrier(&["metadata".into(), "runtime".into()], &source); + assert_eq!(lifecycle, runtime); + + let snapshot = build_snapshot("pty:session-1", "session-1", &source).unwrap(); + assert_eq!(snapshot["lifecycle"], "running"); + assert!(snapshot.get("metadata").is_some_and(Value::is_object)); + assert!(snapshot.get("runtime").is_some_and(Value::is_object)); + assert_eq!( + changed_topics(None, &snapshot), + ["lifecycle", "metadata", "runtime"] + ); + } + + #[test] + fn source_topics_and_facts_are_not_filtered_by_selector() { + validate_topics(&["metadata".into()]).unwrap(); + let before = source(host::Lifecycle::Running); + let after = source(host::Lifecycle::Exited); + let before_snapshot = build_snapshot("pty:session-1", "session-1", &before).unwrap(); + let after_snapshot = build_snapshot("pty:session-1", "session-1", &after).unwrap(); + assert!(!same_semantics(&before_snapshot, &after_snapshot)); + assert_eq!( + changed_topics(Some(&before_snapshot), &after_snapshot), + ["lifecycle"] + ); + let facts = facts(Some(&before), &after, "session-1"); + assert!( + matches!(&facts[1].before, provider_api::FactValue::Value(value) if value == "running") + ); + assert!( + matches!(&facts[1].after, provider_api::FactValue::Value(value) if value == "exited") + ); + } +} + export!(Component); diff --git a/components/vista/src/lib.rs b/components/vista/src/lib.rs index 5abcc220..dfb6cd30 100644 --- a/components/vista/src/lib.rs +++ b/components/vista/src/lib.rs @@ -14,31 +14,27 @@ use exports::st2::resource_provider::provider_api; const SNAPSHOT_SCHEMA: &str = "dev.schickling.vista.snapshot.v1"; const MAX_SNAPSHOT_BYTES: usize = 1024 * 1024; -const MAX_VERSION: u64 = 9_007_199_254_740_991; +const MAX_VERSION: u64 = 9_999_999_999_999_999_999; const TOPICS: [&str; 4] = ["ready", "updated", "failed", "expired"]; const SELECTOR_SCHEMA: &str = r#"{ "type": "object", "properties": { - "slug": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, - "version": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "topics": { "type": "array", - "items": { "type": "string", "enum": ["ready", "updated", "failed", "expired"] }, + "items": { "type": "string" }, "uniqueItems": true } }, - "required": ["slug", "version"], + "required": ["topics"], "additionalProperties": false }"#; +const DEFAULT_SELECTOR: &str = r#"{"topics":["ready","updated","failed","expired"]}"#; struct Component; #[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(deny_unknown_fields)] struct Selector { - slug: String, - version: u64, - #[serde(default)] topics: Vec, } @@ -60,7 +56,7 @@ impl ArtifactState { } } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct StatusCounts { locked: u64, @@ -68,7 +64,7 @@ struct StatusCounts { awaiting: u64, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct ArtifactManifest { schema_version: u8, @@ -90,10 +86,16 @@ struct ArtifactManifest { status: Option, } +struct NormalizedSource { + observed_at: String, + artifact: ArtifactManifest, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct Snapshot<'a> { schema: &'static str, + observed_at: &'a str, #[serde(flatten)] artifact: &'a ArtifactManifest, } @@ -103,7 +105,7 @@ impl provider_api::Guest for Component { Ok(provider_api::ProviderDescriptor { capabilities: vec![provider_api::SchedulingCapability::Demand], selector_schema_json: SELECTOR_SCHEMA.into(), - default_selector_json: "{}".into(), + default_selector_json: DEFAULT_SELECTOR.into(), topics: TOPICS.into_iter().map(str::to_owned).collect(), snapshot_media_type: "application/json".into(), snapshot_schema_id: SNAPSHOT_SCHEMA.into(), @@ -111,9 +113,8 @@ impl provider_api::Guest for Component { } fn observe(request: provider_api::ObserveRequest) -> provider_api::ObservationResult { - observe(request).unwrap_or_else(|diagnostic| { - provider_api::ObservationResult::Failed(Some(diagnostic)) - }) + observe(request) + .unwrap_or_else(|diagnostic| provider_api::ObservationResult::Failed(Some(diagnostic))) } } @@ -122,29 +123,21 @@ fn observe( ) -> Result { let selector: Selector = serde_json::from_str(&request.selector_json) .map_err(|_| "invalid Vista selector".to_owned())?; - let Some((uri_slug, uri_version)) = parse_uri(&request.uri) else { + if !valid_topics(&selector.topics) { + return Err("invalid Vista selector".into()); + } + let Some((slug, version)) = parse_uri(&request.uri) else { return Err("invalid Vista URI".into()); }; - if !valid_slug(&selector.slug) - || !(1..=MAX_VERSION).contains(&selector.version) - || selector.slug != uri_slug - || selector.version != uri_version - { - return Err("Vista selector does not match URI identity".into()); - } - - let outcome = vista::get(&vista::ArtifactRequest { - slug: selector.slug.clone(), - version: selector.version, + let response = vista::get(&vista::ArtifactRequest { + slug: slug.to_owned(), + version, }) .map_err(map_source_error)?; - if outcome.stdout_truncated || outcome.stderr_truncated { - return Err("Vista output exceeded limits".into()); - } - match outcome.exit { - vista::ExitStatus::Code(0) => {} - vista::ExitStatus::Code(_) | vista::ExitStatus::Signal(_) => { - let detail = bounded_detail(&outcome.stderr); + let observation = match response { + vista::ArtifactResponse::Ok(observation) => observation, + vista::ArtifactResponse::CommandFailed(failure) => { + let detail = bounded_detail(&failure.stderr); let diagnostic = if detail.is_empty() { "vista artifact get exited unsuccessfully".into() } else { @@ -152,52 +145,74 @@ fn observe( }; return Ok(provider_api::ObservationResult::Failed(Some(diagnostic))); } - } + }; - let artifact: ArtifactManifest = serde_json::from_slice(&outcome.stdout) - .map_err(|error| format!("vista returned invalid manifest: {error}"))?; - if artifact.schema_version != 1 - || artifact.uri != request.uri - || artifact.slug != selector.slug - || artifact.version != selector.version + let current = normalize_source(&request.uri, slug, version, observation.current)?; + let previous = observation + .previous + .map(|source| normalize_source(&request.uri, slug, version, source)) + .transpose()?; + if previous + .as_ref() + .is_some_and(|previous| previous.artifact == current.artifact) { - return Err("vista returned a different artifact identity".into()); + let prior_digest = request + .prior_digest + .as_deref() + .ok_or_else(|| "Vista prior source lacked a snapshot digest".to_owned())?; + vista::bind_snapshot(prior_digest).map_err(map_source_error)?; + return Ok(provider_api::ObservationResult::Unchanged); } + let publication_topics = topics(previous.as_ref(), ¤t); + let publication_facts = facts(previous.as_ref(), ¤t, slug, version)?; - let state = artifact.state.as_str(); let bytes = serde_json::to_vec(&Snapshot { schema: SNAPSHOT_SCHEMA, - artifact: &artifact, + observed_at: ¤t.observed_at, + artifact: ¤t.artifact, }) .map_err(|_| "Vista snapshot normalization failed".to_owned())?; if bytes.len() > MAX_SNAPSHOT_BYTES { return Err("Vista snapshot exceeded limits".into()); } let digest = Sha256::digest(&bytes); + vista::bind_snapshot(digest.as_slice()).map_err(map_source_error)?; if request.prior_digest.as_deref() == Some(digest.as_slice()) { return Ok(provider_api::ObservationResult::Unchanged); } - let topics = if request.prior_digest.is_some() { - vec!["updated".into(), state.into()] - } else { - vec![state.into()] - }; - let _ = (request.demand_watermark, selector.topics); + let _ = request.demand_watermark; Ok(provider_api::ObservationResult::Published( provider_api::Publication { schema_id: SNAPSHOT_SCHEMA.into(), media_type: "application/json".into(), bytes, - topics, - facts: Some(vec![provider_api::Fact { - key: "state".into(), - before: provider_api::FactValue::Omitted, - after: provider_api::FactValue::Value(state.into()), - }]), + topics: publication_topics, + facts: Some(publication_facts), }, )) } +fn normalize_source( + uri: &str, + slug: &str, + version: u64, + source: vista::SourceSnapshot, +) -> Result { + let artifact: ArtifactManifest = serde_json::from_slice(&source.manifest_json) + .map_err(|error| format!("vista returned invalid manifest: {error}"))?; + if artifact.schema_version != 1 + || artifact.uri != uri + || artifact.slug != slug + || artifact.version != version + { + return Err("vista returned a different artifact identity".into()); + } + Ok(NormalizedSource { + observed_at: source.observed_at, + artifact, + }) +} + fn parse_uri(uri: &str) -> Option<(&str, u64)> { let subject = uri.strip_prefix("vista://")?; let (slug, version) = subject.split_once('/')?; @@ -205,14 +220,16 @@ fn parse_uri(uri: &str) -> Option<(&str, u64)> { if version.contains('/') || !valid_slug(slug) || digits.is_empty() - || digits.len() > 16 + || digits.len() > 19 || digits.starts_with('0') || !digits.bytes().all(|byte| byte.is_ascii_digit()) { return None; } let version = digits.parse::().ok()?; - (1..=MAX_VERSION).contains(&version).then_some((slug, version)) + (1..=MAX_VERSION) + .contains(&version) + .then_some((slug, version)) } fn valid_slug(slug: &str) -> bool { @@ -225,6 +242,91 @@ fn valid_slug(slug: &str) -> bool { && !slug.contains("--") } +fn valid_topics(topics: &[String]) -> bool { + topics + .iter() + .enumerate() + .all(|(index, topic)| TOPICS.contains(&topic.as_str()) && !topics[..index].contains(topic)) +} + +fn topics(previous: Option<&NormalizedSource>, current: &NormalizedSource) -> Vec { + let state = current.artifact.state.as_str(); + let Some(previous) = previous else { + return vec![state.into()]; + }; + if previous.artifact == current.artifact { + return Vec::new(); + } + let mut changed = vec!["updated".into()]; + if previous.artifact.state != current.artifact.state { + changed.push(state.into()); + } + changed +} + +fn facts( + previous: Option<&NormalizedSource>, + current: &NormalizedSource, + slug: &str, + version: u64, +) -> Result, String> { + let current_state = current.artifact.state.as_str(); + let current_blocks = block_count(current)?; + let mut facts = vec![current_fact("artifact", format!("{slug}/v{version}"))]; + match previous { + Some(previous) => facts.push(transition_fact( + "state", + Some(previous.artifact.state.as_str()), + current_state, + )), + None => facts.push(current_fact("state", current_state)), + } + if let Some(current_blocks) = current_blocks { + match previous { + Some(previous) => { + let previous_blocks = block_count(previous)?; + facts.push(transition_fact( + "blocks", + previous_blocks.as_deref(), + ¤t_blocks, + )); + } + None => facts.push(current_fact("blocks", current_blocks)), + } + } + Ok(facts) +} + +fn block_count(source: &NormalizedSource) -> Result, String> { + let Some(status) = source.artifact.status.as_ref() else { + return Ok(None); + }; + let total = status + .locked + .checked_add(status.open) + .and_then(|total| total.checked_add(status.awaiting)) + .ok_or_else(|| "Vista status block count exceeds u64::MAX".to_owned())?; + Ok(Some(total.to_string())) +} + +fn current_fact(key: &str, value: impl Into) -> provider_api::Fact { + provider_api::Fact { + key: key.into(), + before: provider_api::FactValue::Omitted, + after: provider_api::FactValue::Value(value.into()), + } +} + +fn transition_fact(key: &str, before: Option<&str>, after: &str) -> provider_api::Fact { + provider_api::Fact { + key: key.into(), + before: before.map_or(provider_api::FactValue::Null, |value| { + provider_api::FactValue::Value(value.into()) + }), + after: provider_api::FactValue::Value(after.into()), + } +} + fn bounded_detail(stderr: &[u8]) -> String { const MAX_DETAIL: usize = 4096; String::from_utf8_lossy(&stderr[..stderr.len().min(MAX_DETAIL)]) @@ -234,7 +336,7 @@ fn bounded_detail(stderr: &[u8]) -> String { fn map_source_error(error: vista::VistaError) -> String { match error { - vista::VistaError::Denied => "Vista artifact scope denied", + vista::VistaError::Denied => "Vista artifact request denied", vista::VistaError::Unavailable => "Vista is unavailable", vista::VistaError::ResourceExhausted => "Vista output exceeded limits", vista::VistaError::DeadlineExceeded => "Vista deadline exceeded", @@ -247,16 +349,41 @@ fn map_source_error(error: vista::VistaError) -> String { mod tests { use super::*; + fn source(state: ArtifactState, title: &str, status: Option) -> NormalizedSource { + NormalizedSource { + observed_at: "2026-09-02T10:00:00Z".into(), + artifact: ArtifactManifest { + schema_version: 1, + uri: "vista://release/v7".into(), + slug: "release".into(), + version: 7, + author: "agent".into(), + timestamp: "2026-09-02T09:00:00Z".into(), + change_summary: "created".into(), + parent: Some(6), + retired: false, + state, + canonical_url: "https://vista.example/release/v7".into(), + title: Some(title.into()), + template: Some("architecture-review".into()), + status, + }, + } + } + #[test] - fn vista_version_matches_the_javascript_safe_integer_contract() { + fn vista_uri_accepts_the_canonical_nineteen_digit_contract() { + assert_eq!( + parse_uri("vista://release-notes/v9999999999999999999"), + Some(("release-notes", MAX_VERSION)) + ); assert_eq!( - parse_uri("vista://release-notes/v9007199254740991"), - Some(("release-notes", 9_007_199_254_740_991)) + parse_uri("vista://release-notes/v9007199254740992"), + Some(("release-notes", 9_007_199_254_740_992)) ); for invalid in [ "vista://release-notes/v0", "vista://release-notes/v01", - "vista://release-notes/v9007199254740992", "vista://release-notes/v10000000000000000000", "vista://-release/v1", "vista://release-/v1", @@ -267,12 +394,38 @@ mod tests { ] { assert_eq!(parse_uri(invalid), None, "{invalid}"); } + } + #[test] + fn source_identity_must_match_the_canonical_request_uri() { + let manifest_json = + serde_json::to_vec(&source(ArtifactState::Ready, "Architecture", None).artifact) + .unwrap(); + let make_source = || vista::SourceSnapshot { + manifest_json: manifest_json.clone(), + observed_at: "2026-09-02T10:00:00Z".into(), + }; + assert!(normalize_source("vista://release/v7", "release", 7, make_source(),).is_ok()); + assert!(normalize_source("vista://other/v7", "other", 7, make_source(),).is_err()); + } + + #[test] + fn selector_contains_topics_only_and_defaults_to_all_topics() { let schema: serde_json::Value = serde_json::from_str(SELECTOR_SCHEMA).unwrap(); + assert_eq!(schema["required"], serde_json::json!(["topics"])); assert_eq!( - schema["properties"]["version"]["maximum"], - serde_json::json!(9_007_199_254_740_991_u64) + schema["properties"] + .as_object() + .unwrap() + .keys() + .collect::>(), + ["topics"] ); + let default: Selector = serde_json::from_str(DEFAULT_SELECTOR).unwrap(); + assert_eq!(default.topics, TOPICS.map(str::to_owned)); + assert!(valid_topics(&TOPICS.map(str::to_owned))); + assert!(!valid_topics(&["ready".into(), "ready".into()])); + assert!(!valid_topics(&["unknown".into()])); } #[test] @@ -293,6 +446,108 @@ mod tests { }"#; assert!(serde_json::from_slice::(unknown).is_err()); } + + #[test] + fn snapshot_flattens_the_strict_manifest_with_observed_at() { + let source = source(ArtifactState::Ready, "Architecture", None); + let snapshot = serde_json::to_value(Snapshot { + schema: SNAPSHOT_SCHEMA, + observed_at: &source.observed_at, + artifact: &source.artifact, + }) + .unwrap(); + assert_eq!(snapshot["schema"], SNAPSHOT_SCHEMA); + assert_eq!(snapshot["observedAt"], "2026-09-02T10:00:00Z"); + assert_eq!(snapshot["uri"], "vista://release/v7"); + assert_eq!(snapshot["state"], "ready"); + assert!(snapshot.get("artifact").is_none()); + assert!(snapshot.get("status").is_none()); + } + + #[test] + fn snapshot_topics_and_facts_match_manifest_transitions() { + let previous = source( + ArtifactState::Ready, + "Architecture", + Some(StatusCounts { + locked: 3, + open: 1, + awaiting: 2, + }), + ); + let current = source( + ArtifactState::Ready, + "Architecture corrected", + Some(StatusCounts { + locked: 3, + open: 2, + awaiting: 2, + }), + ); + assert_eq!(topics(Some(&previous), ¤t), ["updated"]); + let facts = facts(Some(&previous), ¤t, "release", 7).unwrap(); + assert_eq!(facts.len(), 3); + assert_eq!(facts[0].key, "artifact"); + assert!(matches!( + &facts[0].after, + provider_api::FactValue::Value(value) if value == "release/v7" + )); + assert_eq!(facts[1].key, "state"); + assert!(matches!( + (&facts[1].before, &facts[1].after), + ( + provider_api::FactValue::Value(before), + provider_api::FactValue::Value(after) + ) if before == "ready" && after == "ready" + )); + assert_eq!(facts[2].key, "blocks"); + assert!(matches!( + (&facts[2].before, &facts[2].after), + ( + provider_api::FactValue::Value(before), + provider_api::FactValue::Value(after) + ) if before == "6" && after == "7" + )); + } + + #[test] + fn topics_emit_current_state_first_and_only_changed_state_later() { + let ready = source(ArtifactState::Ready, "Architecture", None); + let same = source(ArtifactState::Ready, "Architecture", None); + let failed = source(ArtifactState::Failed, "Architecture", None); + let expired = source(ArtifactState::Expired, "Architecture", None); + assert_eq!(topics(None, &ready), ["ready"]); + assert!(topics(Some(&ready), &same).is_empty()); + assert_eq!(topics(Some(&ready), &failed), ["updated", "failed"]); + assert_eq!(topics(None, &expired), ["expired"]); + } + + #[test] + fn block_count_overflow_is_atomic_failure() { + let overflowing = source( + ArtifactState::Ready, + "Architecture", + Some(StatusCounts { + locked: u64::MAX, + open: 1, + awaiting: 0, + }), + ); + assert_eq!( + block_count(&overflowing), + Err("Vista status block count exceeds u64::MAX".into()) + ); + let maximum = source( + ArtifactState::Ready, + "Architecture", + Some(StatusCounts { + locked: u64::MAX, + open: 0, + awaiting: 0, + }), + ); + assert_eq!(block_count(&maximum), Ok(Some(u64::MAX.to_string()))); + } } export!(Component); diff --git a/crates/st2-resource-providers/src/github_auth.rs b/crates/st2-resource-providers/src/github_auth.rs new file mode 100644 index 00000000..39f1ec17 --- /dev/null +++ b/crates/st2-resource-providers/src/github_auth.rs @@ -0,0 +1,292 @@ +use std::io::{self, Read as _}; +use std::os::fd::AsRawFd as _; +use std::os::unix::process::CommandExt as _; +use std::path::Path; +use std::process::{Child, ChildStdout, Command, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; + +use reqwest::header::HeaderValue; + +const MAX_TOKEN_BYTES: usize = 4096; + +pub(crate) fn discover_authorization(executable: &Path, deadline: Instant) -> Option { + if Instant::now() >= deadline { + return None; + } + let mut command = Command::new(executable); + command + .args(["auth", "token", "--hostname", "github.com"]) + .env("GH_PROMPT_DISABLED", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .process_group(0); + let child = command.spawn().ok()?; + let mut child = OwnedChild::new(child).ok()?; + let mut stdout = child.child.stdout.take()?; + let (status, token) = collect_token(&mut child, &mut stdout, deadline)?; + if !status.success() { + return None; + } + authorization_header(token) +} + +fn collect_token( + child: &mut OwnedChild, + stdout: &mut ChildStdout, + deadline: Instant, +) -> Option<(ExitStatus, SecretBytes)> { + let mut token = SecretBytes(Vec::with_capacity(128)); + let mut status = None; + let mut stdout_closed = false; + let mut buffer = SecretBuffer([0_u8; 1024]); + + loop { + if Instant::now() >= deadline { + child.terminate_and_reap(); + return None; + } + if status.is_none() { + status = child.try_wait().ok()?; + if status.is_some() { + // The direct child is reaped by try_wait. Terminate any descendants that retained + // stdout so the dedicated process group cannot outlive credential discovery. + child.terminate_group(); + } + } + if stdout_closed { + if let Some(status) = status { + return Some((status, token)); + } + std::thread::sleep( + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_millis(1)), + ); + continue; + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + let timeout_ms = i32::try_from(remaining.as_millis()).unwrap_or(i32::MAX); + let mut descriptor = libc::pollfd { + fd: stdout.as_raw_fd(), + events: libc::POLLIN | libc::POLLHUP | libc::POLLERR, + revents: 0, + }; + // SAFETY: descriptor points to one valid pollfd for the duration of this call. + let polled = unsafe { libc::poll(&mut descriptor, 1, timeout_ms) }; + if polled < 0 { + if io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + return None; + } + if polled == 0 { + continue; + } + if descriptor.revents & libc::POLLNVAL != 0 { + return None; + } + if descriptor.revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) == 0 { + continue; + } + + let read = if token.0.len() == MAX_TOKEN_BYTES { + let mut overflow = SecretBuffer([0_u8; 1]); + match stdout.read(&mut overflow.0) { + Ok(0) => { + stdout_closed = true; + continue; + } + Ok(_) => { + child.terminate_and_reap(); + return None; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => return None, + } + } else { + let available = (MAX_TOKEN_BYTES - token.0.len()).min(buffer.0.len()); + match stdout.read(&mut buffer.0[..available]) { + Ok(0) => { + stdout_closed = true; + continue; + } + Ok(read) => read, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => return None, + } + }; + token.0.extend_from_slice(&buffer.0[..read]); + } +} + +fn authorization_header(token: SecretBytes) -> Option { + let token_text = std::str::from_utf8(&token.0).ok()?; + let token_text = token_text.trim(); + if token_text.is_empty() { + return None; + } + let mut header = SecretBytes(Vec::with_capacity("Bearer ".len() + token_text.len())); + header.0.extend_from_slice(b"Bearer "); + header.0.extend_from_slice(token_text.as_bytes()); + let mut value = HeaderValue::from_bytes(&header.0).ok()?; + value.set_sensitive(true); + Some(value) +} + +struct SecretBytes(Vec); + +impl Drop for SecretBytes { + fn drop(&mut self) { + self.0.fill(0); + } +} + +struct SecretBuffer([u8; N]); + +impl Drop for SecretBuffer { + fn drop(&mut self) { + self.0.fill(0); + } +} + +struct OwnedChild { + child: Child, + process_group: i32, + reaped: bool, +} + +impl OwnedChild { + fn new(mut child: Child) -> io::Result { + let process_group = match i32::try_from(child.id()) { + Ok(process_group) => process_group, + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(io::Error::other("child pid did not fit in pid_t")); + } + }; + Ok(Self { + child, + process_group, + reaped: false, + }) + } + + fn try_wait(&mut self) -> io::Result> { + let status = self.child.try_wait()?; + if status.is_some() { + self.reaped = true; + } + Ok(status) + } + + fn terminate_group(&self) { + // SAFETY: a negative pid addresses the dedicated process group created by process_group. + let _ = unsafe { libc::kill(-self.process_group, libc::SIGKILL) }; + } + + fn terminate_and_reap(&mut self) { + self.terminate_group(); + while !self.reaped { + match self.child.wait() { + Ok(_) => self.reaped = true, + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(_) => break, + } + } + } +} + +impl Drop for OwnedChild { + fn drop(&mut self) { + self.terminate_and_reap(); + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::os::unix::fs::PermissionsExt as _; + use std::path::PathBuf; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn discovers_a_sensitive_authorization_header() { + let (_temporary, executable) = executable_fixture("printf '%s\\n' 'fixture-token'"); + let authorization = + discover_authorization(&executable, Instant::now() + Duration::from_secs(5)).unwrap(); + + assert!(authorization.is_sensitive()); + assert_eq!(authorization.as_bytes(), b"Bearer fixture-token"); + } + + #[test] + fn rejects_oversized_and_failed_credentials() { + let oversized = format!("printf '{}'", "x".repeat(MAX_TOKEN_BYTES + 1)); + let (_temporary, executable) = executable_fixture(&oversized); + assert!( + discover_authorization(&executable, Instant::now() + Duration::from_secs(5)).is_none() + ); + + let (_temporary, executable) = executable_fixture("printf '%s\\n' 'ignored-token'; exit 7"); + assert!( + discover_authorization(&executable, Instant::now() + Duration::from_secs(5)).is_none() + ); + } + + #[test] + fn deadline_terminates_the_owned_process_group() { + let (_temporary, executable) = executable_fixture( + r#" +( + trap '' TERM + while :; do sleep 10; done +) & +descendant=$! +printf '%s %s\n' "$$" "$descendant" > "$0.pids" +wait "$descendant" +"#, + ); + let started = Instant::now(); + assert!( + discover_authorization(&executable, started + Duration::from_millis(200),).is_none() + ); + assert!(started.elapsed() < Duration::from_secs(1)); + + let pids = fs::read_to_string(format!("{}.pids", executable.display())).unwrap(); + let pids = pids + .split_whitespace() + .map(|pid| pid.parse::().unwrap()) + .collect::>(); + let cleanup_deadline = Instant::now() + Duration::from_secs(1); + while pids.iter().copied().any(process_is_running) && Instant::now() < cleanup_deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(!pids.iter().copied().any(process_is_running)); + } + + fn executable_fixture(body: &str) -> (TempDir, PathBuf) { + let temporary = tempfile::tempdir().unwrap(); + let executable = temporary.path().join("gh"); + fs::write(&executable, format!("#!/bin/sh\nset -eu\n{body}\n")).unwrap(); + let mut permissions = fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&executable, permissions).unwrap(); + (temporary, executable) + } + + fn process_is_running(pid: i32) -> bool { + let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) else { + return false; + }; + stat.rsplit_once(") ") + .and_then(|(_, fields)| fields.chars().next()) + .is_some_and(|state| !matches!(state, 'Z' | 'X')) + } +} diff --git a/crates/st2-resource-providers/src/github_issue.rs b/crates/st2-resource-providers/src/github_issue.rs index cd14eeca..0eba885a 100644 --- a/crates/st2-resource-providers/src/github_issue.rs +++ b/crates/st2-resource-providers/src/github_issue.rs @@ -1,14 +1,20 @@ use std::collections::BTreeMap; -use std::net::{IpAddr, SocketAddr, ToSocketAddrs as _}; +use std::net::{IpAddr, SocketAddr}; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; +use chrono::{SecondsFormat, Utc}; +use reqwest::header::HeaderValue; +use serde::{Deserialize, Serialize}; use st2_resource_wasip2::{ CapabilityContext, CapabilityModule, CapabilityPhase, InterruptionReason, InvocationControl, InvocationStore, }; use wasmtime::component::{HasSelf, Linker}; +use crate::github_auth::discover_authorization; + mod bindings { wasmtime::component::bindgen!({ path: "../../wit/github-issue", @@ -17,29 +23,29 @@ mod bindings { } use bindings::compoundingtech::st2_github_issue::github_issue::{ - Host, IssueError, IssueRequest, IssueResponse, + Host, IssueError, IssueRequest, IssueResponse, SourceObject, SourceObservation, SourceSnapshot, }; const IMPORT_NAME: &str = "compoundingtech:st2-github-issue/github-issue@0.1.0"; const API_HOST: &str = "api.github.com"; const API_PORT: u16 = 443; const MAX_HEADERS_BYTES: usize = 16 * 1024; -const MAX_BODY_BYTES: usize = 256 * 1024; -const MAX_ETAG_BYTES: usize = 1024; +const MAX_SOURCE_BYTES: usize = 4 * 1024 * 1024; +const MAX_ETAG_BYTES: usize = 512; +const SNAPSHOT_DIGEST_BYTES: usize = 32; +const MAX_CACHED_SNAPSHOTS: usize = 16; #[derive(Debug, Clone, PartialEq, Eq)] pub struct GitHubIssueConfig { - pub owner: String, - pub repo: String, - pub number: u64, + pub auth_executable: PathBuf, pub connect_timeout: Duration, pub total_timeout: Duration, } impl GitHubIssueConfig { pub fn validate(&self) -> Result<(), &'static str> { - if !valid_slug(&self.owner) || !valid_slug(&self.repo) || self.number == 0 { - return Err("GitHub issue scope is invalid"); + if !self.auth_executable.is_absolute() { + return Err("GitHub authentication executable must be absolute"); } if self.connect_timeout.is_zero() || self.total_timeout.is_zero() @@ -55,36 +61,63 @@ impl GitHubIssueConfig { #[derive(Clone)] pub struct GitHubIssueModule { config: GitHubIssueConfig, - cache: Arc>>, + authorization: Option, + cache: Arc>, } impl GitHubIssueModule { pub fn new(config: GitHubIssueConfig) -> Result { config.validate()?; + let authorization = Instant::now() + .checked_add(config.total_timeout) + .and_then(|deadline| discover_authorization(&config.auth_executable, deadline)); Ok(Self { config, - cache: Arc::new(Mutex::new(BTreeMap::new())), + authorization, + cache: Arc::new(Mutex::new(SnapshotCache::default())), }) } } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct IssueKey { - owner: String, - repo: String, - number: u64, -} - #[derive(Debug, Clone)] -struct CachedIssue { +struct CachedObject { etag: Option, body: Vec, } +#[derive(Debug, Clone)] +struct CachedSource { + issue: CachedObject, + latest_comment: Option, + observed_at: String, +} + +#[derive(Default)] +struct SnapshotCache { + sources: BTreeMap<[u8; SNAPSHOT_DIGEST_BYTES], CachedSource>, +} + +impl SnapshotCache { + fn get(&self, digest: &[u8; SNAPSHOT_DIGEST_BYTES]) -> Option<&CachedSource> { + self.sources.get(digest) + } + + fn insert(&mut self, digest: [u8; SNAPSHOT_DIGEST_BYTES], source: CachedSource) { + if !self.sources.contains_key(&digest) && self.sources.len() >= MAX_CACHED_SNAPSHOTS { + if let Some(evicted) = self.sources.keys().next().copied() { + self.sources.remove(&evicted); + } + } + self.sources.insert(digest, source); + } +} + pub struct GitHubIssueInvocation { config: GitHubIssueConfig, - cache: Arc>>, - has_authoritative_prior: bool, + authorization: Option, + cache: Arc>, + prior_digest: Option<[u8; SNAPSHOT_DIGEST_BYTES]>, + current_source: Option, control: InvocationControl, } @@ -103,14 +136,19 @@ impl CapabilityModule for GitHubIssueModule { } fn begin(&self, context: CapabilityContext<'_>) -> Self::Invocation { - let has_authoritative_prior = match context.phase() { - CapabilityPhase::Describe => false, - CapabilityPhase::Observe(request) => request.prior_digest.is_some(), + let prior_digest = match context.phase() { + CapabilityPhase::Describe => None, + CapabilityPhase::Observe(request) => request + .prior_digest + .as_ref() + .map(|digest| *digest.as_bytes()), }; GitHubIssueInvocation { config: self.config.clone(), + authorization: self.authorization.clone(), cache: Arc::clone(&self.cache), - has_authoritative_prior, + prior_digest, + current_source: None, control: context.control().clone(), } } @@ -120,61 +158,143 @@ impl Host for InvocationStore { fn get(&mut self, request: IssueRequest) -> Result { self.capability_mut().get(request) } + + fn bind_snapshot(&mut self, digest: Vec) -> Result<(), IssueError> { + self.capability_mut().bind_snapshot(digest) + } } impl GitHubIssueInvocation { fn get(&mut self, request: IssueRequest) -> Result { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|_| IssueError::Unavailable)?; - runtime.block_on(self.get_async(request)) + run_on_runtime(self.get_async(request)) } async fn get_async(&mut self, request: IssueRequest) -> Result { - if !request_matches_scope(&self.config, &request) { + if !valid_request(&request) { return Err(IssueError::Denied); } - let key = IssueKey { - owner: request.owner, - repo: request.repo, - number: request.number, + if let Some(reason) = self.control.interruption_reason() { + return Err(interruption_error(reason)); + } + let prior = match self.prior_digest.as_ref() { + Some(digest) => self + .cache + .lock() + .map_err(|_| IssueError::Unavailable)? + .get(digest) + .cloned(), + None => None, }; - let cached = self - .cache - .lock() - .map_err(|_| IssueError::Unavailable)? - .get(&key) - .cloned(); - let requested_etag = request.etag; - let reused_cached_entry = self.has_authoritative_prior - && requested_etag.is_none() - && cached.as_ref().is_some_and(|entry| entry.etag.is_some()); - let etag = conditional_etag( - self.has_authoritative_prior, - requested_etag, - cached.as_ref().and_then(|entry| entry.etag.clone()), - ); - let endpoint = format!( - "https://{API_HOST}/repos/{}/{}/issues/{}", - key.owner, key.repo, key.number - ); - let address = resolve_public_api_address()?; + let deadline = Instant::now() + .checked_add(self.config.total_timeout) + .ok_or(IssueError::DeadlineExceeded)?; + let address = resolve_public_api_address(&self.control, deadline).await?; let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .connect_timeout(self.config.connect_timeout) - .timeout(self.config.total_timeout) .gzip(true) .resolve(API_HOST, address) .build() .map_err(|_| IssueError::Unavailable)?; + let issue_endpoint = format!( + "https://{API_HOST}/repos/{}/{}/issues/{}", + request.owner, request.repo, request.number + ); + let issue = self + .fetch_object( + &client, + issue_endpoint, + prior.as_ref().map(|source| &source.issue), + deadline, + ) + .await?; + let metadata: IssueMetadata = + serde_json::from_slice(&issue.object.body).map_err(|_| IssueError::Unavailable)?; + + let latest_comment = if metadata.comments == 0 { + None + } else { + let cached = prior.as_ref().and_then(|source| { + let previous: IssueMetadata = serde_json::from_slice(&source.issue.body).ok()?; + (previous.comments == metadata.comments) + .then_some(source.latest_comment.as_ref()) + .flatten() + }); + let endpoint = format!( + "https://{API_HOST}/repos/{}/{}/issues/{}/comments?per_page=1&page={}", + request.owner, request.repo, request.number, metadata.comments + ); + let mut fetched = self + .fetch_object(&client, endpoint, cached, deadline) + .await?; + fetched.object.body = normalize_latest_comment(&fetched.object.body)?; + Some(fetched) + }; + + if !issue.modified + && latest_comment + .as_ref() + .is_none_or(|comment| !comment.modified) + { + return Ok(IssueResponse::NotModified); + } + let latest_object = latest_comment.map(|comment| comment.object); + let observed_at = prior + .as_ref() + .filter(|prior| { + prior.issue.body == issue.object.body + && prior.latest_comment.as_ref().map(|object| &object.body) + == latest_object.as_ref().map(|object| &object.body) + }) + .map_or_else( + || Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + |prior| prior.observed_at.clone(), + ); + let source = CachedSource { + issue: issue.object, + latest_comment: latest_object, + observed_at, + }; + let current = source_to_wit(&source); + self.current_source = Some(source); + Ok(IssueResponse::Ok(SourceObservation { + current, + previous: prior.as_ref().map(source_to_wit), + })) + } + + fn bind_snapshot(&mut self, digest: Vec) -> Result<(), IssueError> { + let digest: [u8; SNAPSHOT_DIGEST_BYTES] = + digest.try_into().map_err(|_| IssueError::Denied)?; + let source = self.current_source.take().ok_or(IssueError::Unavailable)?; + self.cache + .lock() + .map_err(|_| IssueError::Unavailable)? + .insert(digest, source); + Ok(()) + } + + async fn fetch_object( + &self, + client: &reqwest::Client, + endpoint: String, + cached: Option<&CachedObject>, + deadline: Instant, + ) -> Result { + if let Some(reason) = self.control.interruption_reason() { + return Err(interruption_error(reason)); + } let mut builder = client .get(endpoint) + .timeout(remaining(deadline)?) .header("accept", "application/vnd.github+json") .header("x-github-api-version", "2022-11-28") - .header("user-agent", "st2-resource-provider"); - if let Some(etag) = etag.as_deref() { - builder = builder.header("if-none-match", etag); + .header("user-agent", "st2-github-resource-profile/1"); + if let Some(authorization) = self.authorization.clone() { + builder = builder.header(reqwest::header::AUTHORIZATION, authorization); + } + if let Some(etag) = cached.and_then(|object| object.etag.as_deref()) { + builder = builder.header(reqwest::header::IF_NONE_MATCH, etag); } let mut response = tokio::select! { biased; @@ -187,15 +307,7 @@ impl GitHubIssueInvocation { if status.is_redirection() && status.as_u16() != 304 { return Err(IssueError::Denied); } - let header_bytes = response.headers().iter().try_fold(0_usize, |total, (name, value)| { - total - .checked_add(name.as_str().len()) - .and_then(|total| total.checked_add(value.as_bytes().len())) - .ok_or(IssueError::ResourceExhausted) - })?; - if header_bytes > MAX_HEADERS_BYTES { - return Err(IssueError::ResourceExhausted); - } + validate_headers(response.headers())?; let response_etag = response .headers() .get(reqwest::header::ETAG) @@ -203,109 +315,169 @@ impl GitHubIssueInvocation { .filter(|value| valid_etag(value)) .map(str::to_owned); match status.as_u16() { - 304 => Ok(not_modified_response( - reused_cached_entry, - response_etag, - etag, - cached, - )), - 200 => { - let mut body = Vec::new(); - loop { - let chunk = tokio::select! { - biased; - reason = wait_for_interruption(&self.control) => { - return Err(interruption_error(reason)); - } - chunk = response.chunk() => chunk.map_err(map_transport_error)?, - }; - let Some(chunk) = chunk else { - break; - }; - if body.len().saturating_add(chunk.len()) > MAX_BODY_BYTES { - return Err(IssueError::ResourceExhausted); - } - body.extend_from_slice(&chunk); - } - self.cache - .lock() - .map_err(|_| IssueError::Unavailable)? - .insert( - key, - CachedIssue { - etag: response_etag.clone(), - body: body.clone(), - }, - ); - Ok(IssueResponse::Ok((response_etag, body))) - } + 304 => replay_not_modified(cached, response_etag), + 200 => Ok(FetchedObject { + object: CachedObject { + etag: response_etag, + body: read_body(&mut response, &self.control).await?, + }, + modified: true, + }), 401 | 403 | 404 => Err(IssueError::Denied), + 429 => Err(IssueError::ResourceExhausted), _ => Err(IssueError::Unavailable), } } } -async fn wait_for_interruption(control: &InvocationControl) -> InterruptionReason { - loop { - if let Some(reason) = control.interruption_reason() { - return reason; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } -} -fn interruption_error(reason: InterruptionReason) -> IssueError { - match reason { - InterruptionReason::Cancelled => IssueError::Unavailable, - InterruptionReason::TimedOut => IssueError::DeadlineExceeded, - } +#[derive(Deserialize)] +struct IssueMetadata { + comments: u64, } -fn request_matches_scope(config: &GitHubIssueConfig, request: &IssueRequest) -> bool { - request.owner == config.owner - && request.repo == config.repo - && request.number == config.number - && request.etag.as_ref().is_none_or(|etag| valid_etag(etag)) +#[derive(Deserialize, Serialize)] +struct CommentMetadata { + updated_at: String, } +fn normalize_latest_comment(body: &[u8]) -> Result, IssueError> { + let comments: Vec = + serde_json::from_slice(body).map_err(|_| IssueError::Unavailable)?; + let [comment] = comments.as_slice() else { + return Err(IssueError::Unavailable); + }; + serde_json::to_vec(&[comment]).map_err(|_| IssueError::Unavailable) +} -fn conditional_etag( - has_authoritative_prior: bool, - requested: Option, - cached: Option, -) -> Option { - has_authoritative_prior - .then(|| requested.or(cached)) - .flatten() +struct FetchedObject { + object: CachedObject, + modified: bool, } -fn not_modified_response( - reused_cached_entry: bool, +fn replay_not_modified( + cached: Option<&CachedObject>, response_etag: Option, - conditional_etag: Option, - cached: Option, -) -> IssueResponse { - let effective_etag = response_etag.or(conditional_etag); - if reused_cached_entry - && let Some(cached) = cached - && cached.etag == effective_etag - { - return IssueResponse::Ok((effective_etag, cached.body)); +) -> Result { + let cached = cached.ok_or(IssueError::Unavailable)?; + let effective_etag = response_etag.or_else(|| cached.etag.clone()); + if cached.etag != effective_etag { + return Err(IssueError::Unavailable); + } + Ok(FetchedObject { + object: cached.clone(), + modified: false, + }) +} + +fn source_to_wit(source: &CachedSource) -> SourceSnapshot { + SourceSnapshot { + issue: object_to_wit(&source.issue), + latest_comment: source.latest_comment.as_ref().map(object_to_wit), + observed_at: source.observed_at.clone(), + } +} + +fn object_to_wit(object: &CachedObject) -> SourceObject { + SourceObject { + etag: object.etag.clone(), + body: object.body.clone(), } - IssueResponse::NotModified(effective_etag) } -fn valid_slug(value: &str) -> bool { +fn run_on_runtime(future: impl Future>) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| IssueError::Unavailable)?; + runtime.block_on(future) +} + +fn valid_request(request: &IssueRequest) -> bool { + valid_component(&request.owner, 39) && valid_component(&request.repo, 100) && request.number > 0 +} + +fn valid_component(value: &str, maximum: usize) -> bool { !value.is_empty() - && value.len() <= 100 + && value.len() <= maximum + && !matches!(value, "." | "..") && value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) } fn valid_etag(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_ETAG_BYTES - && !value.bytes().any(|byte| byte == b'\r' || byte == b'\n' || byte == 0) + if value.len() > MAX_ETAG_BYTES { + return false; + } + let quoted = value.strip_prefix("W/").unwrap_or(value); + let Some(inner) = quoted + .strip_prefix('"') + .and_then(|quoted| quoted.strip_suffix('"')) + else { + return false; + }; + !inner.is_empty() + && inner.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'+' | b'-') + }) +} + +fn remaining(deadline: Instant) -> Result { + deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or(IssueError::DeadlineExceeded) +} + +fn validate_headers(headers: &reqwest::header::HeaderMap) -> Result<(), IssueError> { + let bytes = headers.iter().try_fold(0_usize, |total, (name, value)| { + total + .checked_add(name.as_str().len()) + .and_then(|total| total.checked_add(value.as_bytes().len())) + .ok_or(IssueError::ResourceExhausted) + })?; + if bytes > MAX_HEADERS_BYTES { + return Err(IssueError::ResourceExhausted); + } + Ok(()) +} + +async fn read_body( + response: &mut reqwest::Response, + control: &InvocationControl, +) -> Result, IssueError> { + let mut body = Vec::new(); + loop { + let chunk = tokio::select! { + biased; + reason = wait_for_interruption(control) => return Err(interruption_error(reason)), + chunk = response.chunk() => chunk.map_err(map_transport_error)?, + }; + let Some(chunk) = chunk else { + break; + }; + if body.len().saturating_add(chunk.len()) > MAX_SOURCE_BYTES { + return Err(IssueError::ResourceExhausted); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +async fn wait_for_interruption(control: &InvocationControl) -> InterruptionReason { + loop { + if let Some(reason) = control.interruption_reason() { + return reason; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +fn interruption_error(reason: InterruptionReason) -> IssueError { + match reason { + InterruptionReason::Cancelled => IssueError::Unavailable, + InterruptionReason::TimedOut => IssueError::DeadlineExceeded, + } } fn map_transport_error(error: reqwest::Error) -> IssueError { @@ -316,15 +488,34 @@ fn map_transport_error(error: reqwest::Error) -> IssueError { } } -fn resolve_public_api_address() -> Result { - let addresses = (API_HOST, API_PORT) - .to_socket_addrs() - .map_err(|_| IssueError::Unavailable)? - .collect::>(); - if addresses.is_empty() || addresses.iter().any(|address| !is_public(address.ip())) { +async fn resolve_public_api_address( + control: &InvocationControl, + deadline: Instant, +) -> Result { + let resolver = hickory_resolver::Resolver::builder_tokio() + .and_then(hickory_resolver::ResolverBuilder::build) + .map_err(|_| IssueError::Unavailable)?; + let lookup = tokio::select! { + biased; + reason = wait_for_interruption(control) => return Err(interruption_error(reason)), + result = await_dns_lookup(remaining(deadline)?, resolver.lookup_ip(API_HOST)) => result?, + }; + let mut addresses = lookup.iter(); + let first = addresses.next().ok_or(IssueError::Unavailable)?; + if !is_public(first) || addresses.any(|address| !is_public(address)) { return Err(IssueError::Denied); } - addresses.into_iter().next().ok_or(IssueError::Unavailable) + Ok(SocketAddr::new(first, API_PORT)) +} + +async fn await_dns_lookup( + timeout: Duration, + lookup: impl Future>, +) -> Result { + tokio::time::timeout(timeout, lookup) + .await + .map_err(|_| IssueError::DeadlineExceeded)? + .map_err(|_| IssueError::Unavailable) } fn is_public(address: IpAddr) -> bool { @@ -365,50 +556,78 @@ fn is_public(address: IpAddr) -> bool { mod tests { use super::*; - - fn live_config() -> GitHubIssueConfig { - GitHubIssueConfig { - owner: "rust-lang".into(), - repo: "rust".into(), - number: 1, - connect_timeout: Duration::from_secs(3), - total_timeout: Duration::from_secs(10), - } - } - #[test] - fn exact_scope_and_header_policy_deny_before_transport() { - let module = GitHubIssueModule::new(live_config()).unwrap(); + fn capability_accepts_dynamic_valid_subjects_and_rejects_invalid_components() { for request in [ IssueRequest { - owner: "other".into(), - repo: "rust".into(), + owner: "example".into(), + repo: "demo".into(), number: 1, - etag: None, }, IssueRequest { - owner: "rust-lang".into(), - repo: "other".into(), - number: 1, - etag: None, + owner: "other-owner".into(), + repo: "private.repo".into(), + number: 42, }, + ] { + assert!(valid_request(&request)); + } + for request in [ IssueRequest { - owner: "rust-lang".into(), - repo: "rust".into(), - number: 2, - etag: None, + owner: "..".into(), + repo: "demo".into(), + number: 1, }, IssueRequest { - owner: "rust-lang".into(), - repo: "rust".into(), + owner: "example".into(), + repo: "demo/path".into(), number: 1, - etag: Some("\"ok\"\r\nx-injected: true".into()), + }, + IssueRequest { + owner: "example".into(), + repo: "demo".into(), + number: 0, }, ] { - assert!(!request_matches_scope(&module.config, &request)); + assert!(!valid_request(&request)); } } + #[test] + fn etags_are_quoted_and_header_safe() { + assert!(valid_etag("\"issue-v1\"")); + assert!(valid_etag("W/\"comment/v1:2\"")); + assert!(!valid_etag("issue-v1")); + assert!(!valid_etag("\"bad header\"")); + assert!(!valid_etag("\"ok\"\r\nx-injected: true")); + } + + #[test] + fn latest_comment_source_exposes_only_updated_at_metadata() { + let normalized = normalize_latest_comment( + br#"[{"updated_at":"2026-08-30T11:22:33Z","body":"private discussion"}]"#, + ) + .unwrap(); + assert_eq!( + serde_json::from_slice::(&normalized).unwrap(), + serde_json::json!([{"updated_at": "2026-08-30T11:22:33Z"}]) + ); + } + + #[test] + fn not_modified_replays_only_the_exact_cached_etag_and_body() { + let cached = CachedObject { + etag: Some("\"issue-v1\"".into()), + body: br#"{"comments":2}"#.to_vec(), + }; + let replayed = replay_not_modified(Some(&cached), Some("\"issue-v1\"".into())).unwrap(); + assert!(!replayed.modified); + assert_eq!(replayed.object.etag, cached.etag); + assert_eq!(replayed.object.body, cached.body); + assert!(replay_not_modified(Some(&cached), Some("\"issue-v2\"".into())).is_err()); + assert!(replay_not_modified(None, Some("\"issue-v1\"".into())).is_err()); + } + #[test] fn private_special_and_documentation_addresses_are_never_admitted() { for address in [ @@ -430,49 +649,4 @@ mod tests { assert!(is_public("8.8.8.8".parse().unwrap())); assert!(is_public("2606:4700:4700::1111".parse().unwrap())); } - - #[test] - fn deadlines_are_bounded_and_ordered() { - let mut config = live_config(); - config.connect_timeout = Duration::from_secs(11); - assert!(config.validate().is_err()); - config.connect_timeout = Duration::from_secs(1); - config.total_timeout = Duration::from_secs(61); - assert!(config.validate().is_err()); - } - - #[test] - fn shared_runtime_only_reuses_an_etag_for_a_binding_with_prior_state() { - assert_eq!(conditional_etag(false, None, Some("\"cached\"".into())), None); - assert_eq!( - conditional_etag(true, None, Some("\"cached\"".into())), - Some("\"cached\"".into()) - ); - } - - #[test] - fn shared_runtime_304_replays_cached_body_to_an_older_binding() { - let newest_body = br#"{"title":"new"}"#.to_vec(); - let cached = CachedIssue { - etag: Some("\"new\"".into()), - body: newest_body.clone(), - }; - let response = not_modified_response( - true, - None, - Some("\"new\"".into()), - Some(cached), - ); - let IssueResponse::Ok((etag, body)) = response else { - panic!("shared cached revalidation must return the exact cached body"); - }; - assert_eq!(etag.as_deref(), Some("\"new\"")); - assert_eq!(body, newest_body); - assert_ne!( - st2_resource_protocol::SnapshotDigest::of(b"{\"title\":\"old\"}"), - st2_resource_protocol::SnapshotDigest::of(&body), - "the guest can compare and publish the newer body for the skewed binding" - ); - } - } diff --git a/crates/st2-resource-providers/src/github_pr.rs b/crates/st2-resource-providers/src/github_pr.rs index e0b4d811..c334281f 100644 --- a/crates/st2-resource-providers/src/github_pr.rs +++ b/crates/st2-resource-providers/src/github_pr.rs @@ -1,17 +1,20 @@ use std::collections::BTreeMap; -use std::future::Future; use std::net::{IpAddr, SocketAddr}; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use chrono::{SecondsFormat, Utc}; -use serde::Deserialize; +use reqwest::header::HeaderValue; +use serde_json::{Value, json}; use st2_resource_wasip2::{ CapabilityContext, CapabilityModule, CapabilityPhase, InterruptionReason, InvocationControl, InvocationStore, }; use wasmtime::component::{HasSelf, Linker}; +use crate::github_auth::discover_authorization; + mod bindings { wasmtime::component::bindgen!({ path: "../../wit/github-pr", @@ -20,35 +23,87 @@ mod bindings { } use bindings::compoundingtech::st2_github_pr::github_pr::{ - Host, PullRequestError, PullRequestRequest, PullRequestResponse, SourceObject, - SourceObservation, SourceSnapshot, + Host, PullRequestError, PullRequestRequest, SourceObservation, SourceSnapshot, }; const IMPORT_NAME: &str = "compoundingtech:st2-github-pr/github-pr@0.1.0"; const API_HOST: &str = "api.github.com"; const API_PORT: u16 = 443; const MAX_HEADERS_BYTES: usize = 16 * 1024; -const MAX_SOURCE_BYTES: usize = 1024 * 1024; -const MAX_ETAG_BYTES: usize = 1024; +const MAX_SOURCE_BYTES: usize = 4 * 1024 * 1024; const SNAPSHOT_DIGEST_BYTES: usize = 32; const MAX_CACHED_SNAPSHOTS: usize = 16; +const PULL_REQUEST_QUERY: &str = r#"query PullRequestObservation($owner: String!, $repository: String!, $number: Int!) { + repository(owner: $owner, name: $repository) { + pullRequest(number: $number) { + url + title + body + state + isDraft + merged + mergedAt + closedAt + mergeable + author { login } + headRefOid + headRefName + baseRefName + reviewDecision + reviewRequests(first: 100) { + totalCount + nodes { + requestedReviewer { + __typename + ... on User { login } + ... on Team { slug } + ... on Bot { login } + ... on Mannequin { login } + } + } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + contexts(first: 100) { + totalCount + nodes { + __typename + ... on CheckRun { + name + status + conclusion + detailsUrl + } + ... on StatusContext { + context + state + targetUrl + description + } + } + } + } + } + } + } + } + } +}"#; #[derive(Debug, Clone, PartialEq, Eq)] pub struct GitHubPrConfig { - pub owner: String, - pub repo: String, - pub number: u64, + pub auth_executable: PathBuf, pub connect_timeout: Duration, pub total_timeout: Duration, } impl GitHubPrConfig { pub fn validate(&self) -> Result<(), &'static str> { - if !valid_component(&self.owner, 39) - || !valid_component(&self.repo, 100) - || self.number == 0 - { - return Err("GitHub pull request scope is invalid"); + if !self.auth_executable.is_absolute() { + return Err("GitHub authentication executable must be absolute"); } if self.connect_timeout.is_zero() || self.total_timeout.is_zero() @@ -64,37 +119,27 @@ impl GitHubPrConfig { #[derive(Clone)] pub struct GitHubPrModule { config: GitHubPrConfig, + authorization: Option, cache: Arc>, } impl GitHubPrModule { pub fn new(config: GitHubPrConfig) -> Result { config.validate()?; + let authorization = Instant::now() + .checked_add(config.total_timeout) + .and_then(|deadline| discover_authorization(&config.auth_executable, deadline)); Ok(Self { config, + authorization, cache: Arc::new(Mutex::new(SnapshotCache::default())), }) } } -#[derive(Debug, Clone)] -struct PullRequestKey { - owner: String, - repo: String, - number: u64, -} - -#[derive(Debug, Clone)] -struct CachedObject { - etag: Option, - body: Vec, -} - #[derive(Debug, Clone)] struct CachedSource { - pull_request: CachedObject, - check_runs: CachedObject, - combined_status: CachedObject, + graphql_data: Vec, observed_at: String, } @@ -110,7 +155,7 @@ impl SnapshotCache { fn insert(&mut self, digest: [u8; SNAPSHOT_DIGEST_BYTES], source: CachedSource) { if !self.sources.contains_key(&digest) && self.sources.len() >= MAX_CACHED_SNAPSHOTS { - if let Some(evicted) = self.sources.keys().next().cloned() { + if let Some(evicted) = self.sources.keys().next().copied() { self.sources.remove(&evicted); } } @@ -120,6 +165,7 @@ impl SnapshotCache { pub struct GitHubPrInvocation { config: GitHubPrConfig, + authorization: Option, cache: Arc>, prior_digest: Option<[u8; SNAPSHOT_DIGEST_BYTES]>, current_source: Option, @@ -143,12 +189,14 @@ impl CapabilityModule for GitHubPrModule { fn begin(&self, context: CapabilityContext<'_>) -> Self::Invocation { let prior_digest = match context.phase() { CapabilityPhase::Describe => None, - CapabilityPhase::Observe(request) => { - request.prior_digest.as_ref().map(|digest| *digest.as_bytes()) - } + CapabilityPhase::Observe(request) => request + .prior_digest + .as_ref() + .map(|digest| *digest.as_bytes()), }; GitHubPrInvocation { config: self.config.clone(), + authorization: self.authorization.clone(), cache: Arc::clone(&self.cache), prior_digest, current_source: None, @@ -158,10 +206,7 @@ impl CapabilityModule for GitHubPrModule { } impl Host for InvocationStore { - fn get( - &mut self, - request: PullRequestRequest, - ) -> Result { + fn get(&mut self, request: PullRequestRequest) -> Result { self.capability_mut().get(request) } @@ -171,28 +216,24 @@ impl Host for InvocationStore { } impl GitHubPrInvocation { - fn get( - &mut self, - request: PullRequestRequest, - ) -> Result { + fn get(&mut self, request: PullRequestRequest) -> Result { run_on_runtime(self.get_async(request)) } async fn get_async( &mut self, request: PullRequestRequest, - ) -> Result { - if !request_matches_scope(&self.config, &request) { + ) -> Result { + if !valid_request(&request) { return Err(PullRequestError::Denied); } + let authorization = self + .authorization + .clone() + .ok_or(PullRequestError::AuthenticationRequired)?; if let Some(reason) = self.control.interruption_reason() { return Err(interruption_error(reason)); } - let key = PullRequestKey { - owner: request.owner, - repo: request.repo, - number: request.number, - }; let prior = match self.prior_digest.as_ref() { Some(digest) => self .cache @@ -202,6 +243,7 @@ impl GitHubPrInvocation { .cloned(), None => None, }; + let number = i32::try_from(request.number).map_err(|_| PullRequestError::Denied)?; let deadline = Instant::now() .checked_add(self.config.total_timeout) .ok_or(PullRequestError::DeadlineExceeded)?; @@ -213,93 +255,83 @@ impl GitHubPrInvocation { .resolve(API_HOST, address) .build() .map_err(|_| PullRequestError::Unavailable)?; - - let pull_endpoint = format!( - "https://{API_HOST}/repos/{}/{}/pulls/{}", - key.owner, key.repo, key.number - ); - let pull_request = self - .fetch_object( - &client, - pull_endpoint, - prior.as_ref().map(|source| &source.pull_request), - deadline, - MAX_SOURCE_BYTES, - ) - .await?; - let pull: PullRequestHead = serde_json::from_slice(&pull_request.object.body) - .map_err(|_| PullRequestError::Unavailable)?; - if pull.number != key.number || !valid_head_sha(&pull.head.sha) { + let remaining = remaining(deadline)?; + let body = serde_json::to_vec(&json!({ + "query": PULL_REQUEST_QUERY, + "variables": { + "owner": request.owner, + "repository": request.repo, + "number": number, + } + })) + .map_err(|_| PullRequestError::Unavailable)?; + let builder = client + .post(format!("https://{API_HOST}/graphql")) + .timeout(remaining) + .header("accept", "application/vnd.github+json") + .header("content-type", "application/json") + .header("x-github-api-version", "2022-11-28") + .header("user-agent", "st2-github-resource-profile/1") + .header(reqwest::header::AUTHORIZATION, authorization) + .body(body); + let mut response = tokio::select! { + biased; + reason = wait_for_interruption(&self.control) => { + return Err(interruption_error(reason)); + } + response = builder.send() => response.map_err(map_transport_error)?, + }; + let status = response.status(); + if status.is_redirection() { return Err(PullRequestError::Denied); } - - let mut remaining = MAX_SOURCE_BYTES - .checked_sub(pull_request.object.body.len()) - .ok_or(PullRequestError::ResourceExhausted)?; - let checks_endpoint = format!( - "https://{API_HOST}/repos/{}/{}/commits/{}/check-runs?per_page=100", - key.owner, key.repo, pull.head.sha - ); - let check_runs = require_complete( - self.fetch_object( - &client, - checks_endpoint, - prior.as_ref().map(|source| &source.check_runs), - deadline, - remaining, - ) - .await?, - )?; - remaining = remaining - .checked_sub(check_runs.object.body.len()) - .ok_or(PullRequestError::ResourceExhausted)?; - let status_endpoint = format!( - "https://{API_HOST}/repos/{}/{}/commits/{}/status?per_page=100", - key.owner, key.repo, pull.head.sha - ); - let combined_status = require_complete( - self.fetch_object( - &client, - status_endpoint, - prior.as_ref().map(|source| &source.combined_status), - deadline, - remaining, - ) - .await?, - )?; - - if !pull_request.modified && !check_runs.modified && !combined_status.modified { - return Ok(PullRequestResponse::NotModified); + validate_headers(response.headers())?; + match status.as_u16() { + 200 => {} + 401 | 403 => return Err(PullRequestError::AuthenticationRequired), + 404 => return Err(PullRequestError::Denied), + 429 => return Err(PullRequestError::ResourceExhausted), + _ => return Err(PullRequestError::Unavailable), + } + let bytes = read_body(&mut response, &self.control, MAX_SOURCE_BYTES).await?; + let mut envelope: Value = + serde_json::from_slice(&bytes).map_err(|_| PullRequestError::Unavailable)?; + let envelope = envelope + .as_object_mut() + .ok_or(PullRequestError::Unavailable)?; + if let Some(errors) = envelope.get("errors") { + let errors = errors.as_array().ok_or(PullRequestError::Unavailable)?; + if !errors.is_empty() { + return Err(PullRequestError::Unavailable); + } } + let data = envelope + .remove("data") + .filter(Value::is_object) + .ok_or(PullRequestError::Unavailable)?; + let graphql_data = serde_json::to_vec(&data).map_err(|_| PullRequestError::Unavailable)?; let observed_at = prior .as_ref() - .filter(|prior| { - prior.pull_request.body == pull_request.object.body - && prior.check_runs.body == check_runs.object.body - && prior.combined_status.body == combined_status.object.body - }) + .filter(|prior| prior.graphql_data == graphql_data) .map_or_else( || Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), |prior| prior.observed_at.clone(), ); let source = CachedSource { - pull_request: pull_request.object, - check_runs: check_runs.object, - combined_status: combined_status.object, + graphql_data, observed_at, }; let current = source_to_wit(&source); self.current_source = Some(source); - Ok(PullRequestResponse::Ok(SourceObservation { + Ok(SourceObservation { current, previous: prior.as_ref().map(source_to_wit), - })) + }) } fn bind_snapshot(&mut self, digest: Vec) -> Result<(), PullRequestError> { - let digest: [u8; SNAPSHOT_DIGEST_BYTES] = digest - .try_into() - .map_err(|_| PullRequestError::Denied)?; + let digest: [u8; SNAPSHOT_DIGEST_BYTES] = + digest.try_into().map_err(|_| PullRequestError::Denied)?; let source = self .current_source .take() @@ -310,122 +342,11 @@ impl GitHubPrInvocation { .insert(digest, source); Ok(()) } - - async fn fetch_object( - &self, - client: &reqwest::Client, - endpoint: String, - cached: Option<&CachedObject>, - deadline: Instant, - max_body_bytes: usize, - ) -> Result { - if max_body_bytes == 0 { - return Err(PullRequestError::ResourceExhausted); - } - if let Some(reason) = self.control.interruption_reason() { - return Err(interruption_error(reason)); - } - let remaining = deadline - .checked_duration_since(Instant::now()) - .filter(|remaining| !remaining.is_zero()) - .ok_or(PullRequestError::DeadlineExceeded)?; - let builder = request_builder(client, endpoint, remaining, cached); - let mut response = tokio::select! { - biased; - reason = wait_for_interruption(&self.control) => { - return Err(interruption_error(reason)); - } - response = builder.send() => response.map_err(map_transport_error)?, - }; - let status = response.status(); - if status.is_redirection() && status.as_u16() != 304 { - return Err(PullRequestError::Denied); - } - let header_bytes = response.headers().iter().try_fold( - 0_usize, - |total, (name, value)| { - total - .checked_add(name.as_str().len()) - .and_then(|total| total.checked_add(value.as_bytes().len())) - .ok_or(PullRequestError::ResourceExhausted) - }, - )?; - if header_bytes > MAX_HEADERS_BYTES { - return Err(PullRequestError::ResourceExhausted); - } - let response_etag = response - .headers() - .get(reqwest::header::ETAG) - .and_then(|value| value.to_str().ok()) - .filter(|value| valid_etag(value)) - .map(str::to_owned); - let has_next_page = response_has_next_page(response.headers()); - match status.as_u16() { - 304 => replay_not_modified(cached, response_etag, has_next_page), - 200 => { - let mut body = Vec::new(); - loop { - let chunk = tokio::select! { - biased; - reason = wait_for_interruption(&self.control) => { - return Err(interruption_error(reason)); - } - chunk = response.chunk() => chunk.map_err(map_transport_error)?, - }; - let Some(chunk) = chunk else { - break; - }; - if body.len().saturating_add(chunk.len()) > max_body_bytes { - return Err(PullRequestError::ResourceExhausted); - } - body.extend_from_slice(&chunk); - } - Ok(FetchedObject { - object: CachedObject { - etag: response_etag, - body, - }, - has_next_page, - modified: true, - }) - } - 401 | 403 | 404 => Err(PullRequestError::Denied), - _ => Err(PullRequestError::Unavailable), - } - } -} - -#[derive(Deserialize)] -struct PullRequestHead { - number: u64, - head: PullRequestHeadSha, -} - -#[derive(Deserialize)] -struct PullRequestHeadSha { - sha: String, -} - -struct FetchedObject { - object: CachedObject, - modified: bool, - has_next_page: bool, -} - -fn require_complete(object: FetchedObject) -> Result { - if object.has_next_page { - Err(PullRequestError::ResourceExhausted) - } else { - Ok(object) - } } fn source_to_wit(source: &CachedSource) -> SourceSnapshot { SourceSnapshot { - pull_request: object_to_wit(&source.pull_request), - - check_runs: object_to_wit(&source.check_runs), - combined_status: object_to_wit(&source.combined_status), + graphql_data: source.graphql_data.clone(), observed_at: source.observed_at.clone(), } } @@ -440,56 +361,63 @@ fn run_on_runtime( runtime.block_on(future) } -fn request_builder( - client: &reqwest::Client, - endpoint: String, - timeout: Duration, - cached: Option<&CachedObject>, -) -> reqwest::RequestBuilder { - let mut builder = client - .get(endpoint) - .timeout(timeout) - .header("accept", "application/vnd.github+json") - .header("x-github-api-version", "2022-11-28") - .header("user-agent", "st2-github-pr-resource-profile/1"); - if let Some(etag) = cached.and_then(|object| object.etag.as_deref()) { - builder = builder.header(reqwest::header::IF_NONE_MATCH, etag); - } - builder +fn valid_request(request: &PullRequestRequest) -> bool { + valid_component(&request.owner, 39) + && valid_component(&request.repo, 100) + && request.number > 0 + && request.number <= i32::MAX as u64 } -fn response_has_next_page(headers: &reqwest::header::HeaderMap) -> bool { - headers - .get_all(reqwest::header::LINK) - .iter() - .filter_map(|value| value.to_str().ok()) - .flat_map(|value| value.split(',')) - .flat_map(|link| link.split(';').skip(1)) - .any(|parameter| parameter.trim().eq_ignore_ascii_case(r#"rel="next""#)) +fn valid_component(value: &str, maximum: usize) -> bool { + !value.is_empty() + && value.len() <= maximum + && !matches!(value, "." | "..") + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) } -fn replay_not_modified( - cached: Option<&CachedObject>, - response_etag: Option, - has_next_page: bool, -) -> Result { - let cached = cached.ok_or(PullRequestError::Unavailable)?; - let effective_etag = response_etag.or_else(|| cached.etag.clone()); - if cached.etag != effective_etag { - return Err(PullRequestError::Unavailable); +fn remaining(deadline: Instant) -> Result { + deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or(PullRequestError::DeadlineExceeded) +} + +fn validate_headers(headers: &reqwest::header::HeaderMap) -> Result<(), PullRequestError> { + let bytes = headers.iter().try_fold(0_usize, |total, (name, value)| { + total + .checked_add(name.as_str().len()) + .and_then(|total| total.checked_add(value.as_bytes().len())) + .ok_or(PullRequestError::ResourceExhausted) + })?; + if bytes > MAX_HEADERS_BYTES { + return Err(PullRequestError::ResourceExhausted); } - Ok(FetchedObject { - object: cached.clone(), - modified: false, - has_next_page, - }) + Ok(()) } -fn object_to_wit(object: &CachedObject) -> SourceObject { - SourceObject { - etag: object.etag.clone(), - body: object.body.clone(), +async fn read_body( + response: &mut reqwest::Response, + control: &InvocationControl, + maximum: usize, +) -> Result, PullRequestError> { + let mut body = Vec::new(); + loop { + let chunk = tokio::select! { + biased; + reason = wait_for_interruption(control) => return Err(interruption_error(reason)), + chunk = response.chunk() => chunk.map_err(map_transport_error)?, + }; + let Some(chunk) = chunk else { + break; + }; + if body.len().saturating_add(chunk.len()) > maximum { + return Err(PullRequestError::ResourceExhausted); + } + body.extend_from_slice(&chunk); } + Ok(body) } async fn wait_for_interruption(control: &InvocationControl) -> InterruptionReason { @@ -508,33 +436,6 @@ fn interruption_error(reason: InterruptionReason) -> PullRequestError { } } -fn request_matches_scope(config: &GitHubPrConfig, request: &PullRequestRequest) -> bool { - request.owner == config.owner - && request.repo == config.repo - && request.number == config.number -} - -fn valid_component(value: &str, maximum: usize) -> bool { - !value.is_empty() - && value.len() <= maximum - && !matches!(value, "." | "..") - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) -} - -fn valid_head_sha(value: &str) -> bool { - value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) -} - -fn valid_etag(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_ETAG_BYTES - && !value - .bytes() - .any(|byte| byte == b'\r' || byte == b'\n' || byte == 0) -} - fn map_transport_error(error: reqwest::Error) -> PullRequestError { if error.is_timeout() { PullRequestError::DeadlineExceeded @@ -547,17 +448,13 @@ async fn resolve_public_api_address( control: &InvocationControl, deadline: Instant, ) -> Result { - let remaining = deadline - .checked_duration_since(Instant::now()) - .filter(|remaining| !remaining.is_zero()) - .ok_or(PullRequestError::DeadlineExceeded)?; let resolver = hickory_resolver::Resolver::builder_tokio() .and_then(hickory_resolver::ResolverBuilder::build) .map_err(|_| PullRequestError::Unavailable)?; let lookup = tokio::select! { biased; reason = wait_for_interruption(control) => return Err(interruption_error(reason)), - result = await_dns_lookup(remaining, resolver.lookup_ip(API_HOST)) => result?, + result = await_dns_lookup(remaining(deadline)?, resolver.lookup_ip(API_HOST)) => result?, }; let mut addresses = lookup.iter(); let first = addresses.next().ok_or(PullRequestError::Unavailable)?; @@ -568,10 +465,10 @@ async fn resolve_public_api_address( } async fn await_dns_lookup( - remaining: Duration, + timeout: Duration, lookup: impl Future>, ) -> Result { - tokio::time::timeout(remaining, lookup) + tokio::time::timeout(timeout, lookup) .await .map_err(|_| PullRequestError::DeadlineExceeded)? .map_err(|_| PullRequestError::Unavailable) @@ -615,37 +512,45 @@ fn is_public(address: IpAddr) -> bool { mod tests { use super::*; - fn live_config() -> GitHubPrConfig { - GitHubPrConfig { - owner: "example".into(), - repo: "demo".into(), - number: 389, - connect_timeout: Duration::from_secs(3), - total_timeout: Duration::from_secs(10), - } - } - #[test] - fn exact_scope_denies_before_transport() { - let config = live_config(); + fn capability_accepts_dynamic_valid_subjects_and_rejects_invalid_components() { for request in [ PullRequestRequest { - owner: "other".into(), + owner: "example".into(), repo: "demo".into(), + number: 1, + }, + PullRequestRequest { + owner: "other-owner".into(), + repo: "private.repo".into(), number: 389, }, + ] { + assert!(valid_request(&request)); + } + for request in [ + PullRequestRequest { + owner: "..".into(), + repo: "demo".into(), + number: 1, + }, PullRequestRequest { owner: "example".into(), - repo: "other".into(), - number: 389, + repo: "demo/path".into(), + number: 1, }, PullRequestRequest { owner: "example".into(), repo: "demo".into(), - number: 390, + number: 0, + }, + PullRequestRequest { + owner: "example".into(), + repo: "demo".into(), + number: i32::MAX as u64 + 1, }, ] { - assert!(!request_matches_scope(&config, &request)); + assert!(!valid_request(&request)); } } @@ -672,142 +577,17 @@ mod tests { } #[test] - fn deadlines_are_bounded_and_ordered() { - let mut config = live_config(); - config.connect_timeout = Duration::from_secs(11); - assert!(config.validate().is_err()); - config.connect_timeout = Duration::from_secs(1); - config.total_timeout = Duration::from_secs(61); - assert!(config.validate().is_err()); - } - - #[test] - fn interruption_reasons_preserve_cancel_and_deadline_semantics() { - assert!(matches!( - interruption_error(InterruptionReason::Cancelled), - PullRequestError::Unavailable - )); - assert!(matches!( - interruption_error(InterruptionReason::TimedOut), - PullRequestError::DeadlineExceeded - )); - } - - #[test] - fn conditional_cache_is_selected_by_the_exact_prior_digest() { - let object_v1 = CachedObject { - etag: Some("\"v1\"".into()), - body: b"v1".to_vec(), - }; - let object_v2 = CachedObject { - etag: Some("\"v2\"".into()), - body: b"v2".to_vec(), - }; - let source = |object: CachedObject| CachedSource { - pull_request: object.clone(), - check_runs: object.clone(), - combined_status: object, - observed_at: "2026-09-02T10:00:00Z".into(), - }; - let digest_v1 = [1; SNAPSHOT_DIGEST_BYTES]; - let digest_v2 = [2; SNAPSHOT_DIGEST_BYTES]; - let mut cache = SnapshotCache::default(); - cache.insert(digest_v1, source(object_v1)); - cache.insert(digest_v2, source(object_v2)); - let prior = cache.get(&digest_v1).unwrap(); - assert_eq!(prior.pull_request.body, b"v1"); - - let client = reqwest::Client::new(); - let unbound = request_builder( - &client, - "https://api.github.com/example".into(), - Duration::from_secs(1), - None, - ) - .build() - .unwrap(); - assert!(unbound.headers().get(reqwest::header::IF_NONE_MATCH).is_none()); - let bound = request_builder( - &client, - "https://api.github.com/example".into(), - Duration::from_secs(1), - Some(&prior.pull_request), - ) - .build() - .unwrap(); + fn graphql_operation_is_fixed_and_bounded() { assert_eq!( - bound.headers()[reqwest::header::IF_NONE_MATCH], - "\"v1\"" + PULL_REQUEST_QUERY + .matches("pullRequest(number: $number)") + .count(), + 1 ); - } - - #[test] - fn paginated_ci_responses_are_rejected_instead_of_published_incomplete() { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - reqwest::header::LINK, - r#"; rel="next", ; rel="last""# - .parse() - .unwrap(), - ); - assert!(response_has_next_page(&headers)); - let incomplete = FetchedObject { - object: CachedObject { - etag: None, - body: Vec::new(), - }, - modified: true, - has_next_page: true, - }; - assert!(matches!( - require_complete(incomplete), - Err(PullRequestError::ResourceExhausted) - )); - - headers.insert( - reqwest::header::LINK, - r#"; rel="prev""# - .parse() - .unwrap(), + assert_eq!( + PULL_REQUEST_QUERY.matches("contexts(first: 100)").count(), + 1 ); - assert!(!response_has_next_page(&headers)); - } - - #[test] - fn async_dns_deadline_drops_the_pending_lookup() { - struct PendingLookup(Arc); - - impl Future for PendingLookup { - type Output = Result<(), ()>; - - fn poll( - self: std::pin::Pin<&mut Self>, - _context: &mut std::task::Context<'_>, - ) -> std::task::Poll { - std::task::Poll::Pending - } - } - - impl Drop for PendingLookup { - fn drop(&mut self) { - self.0.store(true, std::sync::atomic::Ordering::SeqCst); - } - } - - let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let result = run_on_runtime(await_dns_lookup( - Duration::ZERO, - PendingLookup(Arc::clone(&dropped)), - )); - assert!(matches!(result, Err(PullRequestError::DeadlineExceeded))); - assert!(dropped.load(std::sync::atomic::Ordering::SeqCst)); - } - - #[test] - fn etags_and_head_shas_reject_header_and_path_injection() { - assert!(valid_etag("\"safe\"")); - assert!(!valid_etag("\"safe\"\r\nx-injected: true")); - assert!(valid_head_sha("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); - assert!(!valid_head_sha("../heads/main")); + assert!(!PULL_REQUEST_QUERY.contains("$cursor")); } } diff --git a/crates/st2-resource-providers/src/lib.rs b/crates/st2-resource-providers/src/lib.rs index a0527f7e..608ffacb 100644 --- a/crates/st2-resource-providers/src/lib.rs +++ b/crates/st2-resource-providers/src/lib.rs @@ -1,5 +1,6 @@ //! Closed host capabilities for production resource-observer components. +mod github_auth; mod github_issue; mod github_pr; mod pty_stats; @@ -7,5 +8,5 @@ mod vista; pub use github_issue::{GitHubIssueConfig, GitHubIssueModule}; pub use github_pr::{GitHubPrConfig, GitHubPrModule}; -pub use pty_stats::{PtyStatsConfig, PtyStatsModule, PtyStatsScope}; +pub use pty_stats::{PtyStatsConfig, PtyStatsModule}; pub use vista::{VistaConfig, VistaModule}; diff --git a/crates/st2-resource-providers/src/pty_stats.rs b/crates/st2-resource-providers/src/pty_stats.rs index 782e06f7..3888d755 100644 --- a/crates/st2-resource-providers/src/pty_stats.rs +++ b/crates/st2-resource-providers/src/pty_stats.rs @@ -1,6 +1,7 @@ +use std::collections::BTreeMap; use std::io::Write as _; use std::os::unix::fs::PermissionsExt as _; -use std::os::unix::process::{CommandExt as _, ExitStatusExt as _}; +use std::os::unix::process::CommandExt as _; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicU8, Ordering}; @@ -8,10 +9,11 @@ use std::sync::{Arc, mpsc}; use std::thread; use std::time::{Duration, Instant}; +use chrono::{SecondsFormat, Utc}; use parking_lot::Mutex; - +use serde::Deserialize; use st2_resource_wasip2::{ - CapabilityContext, CapabilityModule, InterruptionReason, + CapabilityContext, CapabilityModule, CapabilityPhase, InterruptionReason, InvocationControl as ExecutorInvocationControl, InvocationStore, }; use wasmtime::component::{HasSelf, Linker}; @@ -24,24 +26,20 @@ mod bindings { } use bindings::compoundingtech::st2_pty_stats::pty_stats::{ - ExitStatus, Host, Outcome, PtyStatsError, Scope, + Clients, Generation, Host, Lifecycle, Metadata, Modes, Process, ProcessResources, + PtyStatsError, Runtime, SessionSource, SourceObservation, Tag, Terminal, }; const IMPORT_NAME: &str = "compoundingtech:st2-pty-stats/pty-stats@0.1.0"; const MAX_STDOUT_BYTES: usize = 2 * 1024 * 1024; const MAX_STDERR_BYTES: usize = 64 * 1024; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PtyStatsScope { - All, - Session(String), -} +const SNAPSHOT_DIGEST_BYTES: usize = 32; +const MAX_CACHED_SNAPSHOTS: usize = 256; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PtyStatsConfig { pub executable: PathBuf, pub cwd: PathBuf, - pub scope: PtyStatsScope, pub deadline: Duration, } @@ -49,27 +47,20 @@ impl PtyStatsConfig { pub fn resolve( executable: impl AsRef, cwd: impl Into, - scope: PtyStatsScope, deadline: Duration, ) -> Result { if deadline.is_zero() || deadline > Duration::from_secs(60) { - return Err("PTY stats deadline is invalid"); + return Err("PTY control-plane deadline is invalid"); } let executable = resolve_executable(executable.as_ref()).ok_or("PTY executable is unavailable")?; let cwd = cwd.into(); if !cwd.is_absolute() { - return Err("PTY stats cwd must be absolute"); - } - if let PtyStatsScope::Session(session) = &scope - && (session.is_empty() || session.len() > 512 || session.contains('\0')) - { - return Err("PTY session scope is invalid"); + return Err("PTY control-plane cwd must be absolute"); } Ok(Self { executable, cwd, - scope, deadline, }) } @@ -78,19 +69,70 @@ impl PtyStatsConfig { #[derive(Clone)] pub struct PtyStatsModule { config: PtyStatsConfig, + cache: Arc>, } impl PtyStatsModule { pub fn new(config: PtyStatsConfig) -> Self { - Self { config } + Self { + config, + cache: Arc::new(Mutex::new(SnapshotCache::default())), + } + } +} + +#[derive(Debug, Clone)] +struct CachedSource { + id: String, + observed_at: String, + lifecycle: SourceLifecycle, + generation: Option, + metadata: Option, + runtime: Option, +} + +impl CachedSource { + fn absent(id: &str, observed_at: String) -> Self { + Self { + id: id.into(), + observed_at, + lifecycle: SourceLifecycle::Absent, + generation: None, + metadata: None, + runtime: None, + } } } +#[derive(Default)] +struct SnapshotCache { + sources: BTreeMap<[u8; SNAPSHOT_DIGEST_BYTES], CachedSource>, +} + +impl SnapshotCache { + fn get(&self, digest: &[u8; SNAPSHOT_DIGEST_BYTES]) -> Option<&CachedSource> { + self.sources.get(digest) + } + + fn insert(&mut self, digest: [u8; SNAPSHOT_DIGEST_BYTES], source: CachedSource) { + if !self.sources.contains_key(&digest) && self.sources.len() >= MAX_CACHED_SNAPSHOTS { + if let Some(evicted) = self.sources.keys().next().copied() { + self.sources.remove(&evicted); + } + } + self.sources.insert(digest, source); + } +} pub struct PtyStatsInvocation { config: PtyStatsConfig, + cache: Arc>, + prior_digest: Option<[u8; SNAPSHOT_DIGEST_BYTES]>, + current_source: Option, + deadline: Instant, control: Arc, } + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] enum Termination { @@ -112,7 +154,6 @@ enum ChildOwnership { Pending, Live(i32), Reaping, - Reaped, } impl ProcessControl { @@ -187,6 +228,7 @@ impl ProcessControl { } changed } + fn synchronize_interruption(&self) -> Termination { let reason = self.termination(); if reason != Termination::None { @@ -195,7 +237,6 @@ impl ProcessControl { reason } - fn wait_and_reap( &self, child: &mut std::process::Child, @@ -205,7 +246,7 @@ impl ProcessControl { debug_assert!(matches!(*ownership, ChildOwnership::Live(_))); *ownership = ChildOwnership::Reaping; let status = child.wait(); - *ownership = ChildOwnership::Reaped; + *ownership = ChildOwnership::Pending; status } @@ -216,11 +257,10 @@ impl ProcessControl { } *ownership = ChildOwnership::Reaping; let _ = child.wait(); - *ownership = ChildOwnership::Reaped; + *ownership = ChildOwnership::Pending; } } - impl CapabilityModule for PtyStatsModule { type Invocation = PtyStatsInvocation; @@ -236,39 +276,128 @@ impl CapabilityModule for PtyStatsModule { } fn begin(&self, context: CapabilityContext<'_>) -> Self::Invocation { + let prior_digest = match context.phase() { + CapabilityPhase::Describe => None, + CapabilityPhase::Observe(request) => request + .prior_digest + .as_ref() + .map(|digest| *digest.as_bytes()), + }; + let deadline = Instant::now() + self.config.deadline; PtyStatsInvocation { config: self.config.clone(), + cache: Arc::clone(&self.cache), + prior_digest, + current_source: None, + deadline, control: Arc::new(ProcessControl::new(context.control().clone())), } } } impl Host for InvocationStore { - fn get(&mut self, scope: Scope) -> Result { - self.capability_mut().get(scope) + fn list_session(&mut self, session: String) -> Result { + self.capability_mut().list(session) + } + + fn stats(&mut self, session: String) -> Result { + self.capability_mut().stats(session) + } + + fn bind_snapshot(&mut self, digest: Vec) -> Result<(), PtyStatsError> { + self.capability_mut().bind_snapshot(digest) } } impl PtyStatsInvocation { - fn get(&mut self, scope: Scope) -> Result { - if !scope_matches(&self.config.scope, &scope) { + fn list(&mut self, session: String) -> Result { + if !valid_session_id(&session) { + return Err(PtyStatsError::Denied); + } + let previous = self + .prior_digest + .as_ref() + .and_then(|digest| self.cache.lock().get(digest).cloned()) + .filter(|source| source.id == session); + let outcome = self.run(&["list", "--json"])?; + require_success(&outcome)?; + let sessions: Vec = + serde_json::from_slice(&outcome.stdout).map_err(|_| PtyStatsError::Unavailable)?; + let mut matches = sessions + .into_iter() + .filter(|candidate| candidate.name == session); + let observed_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let current = match matches.next() { + Some(found) => CachedSource::from(found, observed_at), + None => CachedSource::absent(&session, observed_at), + }; + if matches.next().is_some() { + return Err(PtyStatsError::Unavailable); + } + self.current_source = Some(current.clone()); + Ok(SourceObservation { + current: source_to_wit(¤t), + previous: previous.as_ref().map(source_to_wit), + }) + } + + fn stats(&mut self, session: String) -> Result { + if !valid_session_id(&session) { return Err(PtyStatsError::Denied); } - if self.control.termination() == Termination::Cancelled { - return Err(PtyStatsError::Cancelled); + let mut current = self + .current_source + .take() + .filter(|source| source.id == session && source.lifecycle == SourceLifecycle::Running) + .ok_or(PtyStatsError::Denied)?; + let outcome = self.run(&["stats", "--json", &session])?; + if !successful(&outcome) { + if contains_not_found(&outcome.stderr) { + current = CachedSource::absent(&session, current.observed_at); + self.current_source = Some(current.clone()); + return Ok(source_to_wit(¤t)); + } + return Err(PtyStatsError::Unavailable); + } + let stats: StatsResponse = + serde_json::from_slice(&outcome.stdout).map_err(|_| PtyStatsError::Unavailable)?; + if stats.name != session { + return Err(PtyStatsError::Unavailable); + } + current.apply_stats(stats)?; + self.current_source = Some(current.clone()); + Ok(source_to_wit(¤t)) + } + + fn bind_snapshot(&mut self, digest: Vec) -> Result<(), PtyStatsError> { + let digest: [u8; SNAPSHOT_DIGEST_BYTES] = + digest.try_into().map_err(|_| PtyStatsError::Denied)?; + let source = self + .current_source + .take() + .ok_or(PtyStatsError::Unavailable)?; + self.cache.lock().insert(digest, source); + Ok(()) + } + + fn run(&mut self, arguments: &[&str]) -> Result { + match self.control.termination() { + Termination::Cancelled => return Err(PtyStatsError::Cancelled), + Termination::TimedOut => return Err(PtyStatsError::DeadlineExceeded), + Termination::None => {} + } + if self.deadline <= Instant::now() { + self.control.terminate(Termination::TimedOut); + return Err(PtyStatsError::DeadlineExceeded); } let mut command = Command::new(&self.config.executable); command - .arg("stats") - .arg("--json") + .args(arguments) .current_dir(&self.config.cwd) .env_clear() .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - if let PtyStatsScope::Session(session) = &self.config.scope { - command.arg(session); - } // SAFETY: this runs in the freshly-forked child before exec and calls only async-signal-safe // setpgid. The dedicated process group is the cancellation/reaping boundary. unsafe { @@ -312,25 +441,26 @@ impl PtyStatsInvocation { } }; let (completed_tx, completed_rx) = mpsc::sync_channel(1); - let deadline = Instant::now() + self.config.deadline; + let deadline = self.deadline; let deadline_control = Arc::clone(&self.control); let timer = match thread::Builder::new() .name("st2-pty-stats-deadline".into()) - .spawn(move || loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - deadline_control.terminate(Termination::TimedOut); - return; + .spawn(move || { + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + deadline_control.terminate(Termination::TimedOut); + return; + } + match completed_rx.recv_timeout(remaining.min(Duration::from_millis(10))) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + if deadline_control.synchronize_interruption() != Termination::None { + return; + } } - match completed_rx.recv_timeout(remaining.min(Duration::from_millis(10))) { - Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return, - Err(mpsc::RecvTimeoutError::Timeout) => {} - } - if deadline_control.synchronize_interruption() != Termination::None { - return; - } - }) - { + }) { Ok(timer) => timer, Err(_) => { self.control.kill_and_reap(&mut child); @@ -358,6 +488,9 @@ impl PtyStatsInvocation { let (stderr, stderr_truncated) = stderr_reader .join() .map_err(|_| PtyStatsError::Unavailable)??; + if self.control.termination() == Termination::None && self.deadline <= Instant::now() { + self.control.terminate(Termination::TimedOut); + } match self.control.termination() { Termination::Cancelled => return Err(PtyStatsError::Cancelled), Termination::TimedOut => return Err(PtyStatsError::DeadlineExceeded), @@ -366,65 +499,331 @@ impl PtyStatsInvocation { if stdout_truncated || stderr_truncated { return Err(PtyStatsError::ResourceExhausted); } - let exit = status.code().map_or_else( - || ExitStatus::Signal(status.signal().unwrap_or(0)), - ExitStatus::Code, - ); - Ok(Outcome { + let exit = status.code().map_or(CommandExit::Signal, CommandExit::Code); + Ok(CommandOutcome { stdout, stderr, - stdout_truncated, - stderr_truncated, exit, }) } } -fn scope_matches(configured: &PtyStatsScope, requested: &Scope) -> bool { - match (configured, requested) { - (PtyStatsScope::All, Scope::All) => true, - (PtyStatsScope::Session(configured), Scope::Session(requested)) => configured == requested, - _ => false, +#[derive(Debug)] +struct CommandOutcome { + stdout: Vec, + stderr: Vec, + exit: CommandExit, +} + +#[derive(Debug)] +enum CommandExit { + Code(i32), + Signal, +} + +fn successful(outcome: &CommandOutcome) -> bool { + matches!(outcome.exit, CommandExit::Code(0)) +} + +fn require_success(outcome: &CommandOutcome) -> Result<(), PtyStatsError> { + successful(outcome) + .then_some(()) + .ok_or(PtyStatsError::Unavailable) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +enum SourceLifecycle { + Running, + Exited, + Vanished, + Absent, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +enum SourceGeneration { + Number(u64), + Timestamp(String), +} + +#[derive(Debug, Clone)] +struct SourceMetadata { + display_name: Option, + command: Option, + cwd: Option, + created_at: Option, + exit_code: Option, + exited_at: Option, + tags: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListSession { + name: String, + status: SourceLifecycle, + command: Option, + cwd: Option, + created_at: Option, + exit_code: Option, + exited_at: Option, + tags: Option>, + display_name: Option, + generation: Option, +} + +impl CachedSource { + fn from(session: ListSession, observed_at: String) -> Self { + let generation = session + .generation + .clone() + .or_else(|| session.created_at.clone().map(SourceGeneration::Timestamp)); + Self { + id: session.name, + observed_at, + lifecycle: session.status, + generation, + metadata: Some(SourceMetadata { + display_name: session.display_name, + command: session.command, + cwd: session.cwd, + created_at: session.created_at, + exit_code: session.exit_code, + exited_at: session.exited_at, + tags: session.tags, + }), + runtime: None, + } + } + + fn apply_stats(&mut self, stats: StatsResponse) -> Result<(), PtyStatsError> { + let stats_generation = stats + .generation + .or_else(|| stats.created_at.map(SourceGeneration::Timestamp)); + self.generation = stats_generation.or_else(|| self.generation.clone()); + match stats.status { + Some(SourceLifecycle::Exited | SourceLifecycle::Vanished) => { + self.lifecycle = stats.status.expect("the gone PTY status was present"); + self.runtime = None; + let metadata = self.metadata.as_mut().ok_or(PtyStatsError::Unavailable)?; + metadata.exit_code = stats.exit_code.or(metadata.exit_code); + metadata.exited_at = stats.exited_at.or_else(|| metadata.exited_at.clone()); + metadata.tags = stats.tags.or_else(|| metadata.tags.clone()); + } + Some(SourceLifecycle::Absent) => { + *self = Self::absent(&self.id, self.observed_at.clone()); + } + Some(SourceLifecycle::Running) | None => { + self.lifecycle = SourceLifecycle::Running; + self.runtime = Some(SourceRuntime { + terminal: stats.terminal.ok_or(PtyStatsError::Unavailable)?, + process: stats.process.ok_or(PtyStatsError::Unavailable)?, + clients: stats.clients.ok_or(PtyStatsError::Unavailable)?, + modes: stats.modes.ok_or(PtyStatsError::Unavailable)?, + uptime_seconds: stats.uptime_seconds, + }); + } + } + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StatsResponse { + name: String, + status: Option, + terminal: Option, + process: Option, + clients: Option, + modes: Option, + uptime_seconds: Option, + created_at: Option, + generation: Option, + exit_code: Option, + exited_at: Option, + tags: Option>, +} + +#[derive(Debug, Clone)] +struct SourceRuntime { + terminal: SourceTerminal, + process: SourceProcess, + clients: SourceClients, + modes: SourceModes, + uptime_seconds: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourceTerminal { + cols: u32, + rows: u32, + cursor_x: u32, + cursor_y: u32, + scrollback_used: u64, + scrollback_capacity: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourceProcess { + alive: bool, + exit_code: Option, + resources: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourceProcessResources { + rss_kb: u64, + cpu_percent: f64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourceClients { + total: u32, + attached: u32, + read_only: u32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourceModes { + sgr_mouse: bool, + cursor_hidden: bool, + kitty_keyboard: bool, + kitty_keyboard_flags: Vec, +} + +fn source_to_wit(source: &CachedSource) -> SessionSource { + SessionSource { + id: source.id.clone(), + observed_at: source.observed_at.clone(), + lifecycle: match source.lifecycle { + SourceLifecycle::Running => Lifecycle::Running, + SourceLifecycle::Exited => Lifecycle::Exited, + SourceLifecycle::Vanished => Lifecycle::Vanished, + SourceLifecycle::Absent => Lifecycle::Absent, + }, + generation: source + .generation + .as_ref() + .map(|generation| match generation { + SourceGeneration::Number(number) => Generation::Number(*number), + SourceGeneration::Timestamp(timestamp) => Generation::Timestamp(timestamp.clone()), + }), + metadata: source.metadata.as_ref().map(|metadata| Metadata { + display_name: metadata.display_name.clone(), + command: metadata.command.clone(), + cwd: metadata.cwd.clone(), + created_at: metadata.created_at.clone(), + exit_code: metadata.exit_code, + exited_at: metadata.exited_at.clone(), + tags: metadata.tags.as_ref().map(|tags| { + tags.iter() + .map(|(key, value)| Tag { + key: key.clone(), + value: value.clone(), + }) + .collect() + }), + }), + runtime: source.runtime.as_ref().map(|runtime| Runtime { + terminal: Terminal { + cols: runtime.terminal.cols, + rows: runtime.terminal.rows, + cursor_x: runtime.terminal.cursor_x, + cursor_y: runtime.terminal.cursor_y, + scrollback_used: runtime.terminal.scrollback_used, + scrollback_capacity: runtime.terminal.scrollback_capacity, + }, + process: Process { + alive: runtime.process.alive, + exit_code: runtime.process.exit_code, + resources: runtime + .process + .resources + .as_ref() + .map(|resources| ProcessResources { + rss_kb: resources.rss_kb, + cpu_percent: resources.cpu_percent, + }), + }, + clients: Clients { + total: runtime.clients.total, + attached: runtime.clients.attached, + read_only: runtime.clients.read_only, + }, + modes: Modes { + sgr_mouse: runtime.modes.sgr_mouse, + cursor_hidden: runtime.modes.cursor_hidden, + kitty_keyboard: runtime.modes.kitty_keyboard, + kitty_keyboard_flags: runtime.modes.kitty_keyboard_flags.clone(), + }, + uptime_seconds: runtime.uptime_seconds, + }), } } +fn valid_session_id(session: &str) -> bool { + !session.is_empty() + && session.len() <= 255 + && !matches!(session, "." | "..") + && session + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn contains_not_found(stderr: &[u8]) -> bool { + String::from_utf8_lossy(stderr) + .to_ascii_lowercase() + .contains("not found") +} + fn drain_bounded( mut input: impl std::io::Read, limit: usize, ) -> Result<(Vec, bool), PtyStatsError> { - let mut retained = Vec::with_capacity(limit.min(64 * 1024)); - let mut truncated = false; - let mut buffer = [0_u8; 16 * 1024]; + let mut bytes = Vec::with_capacity(limit.min(64 * 1024)); + let mut chunk = [0_u8; 8192]; loop { - let read = input.read(&mut buffer).map_err(|_| PtyStatsError::Unavailable)?; + let read = input + .read(&mut chunk) + .map_err(|_| PtyStatsError::Unavailable)?; if read == 0 { - break; + return Ok((bytes, false)); + } + let remaining = limit.saturating_sub(bytes.len()); + bytes.extend_from_slice(&chunk[..read.min(remaining)]); + if read > remaining { + let mut sink = std::io::sink(); + sink.write_all(&chunk[remaining..read]) + .map_err(|_| PtyStatsError::Unavailable)?; + std::io::copy(&mut input, &mut sink).map_err(|_| PtyStatsError::Unavailable)?; + return Ok((bytes, true)); } - let remaining = limit.saturating_sub(retained.len()); - retained - .write_all(&buffer[..read.min(remaining)]) - .map_err(|_| PtyStatsError::Unavailable)?; - truncated |= read > remaining; } - Ok((retained, truncated)) } fn kill_process_group(process_group: i32) -> bool { - // SAFETY: negative pid addresses the process group created by pre_exec; SIGKILL is required to - // make the deadline a hard bound even when the provider subprocess ignores graceful signals. + // SAFETY: a negative pid addresses the dedicated process group created by pre_exec. unsafe { libc::kill(-process_group, libc::SIGKILL) == 0 } } fn wait_without_reaping(pid: u32) -> std::io::Result<()> { + let pid = + i32::try_from(pid).map_err(|_| std::io::Error::other("child pid did not fit in pid_t"))?; loop { - // SAFETY: `info` is initialized for the kernel, and WNOWAIT deliberately keeps the child - // waitable so its process-group identity cannot be recycled before ownership is fenced. + let mut info = std::mem::MaybeUninit::::zeroed(); + // SAFETY: info points to writable storage and WNOWAIT preserves wait() as the sole reaper. let result = unsafe { - let mut info = std::mem::zeroed::(); libc::waitid( libc::P_PID, - pid, - &mut info, + pid as libc::id_t, + info.as_mut_ptr(), libc::WEXITED | libc::WNOWAIT, ) }; @@ -437,17 +836,11 @@ fn wait_without_reaping(pid: u32) -> std::io::Result<()> { } } } + fn resolve_executable(executable: &Path) -> Option { - if executable.is_absolute() { - return executable_is_runnable(executable).then(|| executable.to_path_buf()); - } let validation_cwd = std::env::current_dir().ok()?; let search_path = std::env::var_os("PATH"); - resolve_executable_at( - executable, - &validation_cwd, - search_path.as_deref(), - ) + resolve_executable_at(executable, &validation_cwd, search_path.as_deref()) } fn resolve_executable_at( @@ -455,28 +848,35 @@ fn resolve_executable_at( validation_cwd: &Path, search_path: Option<&std::ffi::OsStr>, ) -> Option { - debug_assert!(validation_cwd.is_absolute()); if executable.components().count() > 1 { - let candidate = validation_cwd.join(executable); - return executable_is_runnable(&candidate).then_some(candidate); - } - let search_path = search_path?; - std::env::split_paths(search_path) - .map(|directory| { - let directory = if directory.is_absolute() { - directory - } else { - validation_cwd.join(directory) - }; - directory.join(executable) - }) - .find(|candidate| executable_is_runnable(candidate)) + let candidate = if executable.is_absolute() { + executable.to_path_buf() + } else { + validation_cwd.join(executable) + }; + return executable_is_runnable(&candidate) + .then(|| candidate.canonicalize().ok()) + .flatten(); + } + let path = search_path?; + std::env::split_paths(path).find_map(|directory| { + let directory = if directory.as_os_str().is_empty() { + validation_cwd.to_path_buf() + } else if directory.is_absolute() { + directory + } else { + validation_cwd.join(directory) + }; + let candidate = directory.join(executable); + executable_is_runnable(&candidate) + .then(|| candidate.canonicalize().ok()) + .flatten() + }) } fn executable_is_runnable(path: &Path) -> bool { - std::fs::metadata(path).is_ok_and(|metadata| { - metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 - }) + std::fs::metadata(path) + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) } #[cfg(test)] @@ -484,7 +884,6 @@ mod tests { use std::ffi::CString; use std::os::unix::ffi::OsStrExt as _; - use super::*; fn write_executable(path: &Path, contents: &str) { @@ -494,17 +893,26 @@ mod tests { std::fs::set_permissions(path, permissions).unwrap(); } - fn invoke(config: PtyStatsConfig) -> Outcome { + fn make_fifo(path: &Path) { + let fifo = CString::new(path.as_os_str().as_bytes()).unwrap(); + // SAFETY: the pathname is a live NUL-terminated byte string owned for the call. + assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); + } + + fn invocation(config: PtyStatsConfig) -> PtyStatsInvocation { + let deadline = Instant::now() + config.deadline; PtyStatsInvocation { config, + cache: Arc::new(Mutex::new(SnapshotCache::default())), + prior_digest: None, + current_source: None, + deadline, control: Arc::new(ProcessControl::detached()), } - .get(Scope::All) - .unwrap() } #[test] - fn slash_relative_executable_stays_bound_to_validation_cwd() { + fn config_authorizes_only_executable_cwd_and_deadline() { let validation_cwd = std::env::current_dir().unwrap(); let temporary = tempfile::Builder::new() .prefix("st2-pty-resolution-") @@ -512,112 +920,131 @@ mod tests { .unwrap(); let configured_cwd = temporary.path().join("configured"); std::fs::create_dir_all(temporary.path().join("tools")).unwrap(); - let validated_executable = temporary.path().join("tools/pty-stats"); - write_executable( - &validated_executable, - "#!/bin/sh\nprintf 'validated\\n'\n", - ); + std::fs::create_dir_all(&configured_cwd).unwrap(); + let validated_executable = temporary.path().join("tools/pty"); + write_executable(&validated_executable, "#!/bin/sh\nprintf '[]'\n"); let relative_executable = validated_executable.strip_prefix(&validation_cwd).unwrap(); - let rebound_executable = configured_cwd.join(relative_executable); - std::fs::create_dir_all(rebound_executable.parent().unwrap()).unwrap(); - write_executable(&rebound_executable, "#!/bin/sh\nprintf 'rebound\\n'\n"); - - let config = PtyStatsConfig::resolve( - relative_executable, - configured_cwd, - PtyStatsScope::All, - Duration::from_secs(1), - ) - .unwrap(); - assert!(config.executable.is_absolute()); + + let config = + PtyStatsConfig::resolve(relative_executable, configured_cwd, Duration::from_secs(1)) + .unwrap(); assert_eq!(config.executable, validated_executable); - assert_eq!(invoke(config).stdout, b"validated\n"); + assert_eq!( + invocation(config) + .list("dynamic-session".into()) + .unwrap() + .current + .id, + "dynamic-session" + ); } #[test] - fn relative_path_entry_stays_bound_to_validation_cwd() { + fn host_accepts_dynamic_canonical_ids_and_rejects_aliases() { + assert!(valid_session_id("stable.session-1")); + for session in ["", ".", "..", "../session", "display name", "slash/name"] { + assert!(!valid_session_id(session)); + } + } + + #[test] + fn list_and_stats_are_sequential_and_cache_typed_source_by_snapshot_digest() { let temporary = tempfile::tempdir().unwrap(); - let validation_cwd = temporary.path().join("validation"); - let configured_cwd = temporary.path().join("configured"); - std::fs::create_dir_all(validation_cwd.join("bin")).unwrap(); - std::fs::create_dir_all(configured_cwd.join("bin")).unwrap(); - write_executable( - &validation_cwd.join("bin/pty-stats"), - "#!/bin/sh\nprintf 'validated-path\\n'\n", - ); + let executable = temporary.path().join("pty"); write_executable( - &configured_cwd.join("bin/pty-stats"), - "#!/bin/sh\nprintf 'rebound-path\\n'\n", + &executable, + "#!/bin/sh\nif [ \"$1\" = list ]; then\n printf '%s' '[{\"name\":\"one\",\"status\":\"running\",\"command\":\"agent\",\"cwd\":\"/workspace\",\"createdAt\":\"created\",\"exitCode\":null,\"exitedAt\":null,\"tags\":{\"owner\":\"agent\"},\"displayName\":\"One\",\"generation\":1}]'\nelse\n printf '%s' '{\"name\":\"one\",\"status\":\"running\",\"terminal\":{\"cols\":80,\"rows\":24,\"cursorX\":1,\"cursorY\":2,\"scrollbackUsed\":3,\"scrollbackCapacity\":100},\"process\":{\"alive\":true,\"exitCode\":null,\"resources\":{\"rssKb\":10,\"cpuPercent\":1.5}},\"clients\":{\"total\":1,\"attached\":1,\"readOnly\":0},\"modes\":{\"sgrMouse\":false,\"cursorHidden\":false,\"kittyKeyboard\":true,\"kittyKeyboardFlags\":[1]},\"uptimeSeconds\":5,\"createdAt\":\"created\",\"generation\":1,\"exitCode\":null,\"exitedAt\":null,\"tags\":null}'\nfi\n", ); + let config = + PtyStatsConfig::resolve(&executable, temporary.path(), Duration::from_secs(1)).unwrap(); + let cache = Arc::new(Mutex::new(SnapshotCache::default())); + let mut first = PtyStatsInvocation { + config: config.clone(), + cache: Arc::clone(&cache), + prior_digest: None, + current_source: None, + deadline: Instant::now() + config.deadline, + control: Arc::new(ProcessControl::detached()), + }; + let listed = first.list("one".into()).unwrap(); + assert!(matches!(&listed.current.lifecycle, Lifecycle::Running)); + assert!(listed.current.runtime.is_none()); + let with_stats = first.stats("one".into()).unwrap(); + assert!(with_stats.runtime.is_some()); + first.bind_snapshot(vec![7; SNAPSHOT_DIGEST_BYTES]).unwrap(); - let executable = resolve_executable_at( - Path::new("pty-stats"), - &validation_cwd, - Some(std::ffi::OsStr::new("bin")), - ) - .unwrap(); - assert!(executable.is_absolute()); - assert_eq!(executable, validation_cwd.join("bin/pty-stats")); - let outcome = invoke(PtyStatsConfig { - executable, - cwd: configured_cwd, - scope: PtyStatsScope::All, - deadline: Duration::from_secs(1), - }); - assert_eq!(outcome.stdout, b"validated-path\n"); + let deadline = Instant::now() + config.deadline; + let mut second = PtyStatsInvocation { + config, + cache, + prior_digest: Some([7; SNAPSHOT_DIGEST_BYTES]), + current_source: None, + deadline, + control: Arc::new(ProcessControl::detached()), + }; + let listed = second.list("one".into()).unwrap(); + assert!(listed.previous.unwrap().runtime.is_some()); } #[test] - fn fixed_command_deadline_kills_and_reaps_the_process_group() { + fn list_and_stats_reuse_the_invocation_deadline() { let temporary = tempfile::tempdir().unwrap(); - let executable = temporary.path().join("blocked-pty"); - let fifo = temporary.path().join("block"); - let fifo_c = CString::new(fifo.as_os_str().as_bytes()).unwrap(); - // SAFETY: the pathname is a live NUL-terminated byte string owned for the call. - assert_eq!(unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }, 0); - std::fs::write( - &executable, - "#!/bin/sh\nexec 3< \"$PWD/block\"\nread value <&3\n", - ) - .unwrap(); - let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); - permissions.set_mode(0o700); - std::fs::set_permissions(&executable, permissions).unwrap(); - let config = PtyStatsConfig::resolve( + let executable = temporary.path().join("pty"); + write_executable( &executable, - temporary.path(), - PtyStatsScope::All, - Duration::from_millis(100), - ) - .unwrap(); + "#!/bin/sh\nif [ \"$1\" = list ]; then\n printf '%s' '[{\"name\":\"one\",\"status\":\"running\"}]'\nelse\n : > \"$PWD/stats-invoked\"\n exit 64\nfi\n", + ); + let config = + PtyStatsConfig::resolve(&executable, temporary.path(), Duration::from_secs(1)).unwrap(); let control = Arc::new(ProcessControl::detached()); let mut invocation = PtyStatsInvocation { config, + cache: Arc::new(Mutex::new(SnapshotCache::default())), + prior_digest: None, + current_source: None, + deadline: Instant::now() + Duration::from_secs(1), control: Arc::clone(&control), }; + let original_deadline = invocation.deadline; + + invocation.list("one".into()).unwrap(); + assert_eq!(invocation.deadline, original_deadline); + invocation.deadline = Instant::now(); assert!(matches!( - invocation.get(Scope::All), + invocation.stats("one".into()), Err(PtyStatsError::DeadlineExceeded) )); - assert!(matches!(*control.child.lock(), ChildOwnership::Reaped)); + assert!(!temporary.path().join("stats-invoked").exists()); + assert!(matches!(*control.child.lock(), ChildOwnership::Pending)); } #[test] - #[ignore = "requires packaged pty: cargo test -p st2-resource-providers pty_stats_live_json -- --ignored"] - fn pty_stats_live_json() { - let config = PtyStatsConfig::resolve( - "pty", - "/", - PtyStatsScope::All, - Duration::from_secs(10), - ) - .unwrap(); + fn fixed_command_deadline_kills_reaps_and_resets_the_process_boundary() { + let temporary = tempfile::tempdir().unwrap(); + let executable = temporary.path().join("blocked-pty"); + let fifo = temporary.path().join("block"); + make_fifo(&fifo); + write_executable( + &executable, + "#!/bin/sh\nexec 3< \"$PWD/block\"\nread value <&3\n", + ); + let config = + PtyStatsConfig::resolve(&executable, temporary.path(), Duration::from_millis(100)) + .unwrap(); + let control = Arc::new(ProcessControl::detached()); + let deadline = Instant::now() + config.deadline; let mut invocation = PtyStatsInvocation { config, - control: Arc::new(ProcessControl::detached()), + cache: Arc::new(Mutex::new(SnapshotCache::default())), + prior_digest: None, + current_source: None, + deadline, + control: Arc::clone(&control), }; - let outcome = invocation.get(Scope::All).unwrap(); - assert!(matches!(outcome.exit, ExitStatus::Code(0))); - serde_json::from_slice::(&outcome.stdout).unwrap(); + assert!(matches!( + invocation.run(&["list", "--json"]), + Err(PtyStatsError::DeadlineExceeded) + )); + assert!(matches!(*control.child.lock(), ChildOwnership::Pending)); } } diff --git a/crates/st2-resource-providers/src/vista.rs b/crates/st2-resource-providers/src/vista.rs index 6ae0d6e3..e4bced98 100644 --- a/crates/st2-resource-providers/src/vista.rs +++ b/crates/st2-resource-providers/src/vista.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::io::Write as _; use std::os::unix::fs::PermissionsExt as _; use std::os::unix::process::{CommandExt as _, ExitStatusExt as _}; @@ -8,9 +9,10 @@ use std::sync::{Arc, mpsc}; use std::thread; use std::time::{Duration, Instant}; +use chrono::{SecondsFormat, Utc}; use parking_lot::Mutex; use st2_resource_wasip2::{ - CapabilityContext, CapabilityModule, InterruptionReason, + CapabilityContext, CapabilityModule, CapabilityPhase, InterruptionReason, InvocationControl as ExecutorInvocationControl, InvocationStore, }; use wasmtime::component::{HasSelf, Linker}; @@ -23,20 +25,21 @@ mod bindings { } use bindings::compoundingtech::st2_vista::vista::{ - ArtifactRequest, ExitStatus, Host, Outcome, VistaError, + ArtifactRequest, ArtifactResponse, CommandFailure, ExitStatus, Host, SourceObservation, + SourceSnapshot, VistaError, }; const IMPORT_NAME: &str = "compoundingtech:st2-vista/vista@0.1.0"; const MAX_STDOUT_BYTES: usize = 1024 * 1024; const MAX_STDERR_BYTES: usize = 64 * 1024; -const MAX_VERSION: u64 = 9_007_199_254_740_991; +const MAX_VERSION: u64 = 9_999_999_999_999_999_999; +const SNAPSHOT_DIGEST_BYTES: usize = 32; +const MAX_CACHED_SNAPSHOTS: usize = 16; #[derive(Debug, Clone, PartialEq, Eq)] pub struct VistaConfig { pub executable: PathBuf, pub cwd: PathBuf, - pub slug: String, - pub version: u64, pub deadline: Duration, } @@ -44,8 +47,6 @@ impl VistaConfig { pub fn resolve( executable: impl AsRef, cwd: impl Into, - slug: String, - version: u64, deadline: Duration, ) -> Result { if deadline.is_zero() || deadline > Duration::from_secs(60) { @@ -57,14 +58,9 @@ impl VistaConfig { if !cwd.is_absolute() { return Err("Vista cwd must be absolute"); } - if !valid_slug(&slug) || !(1..=MAX_VERSION).contains(&version) { - return Err("Vista artifact scope is invalid"); - } Ok(Self { executable, cwd, - slug, - version, deadline, }) } @@ -73,16 +69,49 @@ impl VistaConfig { #[derive(Clone)] pub struct VistaModule { config: VistaConfig, + cache: Arc>, } impl VistaModule { pub fn new(config: VistaConfig) -> Self { - Self { config } + Self { + config, + cache: Arc::new(Mutex::new(SnapshotCache::default())), + } + } +} + +#[derive(Debug, Clone)] +struct CachedSource { + manifest_json: Vec, + observed_at: String, +} + +#[derive(Default)] +struct SnapshotCache { + sources: BTreeMap<[u8; SNAPSHOT_DIGEST_BYTES], CachedSource>, +} + +impl SnapshotCache { + fn get(&self, digest: &[u8; SNAPSHOT_DIGEST_BYTES]) -> Option<&CachedSource> { + self.sources.get(digest) + } + + fn insert(&mut self, digest: [u8; SNAPSHOT_DIGEST_BYTES], source: CachedSource) { + if !self.sources.contains_key(&digest) && self.sources.len() >= MAX_CACHED_SNAPSHOTS { + if let Some(evicted) = self.sources.keys().next().copied() { + self.sources.remove(&evicted); + } + } + self.sources.insert(digest, source); } } pub struct VistaInvocation { config: VistaConfig, + cache: Arc>, + prior_digest: Option<[u8; SNAPSHOT_DIGEST_BYTES]>, + current_source: Option, control: Arc, } @@ -175,9 +204,7 @@ impl ProcessControl { Ordering::Acquire, ) .is_ok(); - if changed - && let ChildOwnership::Live(process_group) = *self.child.lock() - { + if changed && let ChildOwnership::Live(process_group) = *self.child.lock() { let _ = kill_process_group(process_group); } changed @@ -235,27 +262,42 @@ impl CapabilityModule for VistaModule { } fn begin(&self, context: CapabilityContext<'_>) -> Self::Invocation { + let prior_digest = match context.phase() { + CapabilityPhase::Describe => None, + CapabilityPhase::Observe(request) => request + .prior_digest + .as_ref() + .map(|digest| *digest.as_bytes()), + }; VistaInvocation { config: self.config.clone(), + cache: Arc::clone(&self.cache), + prior_digest, + current_source: None, control: Arc::new(ProcessControl::new(context.control().clone())), } } } impl Host for InvocationStore { - fn get(&mut self, request: ArtifactRequest) -> Result { + fn get(&mut self, request: ArtifactRequest) -> Result { self.capability_mut().get(request) } + + fn bind_snapshot(&mut self, digest: Vec) -> Result<(), VistaError> { + self.capability_mut().bind_snapshot(digest) + } } impl VistaInvocation { - fn get(&mut self, request: ArtifactRequest) -> Result { - if !request_is_valid(&request) - || request.slug != self.config.slug - || request.version != self.config.version - { + fn get(&mut self, request: ArtifactRequest) -> Result { + if !request_is_valid(&request) { return Err(VistaError::Denied); } + let prior = self + .prior_digest + .as_ref() + .and_then(|digest| self.cache.lock().get(digest).cloned()); match self.control.termination() { Termination::Cancelled => return Err(VistaError::Cancelled), Termination::TimedOut => return Err(VistaError::DeadlineExceeded), @@ -322,21 +364,22 @@ impl VistaInvocation { let deadline_control = Arc::clone(&self.control); let timer = match thread::Builder::new() .name("st2-vista-deadline".into()) - .spawn(move || loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - deadline_control.terminate(Termination::TimedOut); - return; - } - match completed_rx.recv_timeout(remaining.min(Duration::from_millis(10))) { - Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return, - Err(mpsc::RecvTimeoutError::Timeout) => {} - } - if deadline_control.synchronize_interruption() != Termination::None { - return; + .spawn(move || { + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + deadline_control.terminate(Termination::TimedOut); + return; + } + match completed_rx.recv_timeout(remaining.min(Duration::from_millis(10))) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + if deadline_control.synchronize_interruption() != Termination::None { + return; + } } - }) - { + }) { Ok(timer) => timer, Err(_) => { self.control.kill_and_reap(&mut child); @@ -374,13 +417,48 @@ impl VistaInvocation { || ExitStatus::Signal(status.signal().unwrap_or(0)), ExitStatus::Code, ); - Ok(Outcome { - stdout, - stderr, - stdout_truncated, - stderr_truncated, - exit, - }) + match exit { + ExitStatus::Code(0) => { + let observed_at = prior + .as_ref() + .filter(|prior| prior.manifest_json == stdout) + .map_or_else( + || Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + |prior| prior.observed_at.clone(), + ); + let source = CachedSource { + manifest_json: stdout, + observed_at, + }; + let current = source_to_wit(&source); + self.current_source = Some(source); + Ok(ArtifactResponse::Ok(SourceObservation { + current, + previous: prior.as_ref().map(source_to_wit), + })) + } + exit @ (ExitStatus::Code(_) | ExitStatus::Signal(_)) => { + Ok(ArtifactResponse::CommandFailed(CommandFailure { + stderr, + exit, + })) + } + } + } + + fn bind_snapshot(&mut self, digest: Vec) -> Result<(), VistaError> { + let digest: [u8; SNAPSHOT_DIGEST_BYTES] = + digest.try_into().map_err(|_| VistaError::Denied)?; + let source = self.current_source.take().ok_or(VistaError::Unavailable)?; + self.cache.lock().insert(digest, source); + Ok(()) + } +} + +fn source_to_wit(source: &CachedSource) -> SourceSnapshot { + SourceSnapshot { + manifest_json: source.manifest_json.clone(), + observed_at: source.observed_at.clone(), } } @@ -433,12 +511,7 @@ fn wait_without_reaping(pid: u32) -> std::io::Result<()> { // process-group identity cannot be recycled before ownership is fenced. let result = unsafe { let mut info = std::mem::zeroed::(); - libc::waitid( - libc::P_PID, - pid, - &mut info, - libc::WEXITED | libc::WNOWAIT, - ) + libc::waitid(libc::P_PID, pid, &mut info, libc::WEXITED | libc::WNOWAIT) }; if result == 0 { return Ok(()); @@ -483,9 +556,8 @@ fn resolve_executable_at( } fn executable_is_runnable(path: &Path) -> bool { - std::fs::metadata(path).is_ok_and(|metadata| { - metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 - }) + std::fs::metadata(path) + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) } #[cfg(test)] @@ -502,21 +574,19 @@ mod tests { std::fs::set_permissions(path, permissions).unwrap(); } - fn config( + fn invocation( executable: &Path, cwd: &Path, deadline: Duration, - slug: &str, - version: u64, - ) -> VistaConfig { - VistaConfig::resolve( - executable, - cwd.to_path_buf(), - slug.to_owned(), - version, - deadline, - ) - .unwrap() + control: Arc, + ) -> VistaInvocation { + VistaInvocation { + config: VistaConfig::resolve(executable, cwd.to_path_buf(), deadline).unwrap(), + cache: Arc::new(Mutex::new(SnapshotCache::default())), + prior_digest: None, + current_source: None, + control, + } } fn request(slug: &str, version: u64) -> ArtifactRequest { @@ -534,42 +604,35 @@ mod tests { &executable, "#!/bin/sh\nprintf '%s\\n' \"$#|$1|$2|$3|$4|$5|$6\"\n", ); - let mut invocation = VistaInvocation { - config: config( - &executable, - temporary.path(), - Duration::from_secs(1), - "release-notes", - 7, - ), - control: Arc::new(ProcessControl::detached()), + let mut invocation = invocation( + &executable, + temporary.path(), + Duration::from_secs(1), + Arc::new(ProcessControl::detached()), + ); + let response = invocation.get(request("release-notes", 7)).unwrap(); + let ArtifactResponse::Ok(observation) = response else { + panic!("the successful command did not return a source observation"); }; - let outcome = invocation.get(request("release-notes", 7)).unwrap(); assert_eq!( - outcome.stdout, + observation.current.manifest_json, b"6|artifact|get|release-notes|v7|--output|json\n" ); } #[test] - fn exact_artifact_scope_is_denied_before_spawn() { + fn dynamic_artifact_identity_is_validated_before_spawn() { let temporary = tempfile::tempdir().unwrap(); let executable = temporary.path().join("vista"); let marker = temporary.path().join("spawned"); - write_executable(&executable, "#!/bin/sh\ntouch \"$PWD/spawned\"\n"); - let mut invocation = VistaInvocation { - config: config( - &executable, - temporary.path(), - Duration::from_secs(1), - "release-notes", - 7, - ), - control: Arc::new(ProcessControl::detached()), - }; + write_executable(&executable, "#!/bin/sh\n: > \"$PWD/spawned\"\n"); + let mut first = invocation( + &executable, + temporary.path(), + Duration::from_secs(1), + Arc::new(ProcessControl::detached()), + ); for denied in [ - request("other-valid-slug", 7), - request("release-notes", 8), request("", 7), request("-leading", 7), request("trailing-", 7), @@ -578,13 +641,28 @@ mod tests { request("release-notes", 0), request("release-notes", MAX_VERSION + 1), ] { - assert!(matches!(invocation.get(denied), Err(VistaError::Denied))); + assert!(matches!(first.get(denied), Err(VistaError::Denied))); } assert!(!marker.exists()); + assert!(matches!( + first.get(request("release-notes", 7)), + Ok(ArtifactResponse::Ok(_)) + )); + let mut second = invocation( + &executable, + temporary.path(), + Duration::from_secs(1), + Arc::new(ProcessControl::detached()), + ); + assert!(matches!( + second.get(request("other-valid-slug", MAX_VERSION)), + Ok(ArtifactResponse::Ok(_)) + )); + assert!(marker.exists()); } #[test] - fn artifact_version_is_bounded_by_the_javascript_safe_integer_contract() { + fn artifact_version_is_bounded_by_the_canonical_nineteen_digit_contract() { let temporary = tempfile::tempdir().unwrap(); let executable = temporary.path().join("vista"); write_executable(&executable, "#!/bin/sh\nexit 0\n"); @@ -594,12 +672,53 @@ mod tests { VistaConfig::resolve( &executable, temporary.path().to_path_buf(), - "release".into(), - MAX_VERSION + 1, Duration::from_secs(1), ) - .is_err() + .is_ok() + ); + } + + #[test] + fn snapshot_binding_recovers_the_previous_source_by_digest() { + let temporary = tempfile::tempdir().unwrap(); + let executable = temporary.path().join("vista"); + write_executable(&executable, "#!/bin/sh\nprintf '{\"schemaVersion\":1}'\n"); + let cache = Arc::new(Mutex::new(SnapshotCache::default())); + let mut first = invocation( + &executable, + temporary.path(), + Duration::from_secs(1), + Arc::new(ProcessControl::detached()), + ); + first.cache = Arc::clone(&cache); + let ArtifactResponse::Ok(first_observation) = first.get(request("release", 7)).unwrap() + else { + panic!("the successful command did not return a source observation"); + }; + assert!(first_observation.previous.is_none()); + let digest = [7; SNAPSHOT_DIGEST_BYTES]; + first.bind_snapshot(digest.to_vec()).unwrap(); + + let mut second = invocation( + &executable, + temporary.path(), + Duration::from_secs(1), + Arc::new(ProcessControl::detached()), + ); + second.cache = cache; + second.prior_digest = Some(digest); + let ArtifactResponse::Ok(second_observation) = second.get(request("release", 7)).unwrap() + else { + panic!("the successful command did not return a source observation"); + }; + let previous = second_observation + .previous + .expect("the bound source should be recovered"); + assert_eq!( + previous.manifest_json, + second_observation.current.manifest_json ); + assert_eq!(previous.observed_at, second_observation.current.observed_at); } #[test] @@ -610,16 +729,12 @@ mod tests { write_executable(&executable, "#!/bin/sh\ntouch \"$PWD/spawned\"\n"); let control = Arc::new(ProcessControl::detached()); control.terminate(Termination::Cancelled); - let mut invocation = VistaInvocation { - config: config( - &executable, - temporary.path(), - Duration::from_secs(1), - "valid", - 1, - ), + let mut invocation = invocation( + &executable, + temporary.path(), + Duration::from_secs(1), control, - }; + ); assert!(matches!( invocation.get(request("valid", 1)), Err(VistaError::Cancelled) @@ -645,16 +760,12 @@ mod tests { "#!/bin/sh\n(trap '' HUP; exec \"$PWD/pipe-holder\" --ignored --exact vista::tests::inherited_pipe_holder --nocapture) &\nread marker < \"$PWD/started\"\nprintf 'direct child exited\\n'\nexit 0\n", ); let control = Arc::new(ProcessControl::detached()); - let mut invocation = VistaInvocation { - config: config( - &executable, - temporary.path(), - Duration::from_millis(100), - "valid", - 1, - ), - control: Arc::clone(&control), - }; + let mut invocation = invocation( + &executable, + temporary.path(), + Duration::from_millis(100), + Arc::clone(&control), + ); assert!(matches!( invocation.get(request("valid", 1)), Err(VistaError::DeadlineExceeded) @@ -679,16 +790,12 @@ mod tests { "#!/bin/sh\n(trap '' HUP; exec \"$PWD/pipe-holder\" --ignored --exact vista::tests::inherited_pipe_holder --nocapture) &\nread marker < \"$PWD/started\"\nprintf 'ready\\n' > \"$PWD/cancel-ready\"\nprintf 'direct child exited\\n'\nexit 0\n", ); let control = Arc::new(ProcessControl::detached()); - let mut invocation = VistaInvocation { - config: config( - &executable, - temporary.path(), - Duration::from_secs(10), - "valid", - 1, - ), - control: Arc::clone(&control), - }; + let mut invocation = invocation( + &executable, + temporary.path(), + Duration::from_secs(10), + Arc::clone(&control), + ); let worker = thread::spawn(move || invocation.get(request("valid", 1))); let mut ready = String::new(); let mut ready_pipe = std::fs::File::open(temporary.path().join("cancel-ready")).unwrap(); @@ -717,18 +824,16 @@ mod tests { "#!/bin/sh\n(trap '' HUP; exec \"$PWD/pipe-holder\" --ignored --exact vista::tests::closed_pipe_descendant_holder --nocapture) &\nread marker < \"$PWD/started\"\nexit 0\n", ); let control = Arc::new(ProcessControl::detached()); - let mut invocation = VistaInvocation { - config: config( - &executable, - temporary.path(), - Duration::from_secs(1), - "valid", - 1, - ), - control: Arc::clone(&control), - }; - let outcome = invocation.get(request("valid", 1)).unwrap(); - assert!(matches!(outcome.exit, ExitStatus::Code(0))); + let mut invocation = invocation( + &executable, + temporary.path(), + Duration::from_secs(1), + Arc::clone(&control), + ); + assert!(matches!( + invocation.get(request("valid", 1)), + Ok(ArtifactResponse::Ok(_)) + )); let lock_path = temporary.path().join("descendant-lock"); let (acquired_tx, acquired_rx) = mpsc::sync_channel(0); let waiter = thread::spawn(move || { @@ -744,11 +849,10 @@ mod tests { let acquired = acquired_rx.recv_timeout(Duration::from_secs(1)); if let Err(error) = &acquired { drop(acquired_rx); - let pid: i32 = - std::fs::read_to_string(temporary.path().join("descendant-pid")) - .unwrap() - .parse() - .unwrap(); + let pid: i32 = std::fs::read_to_string(temporary.path().join("descendant-pid")) + .unwrap() + .parse() + .unwrap(); // SAFETY: the fixture wrote its live pid after acquiring the lock. unsafe { libc::kill(pid, libc::SIGKILL) }; waiter.join().unwrap(); diff --git a/crates/st2-resource-providers/tests/github_issue_component.rs b/crates/st2-resource-providers/tests/github_issue_component.rs new file mode 100644 index 00000000..f1661152 --- /dev/null +++ b/crates/st2-resource-providers/tests/github_issue_component.rs @@ -0,0 +1,308 @@ +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use serde_json::{Value, json}; +use st2_resource_protocol::{ObservationResult, SnapshotDigest}; +use st2_resource_wasip2::{ + CapabilityContext, CapabilityModule, Executor, InvocationStore, ObservationRequest, + RuntimeConfig, +}; +use wasmtime::component::{HasSelf, Linker}; + +mod bindings { + wasmtime::component::bindgen!({ + path: "../../wit/github-issue", + world: "github-issue-provider", + }); +} + +use bindings::compoundingtech::st2_github_issue::github_issue::{ + Host, IssueError, IssueRequest, IssueResponse, SourceObject, SourceObservation, SourceSnapshot, +}; + +const IMPORT_NAME: &str = "compoundingtech:st2-github-issue/github-issue@0.1.0"; + +#[derive(Clone, Default)] +struct FixtureModule { + calls: Arc, + bindings: Arc, +} + +struct FixtureInvocation { + calls: Arc, + bindings: Arc, +} + +impl CapabilityModule for FixtureModule { + type Invocation = FixtureInvocation; + + fn import_names(&self) -> &'static [&'static str] { + &[IMPORT_NAME] + } + + fn add_to_linker( + &self, + linker: &mut Linker>, + ) -> Result<(), wasmtime::Error> { + bindings::GithubIssueProvider::add_to_linker::<_, HasSelf<_>>(linker, |state| state) + } + + fn begin(&self, _context: CapabilityContext<'_>) -> Self::Invocation { + FixtureInvocation { + calls: Arc::clone(&self.calls), + bindings: Arc::clone(&self.bindings), + } + } +} + +impl Host for InvocationStore { + fn get(&mut self, request: IssueRequest) -> Result { + assert_eq!(request.owner, "example"); + assert_eq!(request.repo, "demo"); + assert_eq!(request.number, 42); + let call = self.capability().calls.fetch_add(1, Ordering::SeqCst); + match call { + 0 => Ok(IssueResponse::Ok(SourceObservation { + current: source(2, "2026-08-30T11:22:33Z", "2026-08-30T12:34:56Z"), + previous: None, + })), + 1 => Ok(IssueResponse::Ok(SourceObservation { + current: source(3, "2026-08-30T12:22:33Z", "2026-08-30T12:35:56Z"), + previous: Some(source(2, "2026-08-30T11:22:33Z", "2026-08-30T12:34:56Z")), + })), + 2 => Ok(IssueResponse::Ok(SourceObservation { + current: source(3, "2026-08-30T12:22:33Z", "2026-08-30T12:36:56Z"), + previous: Some(source(3, "2026-08-30T12:22:33Z", "2026-08-30T12:35:56Z")), + })), + 3 => Ok(IssueResponse::NotModified), + _ => Err(IssueError::Unavailable), + } + } + + fn bind_snapshot(&mut self, digest: Vec) -> Result<(), IssueError> { + assert_eq!(digest.len(), 32); + self.capability().bindings.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +#[test] +fn component_pins_approved_snapshot_facts_topics_and_atomic_results() { + let module = FixtureModule::default(); + let calls = Arc::clone(&module.calls); + let bindings = Arc::clone(&module.bindings); + let executor = Executor::new(RuntimeConfig::default(), None, module).unwrap(); + let loaded = executor.load(&fs::read(component()).unwrap()).unwrap(); + let descriptor = executor.describe(&loaded, None).unwrap(); + assert_eq!( + descriptor.topics, + ["body", "state", "labels", "assignment", "discussion"] + ); + assert_eq!( + descriptor.snapshot_schema_id, + "dev.schickling.github-issue.snapshot.v1" + ); + assert_eq!(descriptor.snapshot_media_type, "application/json"); + let selector_properties = descriptor.selector_schema["properties"] + .as_object() + .unwrap(); + assert!(selector_properties.contains_key("topics")); + assert!(!selector_properties.contains_key("owner")); + + let first = executor.observe(&loaded, &request(1, None), None).unwrap(); + let first = match first { + ObservationResult::Published { publication } => publication, + other => panic!("first observation must publish, got {other:?}"), + }; + assert_eq!( + first.topics, + ["body", "state", "labels", "assignment", "discussion"] + ); + assert_eq!( + first + .facts + .as_ref() + .unwrap() + .iter() + .map(|fact| (fact.key(), fact.before(), fact.after())) + .collect::>(), + [ + ("issue", None, Some(Some("#42"))), + ("state", None, Some(Some("open"))), + ("comments", None, Some(Some("2"))), + ] + ); + let snapshot: Value = serde_json::from_slice(first.bytes.as_slice()).unwrap(); + assert_eq!( + snapshot, + json!({ + "schema": "dev.schickling.github-issue.snapshot.v1", + "uri": "github-issue://github.com/example/demo/issues/42", + "observedAt": "2026-08-30T12:34:56Z", + "repository": {"owner": "example", "name": "demo"}, + "number": 42, + "issue": { + "title": "Canonical issue", + "body": "Authoritative body", + "state": "open", + "stateReason": null, + "author": "octocat", + "htmlUrl": "https://github.com/example/demo/issues/42", + "locked": false, + "labels": ["a-first", "z-last"], + "assignees": ["a-user", "z-user"], + "milestone": { + "number": 7, + "title": "Ship it", + "state": "open", + "htmlUrl": "https://github.com/example/demo/milestone/7", + "dueOn": "2026-09-01T00:00:00Z" + }, + "createdAt": "2026-08-28T09:00:00Z", + "updatedAt": "2026-08-30T11:23:00Z", + "closedAt": null + }, + "discussion": {"commentCount": 2, "latestUpdatedAt": "2026-08-30T11:22:33Z"}, + "facets": {"open": true, "closed": false, "assigned": true, "hasDiscussion": true} + }) + ); + assert!(!String::from_utf8_lossy(first.bytes.as_slice()).contains("COMMENT BODY")); + + let second = executor + .observe( + &loaded, + &request(2, Some(SnapshotDigest::of(first.bytes.as_slice()))), + None, + ) + .unwrap(); + let second = match second { + ObservationResult::Published { publication } => publication, + other => panic!("discussion transition must publish, got {other:?}"), + }; + assert_eq!(second.topics, ["discussion"]); + let comments = &second.facts.as_ref().unwrap()[2]; + assert_eq!(comments.key(), "comments"); + assert_eq!(comments.before(), Some(Some("2"))); + assert_eq!(comments.after(), Some(Some("3"))); + + let third = executor + .observe( + &loaded, + &request(3, Some(SnapshotDigest::of(second.bytes.as_slice()))), + None, + ) + .unwrap(); + assert_eq!(third, ObservationResult::Unchanged); + let fourth = executor + .observe( + &loaded, + &request(4, Some(SnapshotDigest::of(second.bytes.as_slice()))), + None, + ) + .unwrap(); + assert_eq!(fourth, ObservationResult::Unchanged); + let fifth = executor + .observe( + &loaded, + &request(5, Some(SnapshotDigest::of(second.bytes.as_slice()))), + None, + ) + .unwrap(); + assert!(matches!( + &fifth, + ObservationResult::Failed { + diagnostic: Some(diagnostic) + } if diagnostic == "GitHub is unavailable" + )); + assert_eq!(calls.load(Ordering::SeqCst), 5); + assert_eq!(bindings.load(Ordering::SeqCst), 3); +} + +#[test] +fn component_rejects_old_short_uri_before_import_as_one_failed_result() { + let module = FixtureModule::default(); + let calls = Arc::clone(&module.calls); + let executor = Executor::new(RuntimeConfig::default(), None, module).unwrap(); + let loaded = executor.load(&fs::read(component()).unwrap()).unwrap(); + let result = executor + .observe( + &loaded, + &ObservationRequest { + invocation_id: 9, + uri: "github-issue://example/demo/42".into(), + selector: json!({}), + prior_digest: None, + demand_watermark: Some(9), + }, + None, + ) + .unwrap(); + assert!(matches!( + &result, + ObservationResult::Failed { + diagnostic: Some(diagnostic) + } if diagnostic == "invalid canonical GitHub issue URI" + )); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + +fn request(invocation_id: u64, prior_digest: Option) -> ObservationRequest { + ObservationRequest { + invocation_id, + uri: "github-issue://github.com/example/demo/issues/42".into(), + selector: json!({"topics": ["discussion"]}), + prior_digest, + demand_watermark: Some(invocation_id), + } +} + +fn source(comments: u64, latest_updated_at: &str, observed_at: &str) -> SourceSnapshot { + SourceSnapshot { + issue: object( + "\"issue-v1\"", + json!({ + "title": "Canonical issue", + "body": "Authoritative body", + "state": "open", + "state_reason": null, + "user": {"login": "octocat"}, + "html_url": "https://github.com/example/demo/issues/42", + "locked": false, + "labels": [{"name": "z-last", "color": "ffffff"}, {"name": "a-first", "color": "000000"}], + "assignees": [{"login": "z-user"}, {"login": "a-user"}], + "milestone": { + "number": 7, + "title": "Ship it", + "state": "open", + "html_url": "https://github.com/example/demo/milestone/7", + "due_on": "2026-09-01T00:00:00Z" + }, + "comments": comments, + "created_at": "2026-08-28T09:00:00Z", + "updated_at": "2026-08-30T11:23:00Z", + "closed_at": null + }), + ), + latest_comment: Some(object( + "\"comment-v1\"", + json!([{"updated_at": latest_updated_at}]), + )), + observed_at: observed_at.into(), + } +} + +fn object(etag: &str, value: Value) -> SourceObject { + SourceObject { + etag: Some(etag.into()), + body: serde_json::to_vec(&value).unwrap(), + } +} + +fn component() -> PathBuf { + PathBuf::from( + std::env::var_os("ST2_GITHUB_ISSUE_COMPONENT") + .expect("ST2_GITHUB_ISSUE_COMPONENT is not set"), + ) +} diff --git a/crates/st2-resource-providers/tests/github_pr_component.rs b/crates/st2-resource-providers/tests/github_pr_component.rs index 889c08c1..2211924d 100644 --- a/crates/st2-resource-providers/tests/github_pr_component.rs +++ b/crates/st2-resource-providers/tests/github_pr_component.rs @@ -1,7 +1,7 @@ use std::fs; use std::path::PathBuf; -use std::sync::{Arc, Barrier}; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Barrier}; use serde_json::{Value, json}; use st2_resource_protocol::{ObservationResult, SnapshotDigest}; @@ -19,8 +19,7 @@ mod bindings { } use bindings::compoundingtech::st2_github_pr::github_pr::{ - Host, PullRequestError, PullRequestRequest, PullRequestResponse, SourceObject, - SourceObservation, SourceSnapshot, + Host, PullRequestError, PullRequestRequest, SourceObservation, SourceSnapshot, }; const IMPORT_NAME: &str = "compoundingtech:st2-github-pr/github-pr@0.1.0"; @@ -60,27 +59,24 @@ impl CapabilityModule for FixtureModule { } impl Host for InvocationStore { - fn get( - &mut self, - request: PullRequestRequest, - ) -> Result { + fn get(&mut self, request: PullRequestRequest) -> Result { assert_eq!(request.owner, "example"); assert_eq!(request.repo, "demo"); assert_eq!(request.number, 389); let call = self.capability().calls.fetch_add(1, Ordering::SeqCst); Ok(match call { - 0 => PullRequestResponse::Ok(SourceObservation { + 0 => SourceObservation { current: source(false, "2026-08-30T12:34:56Z"), previous: None, - }), - 1 => PullRequestResponse::Ok(SourceObservation { + }, + 1 => SourceObservation { current: source(true, "2026-08-30T12:35:56Z"), previous: Some(source(false, "2026-08-30T12:34:56Z")), - }), - _ => PullRequestResponse::Ok(SourceObservation { + }, + _ => SourceObservation { current: source(true, "2026-08-30T12:36:56Z"), previous: Some(source(true, "2026-08-30T12:35:56Z")), - }), + }, }) } @@ -124,10 +120,7 @@ impl CapabilityModule for BlockingModule { } impl Host for InvocationStore { - fn get( - &mut self, - _request: PullRequestRequest, - ) -> Result { + fn get(&mut self, _request: PullRequestRequest) -> Result { let capability = self.capability(); capability.entered.wait(); match capability.control.wait_for_interruption() { @@ -142,8 +135,9 @@ impl Host for InvocationStore { } #[test] -fn component_preserves_snapshot_facets_delta_topics_and_semantic_replay() { +fn component_pins_approved_snapshot_facts_topics_and_atomic_results() { let module = FixtureModule::default(); + let calls = Arc::clone(&module.calls); let bindings = Arc::clone(&module.bindings); let executor = Executor::new(RuntimeConfig::default(), None, module).unwrap(); let bytes = fs::read(component()).unwrap(); @@ -163,10 +157,13 @@ fn component_preserves_snapshot_facets_delta_topics_and_semantic_replay() { "dev.schickling.github-pr.snapshot.v1" ); assert_eq!(descriptor.snapshot_media_type, "application/json"); - - let first = executor - .observe(&loaded, &request(1, None), None) + let selector_properties = descriptor.selector_schema["properties"] + .as_object() .unwrap(); + assert!(selector_properties.contains_key("topics")); + assert!(!selector_properties.contains_key("owner")); + + let first = executor.observe(&loaded, &request(1, None), None).unwrap(); let first = match first { ObservationResult::Published { publication } => publication, other => panic!("first observation must publish, got {other:?}"), @@ -180,42 +177,63 @@ fn component_preserves_snapshot_facets_delta_topics_and_semantic_replay() { "terminal" ] ); + assert_eq!( + first + .facts + .as_ref() + .unwrap() + .iter() + .map(|fact| (fact.key(), fact.before(), fact.after())) + .collect::>(), + [ + ("pr", None, Some(Some("#389"))), + ("state", None, Some(Some("open"))), + ("ci", None, Some(Some("failure"))), + ] + ); let snapshot: Value = serde_json::from_slice(first.bytes.as_slice()).unwrap(); assert_eq!( snapshot, json!({ - "ci": { - "checkRuns": [ - {"conclusion": "failure", "detailsUrl": "https://example.invalid/a", "name": "a-build", "status": "completed"}, - {"conclusion": "success", "detailsUrl": "https://example.invalid/z", "name": "z-test", "status": "completed"} - ], - "state": "failure", - "statuses": [ - {"context": "a/build", "description": "failed", "state": "failure", "targetUrl": "https://example.invalid/a"}, - {"context": "z/lint", "description": null, "state": "success", "targetUrl": "https://example.invalid/z"} - ] - }, - "facets": {"ciFailure": true, "mergeConflict": false, "reviewRequested": true, "terminal": false}, - "number": 389, + "schema": "dev.schickling.github-pr.snapshot.v1", + "uri": "github-pr://github.com/example/demo/pull/389", "observedAt": "2026-08-30T12:34:56Z", + "repository": {"owner": "example", "name": "demo"}, + "number": 389, "pullRequest": { "apiUrl": "https://api.github.com/repos/example/demo/pulls/389", - "base": {"ref": "main"}, - "closedAt": null, - "draft": false, - "head": {"ref": "resources", "sha": HEAD_SHA}, "htmlUrl": "https://github.com/example/demo/pull/389", - "mergeable": true, - "mergeableState": "clean", + "title": "Canonical pull request", + "body": "Authoritative PR body", + "state": "open", + "author": "octocat", + "draft": false, "merged": false, "mergedAt": null, - "requestedReviewers": ["a-reviewer", "z-reviewer"], - "requestedTeams": ["team-a", "team-b"], - "state": "open" + "closedAt": null, + "mergeable": true, + "mergeableState": "clean", + "head": {"sha": HEAD_SHA, "ref": "resources"}, + "base": {"ref": "main"}, + "reviewDecision": "REVIEW_REQUIRED", + "requestedReviewers": ["copilot-pull-request-reviewer", "former-reviewer", "z-reviewer"], + "requestedTeams": ["team-a"], + "reviewRequestTotalCount": 4, + "reviewRequestsTruncated": false }, - "repository": {"name": "demo", "owner": "example"}, - "schema": "dev.schickling.github-pr.snapshot.v1", - "uri": "github-pr://example/demo/389" + "ci": { + "state": "failure", + "totalCount": 101, + "truncated": true, + "checkRuns": [ + {"name": "a-build", "status": "completed", "conclusion": "failure", "detailsUrl": "https://github.com/example/demo/actions/a"}, + {"name": "z-test", "status": "completed", "conclusion": "success", "detailsUrl": "https://github.com/example/demo/actions/z"} + ], + "statuses": [ + {"context": "a/build", "state": "failure", "targetUrl": "https://ci.example/a", "description": "failed"} + ] + }, + "facets": {"reviewRequested": true, "ciFailure": true, "mergeConflict": false, "terminal": false} }) ); @@ -239,7 +257,7 @@ fn component_preserves_snapshot_facets_delta_topics_and_semantic_replay() { .iter() .map(|fact| fact.key()) .collect::>(), - ["facets.terminal"] + ["pr", "state", "ci"] ); let third = executor @@ -250,9 +268,38 @@ fn component_preserves_snapshot_facets_delta_topics_and_semantic_replay() { ) .unwrap(); assert_eq!(third, ObservationResult::Unchanged); + assert_eq!(calls.load(Ordering::SeqCst), 3); assert_eq!(bindings.load(Ordering::SeqCst), 3); } +#[test] +fn component_rejects_old_short_uri_before_import_as_one_failed_result() { + let module = FixtureModule::default(); + let calls = Arc::clone(&module.calls); + let executor = Executor::new(RuntimeConfig::default(), None, module).unwrap(); + let loaded = executor.load(&fs::read(component()).unwrap()).unwrap(); + let result = executor + .observe( + &loaded, + &ObservationRequest { + invocation_id: 9, + uri: "github-pr://example/demo/389".into(), + selector: json!({}), + prior_digest: None, + demand_watermark: Some(9), + }, + None, + ) + .unwrap(); + assert!(matches!( + &result, + ObservationResult::Failed { + diagnostic: Some(diagnostic) + } if diagnostic == "invalid canonical GitHub pull request URI" + )); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + #[test] fn component_import_cancellation_is_deterministic() { assert!(matches!( @@ -289,7 +336,7 @@ fn interrupt_blocked_import(interruption: BlockedInterruption) -> ObserveError { let handle = executor.interruption_handle(); let trigger = handle.clone(); let observing = - std::thread::spawn(move || executor.observe(&loaded, &request(4, None), Some(&handle))); + std::thread::spawn(move || executor.observe(&loaded, &request(10, None), Some(&handle))); entered.wait(); match interruption { BlockedInterruption::Cancel => assert!(trigger.cancel()), @@ -301,73 +348,79 @@ fn interrupt_blocked_import(interruption: BlockedInterruption) -> ObserveError { fn request(invocation_id: u64, prior_digest: Option) -> ObservationRequest { ObservationRequest { invocation_id, - uri: "github-pr://example/demo/389".into(), - selector: json!({ - "owner": "example", - "repo": "demo", - "number": 389, - "topics": ["ci.failure", "terminal"] - }), + uri: "github-pr://github.com/example/demo/pull/389".into(), + selector: json!({"topics": ["ci.failure", "terminal"]}), prior_digest, demand_watermark: Some(invocation_id), } } fn source(terminal: bool, observed_at: &str) -> SourceSnapshot { - let state = if terminal { "closed" } else { "open" }; + let mut data = graphql_data(); + let pull = &mut data["repository"]["pullRequest"]; + if terminal { + pull["state"] = json!("CLOSED"); + pull["merged"] = json!(true); + pull["mergedAt"] = json!("2026-08-30T12:35:00Z"); + pull["closedAt"] = json!("2026-08-30T12:35:00Z"); + } SourceSnapshot { - pull_request: object( - "\"pull-v1\"", - json!({ - "number": 389, - "url": "https://api.github.com/repos/example/demo/pulls/389", - "html_url": "https://github.com/example/demo/pull/389", - "state": state, - "draft": false, - "merged": terminal, - "merged_at": if terminal { Some("2026-08-30T12:35:00Z") } else { None }, - "closed_at": if terminal { Some("2026-08-30T12:35:00Z") } else { None }, - "mergeable": true, - "mergeable_state": "clean", - "head": {"sha": HEAD_SHA, "ref": "resources"}, - "base": {"ref": "main"}, - "requested_reviewers": [{"login": "z-reviewer"}, {"login": "a-reviewer"}], - "requested_teams": [{"slug": "team-b"}, {"slug": "team-a"}] - }), - ), - check_runs: object( - "\"checks-v1\"", - json!({ - "check_runs": [ - {"name": "z-test", "status": "completed", "conclusion": "success", "details_url": "https://example.invalid/z"}, - {"name": "a-build", "status": "completed", "conclusion": "failure", "details_url": "https://example.invalid/a"} - ] - }), - ), - combined_status: object( - "\"status-v1\"", - json!({ - "state": "failure", - "statuses": [ - {"context": "z/lint", "state": "success", "target_url": "https://example.invalid/z", "description": null}, - {"context": "a/build", "state": "failure", "target_url": "https://example.invalid/a", "description": "failed"} - ] - }), - ), + graphql_data: serde_json::to_vec(&data).unwrap(), observed_at: observed_at.into(), } } -fn object(etag: &str, value: Value) -> SourceObject { - SourceObject { - etag: Some(etag.into()), - body: serde_json::to_vec(&value).unwrap(), - } +fn graphql_data() -> Value { + json!({ + "repository": { + "pullRequest": { + "url": "https://github.com/example/demo/pull/389", + "title": "Canonical pull request", + "body": "Authoritative PR body", + "state": "OPEN", + "isDraft": false, + "merged": false, + "mergedAt": null, + "closedAt": null, + "mergeable": "MERGEABLE", + "author": {"login": "octocat"}, + "headRefOid": HEAD_SHA, + "headRefName": "resources", + "baseRefName": "main", + "reviewDecision": "REVIEW_REQUIRED", + "reviewRequests": { + "totalCount": 4, + "nodes": [ + {"requestedReviewer": {"__typename": "User", "login": "z-reviewer"}}, + {"requestedReviewer": {"__typename": "Team", "slug": "team-a"}}, + {"requestedReviewer": {"__typename": "Bot", "login": "copilot-pull-request-reviewer"}}, + {"requestedReviewer": {"__typename": "Mannequin", "login": "former-reviewer"}} + ] + }, + "commits": { + "nodes": [{ + "commit": { + "statusCheckRollup": { + "state": "FAILURE", + "contexts": { + "totalCount": 101, + "nodes": [ + {"__typename": "CheckRun", "name": "z-test", "status": "COMPLETED", "conclusion": "SUCCESS", "detailsUrl": "https://github.com/example/demo/actions/z"}, + {"__typename": "CheckRun", "name": "a-build", "status": "COMPLETED", "conclusion": "FAILURE", "detailsUrl": "https://github.com/example/demo/actions/a"}, + {"__typename": "StatusContext", "context": "a/build", "state": "FAILURE", "targetUrl": "https://ci.example/a", "description": "failed"} + ] + } + } + } + }] + } + } + } + }) } fn component() -> PathBuf { PathBuf::from( - std::env::var_os("ST2_GITHUB_PR_COMPONENT") - .expect("ST2_GITHUB_PR_COMPONENT is not set"), + std::env::var_os("ST2_GITHUB_PR_COMPONENT").expect("ST2_GITHUB_PR_COMPONENT is not set"), ) } diff --git a/docs/vrs/07-resource-profile/spec.md b/docs/vrs/07-resource-profile/spec.md index 2c99e277..b196962e 100644 --- a/docs/vrs/07-resource-profile/spec.md +++ b/docs/vrs/07-resource-profile/spec.md @@ -419,7 +419,7 @@ Agent Spec KDL carries normalized selector JSON as a `selector` raw-string property: ```kdl -resource "pr" uri="github-pr://example/1" reason="Review." \ +resource "pr" uri="github-pr://github.com/example/project/pull/42" reason="Review." \ selector=#"{"topics":["ci.failure","review.requested"]}"# ``` @@ -479,6 +479,56 @@ process API. Every non-foundation import must: For example, a GitHub Issue source capability may accept a typed `{ owner, repository, number }` and return a bounded typed issue response while the host fixes HTTPS endpoint policy, authentication, redirects, and deadlines. + +The built-in GitHub capabilities authorize the fixed `github.com` read API, +not one catalog-pinned Resource identity: + +```kdl +github-pr auth-executable="/nix/store/.../bin/gh" connect-timeout-ms=3000 total-timeout-ms=10000 +github-issue auth-executable="/nix/store/.../bin/gh" connect-timeout-ms=3000 total-timeout-ms=10000 +``` + +The catalog fixes an absolute GitHub CLI executable. The host uses that +executable to resolve durable GitHub CLI authentication for `github.com`, +retains the bearer value as sensitive host state, fixes API origin, operation, +headers, response limits, redirect policy, deadlines, cancellation, and public +DNS admission, and never places a token in catalog KDL or guest memory. A +missing credential fails the authenticated PR GraphQL operation; issue REST +reads may remain anonymous for public repositories. The component parses the +Resource identity and passes only validated `{ owner, repo, number }` values to +the typed capability. This lets one scheme profile observe every repository +visible to that user credential without granting the guest a URL or generic +HTTP capability. + +`github-pr` accepts only +`github-pr://github.com///pull/`. Its selector contains +only optional `topics`; identity fields in selectors and the former short URI +are rejected. It performs one fixed GraphQL query with bounded review-request +and status-rollup connections. Snapshot schema +`dev.schickling.github-pr.snapshot.v1` contains normalized repository, +pull-request, CI, and facet data. Initial publications emit +`ci.failure`, `mergeability.conflict`, `review.requested`, and `terminal`; +later publications emit only changed facet topics. Ordered facts are `pr`, +`state`, and `ci`. + +`github-issue` accepts only +`github-issue://github.com///issues/` with the same +topics-only selector rule. It conditionally reads the issue and, when comments +exist, exactly the latest comment page with `per_page=1`; only the comment +`updated_at` metadata crosses into normalization. Snapshot schema +`dev.schickling.github-issue.snapshot.v1` contains normalized issue, +discussion, and facet data. Initial publications emit `body`, `state`, +`labels`, `assignment`, and `discussion`; later publications emit only topics +whose corresponding semantic fields changed. Ordered facts are `issue`, +`state`, and `comments`. + +Both host adapters retain bounded source state by authoritative snapshot +digest; the issue adapter additionally retains validated REST ETags. An issue +source-level 304 or any snapshot differing only in `observedAt` produces +`unchanged`; every other invocation produces exactly one atomic +`published`, `unchanged`, or redacted `failed` result. Partial multi-request +observations are never published. + A local PTY statistics capability may accept a closed `scope` variant while the host fixes the executable, argument shape, empty environment, working directory, output caps, deadline, and process containment. An interface that diff --git a/flake.nix b/flake.nix index f4963f1d..7a30cdaf 100644 --- a/flake.nix +++ b/flake.nix @@ -161,6 +161,10 @@ "run" "--test" "driver_expansion" + # Lifecycle tests fork while holding temporary sockets and executables. + # Serial execution prevents sibling tests from inheriting those live handles. + "--" + "--test-threads=1" ]; # A few unit tests write under $HOME; the sandbox HOME is not writable. @@ -297,6 +301,8 @@ "st2-resource-providers" "--lib" "--test" + "github_issue_component" + "--test" "github_pr_component" "-p" "st2" diff --git a/src/catalog.rs b/src/catalog.rs index f71d41b7..24dcfdce 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -59,40 +59,27 @@ pub struct DeclaredProfileRuntime { #[derive(Debug, Clone, PartialEq, Eq)] pub enum DeclaredProviderCapability { GitHubIssue { - owner: String, - repo: String, - number: u64, + auth_executable: String, connect_timeout_ms: u64, total_timeout_ms: u64, }, GitHubPr { - owner: String, - repo: String, - number: u64, + auth_executable: String, connect_timeout_ms: u64, total_timeout_ms: u64, }, PtyStats { executable: String, cwd: String, - scope: DeclaredPtyStatsScope, deadline_ms: u64, }, Vista { executable: String, cwd: String, - slug: String, - version: u64, deadline_ms: u64, }, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DeclaredPtyStatsScope { - All, - Session(String), -} - /// What `/catalog.kdl` declares. An absent file leaves every field empty. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct CatalogConfig { @@ -103,6 +90,12 @@ pub struct CatalogConfig { pub profiles: Vec, } +/// The catalog fields that raw-preimage repair may interpret from an otherwise invalid catalog. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct CatalogEnvelope { + pub pty_root: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ResolvedProfileModule { CatalogRelative(PathBuf), @@ -137,28 +130,7 @@ pub fn parse(text: &str) -> anyhow::Result { anyhow::bail!("catalog block declared more than once"); } seen_catalog = true; - let Some(children) = node.children() else { - continue; - }; - for child in children.nodes() { - match child.name().value() { - "pty-root" => { - let value = child - .get(0) - .and_then(|v| v.as_string()) - .filter(|v| !v.is_empty()) - .ok_or_else(|| { - anyhow::anyhow!( - "pty-root needs a non-empty path, e.g. pty-root \"/run/agents/pty\"" - ) - })?; - config.pty_root = Some(value.to_string()); - } - other => { - anyhow::bail!("unknown catalog field '{other}' (expected pty-root)") - } - } - } + config.pty_root = parse_catalog_node(node)?; } "profile" => { let profile = parse_profile(node)?; @@ -176,6 +148,53 @@ pub fn parse(text: &str) -> anyhow::Result { Ok(config) } +/// Parse only the catalog envelope while treating every other top-level declaration as opaque. +/// +/// Raw-preimage repair uses this parser so an obsolete profile or agent grammar cannot prevent +/// replacement, while the PTY-root boundary remains subject to the ordinary catalog semantics. +pub(crate) fn parse_envelope(text: &str) -> anyhow::Result { + let doc = KdlDocument::parse(text).map_err(|e| anyhow::anyhow!("KDL parse error: {e}"))?; + let mut envelope = CatalogEnvelope::default(); + let mut seen_catalog = false; + for node in doc.nodes() { + if node.name().value() != "catalog" { + continue; + } + if seen_catalog { + anyhow::bail!("catalog block declared more than once"); + } + seen_catalog = true; + envelope.pty_root = parse_catalog_node(node)?; + } + Ok(envelope) +} + +fn parse_catalog_node(node: &kdl::KdlNode) -> anyhow::Result> { + let Some(children) = node.children() else { + return Ok(None); + }; + let mut pty_root = None; + for child in children.nodes() { + match child.name().value() { + "pty-root" => { + anyhow::ensure!(pty_root.is_none(), "pty-root declared more than once"); + let value = child + .get(0) + .and_then(|v| v.as_string()) + .filter(|v| !v.is_empty()) + .ok_or_else(|| { + anyhow::anyhow!( + "pty-root needs a non-empty path, e.g. pty-root \"/run/agents/pty\"" + ) + })?; + pty_root = Some(value.to_string()); + } + other => anyhow::bail!("unknown catalog field '{other}' (expected pty-root)"), + } + } + Ok(pty_root) +} + fn parse_profile(node: &kdl::KdlNode) -> anyhow::Result { if node.entries().len() != 1 { anyhow::bail!("profile takes exactly one quoted URI scheme and no properties"); @@ -306,7 +325,9 @@ fn parse_profile(node: &kdl::KdlNode) -> anyhow::Result { anyhow::anyhow!("profile '{scheme}': runtime needs exactly one component path") })?, capability: capability.ok_or_else(|| { - anyhow::anyhow!("profile '{scheme}': runtime needs exactly one typed capability") + anyhow::anyhow!( + "profile '{scheme}': runtime needs exactly one typed capability" + ) })?, demand, }); @@ -388,16 +409,17 @@ fn parse_github_issue_capability( node: &kdl::KdlNode, ) -> anyhow::Result { anyhow::ensure!( - node.entries().len() == 5 && node.entries().iter().all(|entry| entry.name().is_some()), - "profile '{scheme}': github-issue requires owner, repo, number, \ - connect-timeout-ms, and total-timeout-ms properties" + node.entries().len() == 3 && node.entries().iter().all(|entry| entry.name().is_some()), + "profile '{scheme}': github-issue requires auth-executable, connect-timeout-ms, and \ + total-timeout-ms properties" + ); + let auth_executable = required_string_property(scheme, node, "auth-executable")?; + anyhow::ensure!( + Path::new(&auth_executable).is_absolute(), + "profile '{scheme}': GitHub authentication executable must be absolute" ); - let owner = required_string_property(scheme, node, "owner")?; - let repo = required_string_property(scheme, node, "repo")?; - let number = required_u64_property(scheme, node, "number")?; let connect_timeout_ms = required_u64_property(scheme, node, "connect-timeout-ms")?; let total_timeout_ms = required_u64_property(scheme, node, "total-timeout-ms")?; - anyhow::ensure!(number > 0, "profile '{scheme}': GitHub issue number must be positive"); anyhow::ensure!( connect_timeout_ms > 0 && connect_timeout_ms <= total_timeout_ms @@ -405,9 +427,7 @@ fn parse_github_issue_capability( "profile '{scheme}': GitHub deadlines must be positive, ordered, and at most 60000ms" ); Ok(DeclaredProviderCapability::GitHubIssue { - owner, - repo, - number, + auth_executable, connect_timeout_ms, total_timeout_ms, }) @@ -418,19 +438,17 @@ fn parse_github_pr_capability( node: &kdl::KdlNode, ) -> anyhow::Result { anyhow::ensure!( - node.entries().len() == 5 && node.entries().iter().all(|entry| entry.name().is_some()), - "profile '{scheme}': github-pr requires owner, repo, number, \ - connect-timeout-ms, and total-timeout-ms properties" + node.entries().len() == 3 && node.entries().iter().all(|entry| entry.name().is_some()), + "profile '{scheme}': github-pr requires auth-executable, connect-timeout-ms, and \ + total-timeout-ms properties" ); - let owner = required_string_property(scheme, node, "owner")?; - let repo = required_string_property(scheme, node, "repo")?; - let number = required_u64_property(scheme, node, "number")?; - let connect_timeout_ms = required_u64_property(scheme, node, "connect-timeout-ms")?; - let total_timeout_ms = required_u64_property(scheme, node, "total-timeout-ms")?; + let auth_executable = required_string_property(scheme, node, "auth-executable")?; anyhow::ensure!( - number > 0, - "profile '{scheme}': GitHub pull request number must be positive" + Path::new(&auth_executable).is_absolute(), + "profile '{scheme}': GitHub authentication executable must be absolute" ); + let connect_timeout_ms = required_u64_property(scheme, node, "connect-timeout-ms")?; + let total_timeout_ms = required_u64_property(scheme, node, "total-timeout-ms")?; anyhow::ensure!( connect_timeout_ms > 0 && connect_timeout_ms <= total_timeout_ms @@ -438,9 +456,7 @@ fn parse_github_pr_capability( "profile '{scheme}': GitHub deadlines must be positive, ordered, and at most 60000ms" ); Ok(DeclaredProviderCapability::GitHubPr { - owner, - repo, - number, + auth_executable, connect_timeout_ms, total_timeout_ms, }) @@ -451,8 +467,8 @@ fn parse_pty_stats_capability( node: &kdl::KdlNode, ) -> anyhow::Result { anyhow::ensure!( - node.entries().len() == 4 && node.entries().iter().all(|entry| entry.name().is_some()), - "profile '{scheme}': pty-stats requires executable, cwd, scope, and deadline-ms properties" + node.entries().len() == 3 && node.entries().iter().all(|entry| entry.name().is_some()), + "profile '{scheme}': pty-stats requires executable, cwd, and deadline-ms properties" ); let executable = required_string_property(scheme, node, "executable")?; let cwd = required_string_property(scheme, node, "cwd")?; @@ -461,20 +477,9 @@ fn parse_pty_stats_capability( deadline_ms > 0 && deadline_ms <= 60_000, "profile '{scheme}': PTY deadline must be between 1ms and 60000ms" ); - let scope = required_string_property(scheme, node, "scope")?; - let scope = if scope == "all" { - DeclaredPtyStatsScope::All - } else if let Some(session) = scope.strip_prefix("session:").filter(|value| !value.is_empty()) { - DeclaredPtyStatsScope::Session(session.to_owned()) - } else { - anyhow::bail!( - "profile '{scheme}': PTY scope must be 'all' or 'session:'" - ); - }; Ok(DeclaredProviderCapability::PtyStats { executable, cwd, - scope, deadline_ms, }) } @@ -484,18 +489,12 @@ fn parse_vista_capability( node: &kdl::KdlNode, ) -> anyhow::Result { anyhow::ensure!( - node.entries().len() == 5 && node.entries().iter().all(|entry| entry.name().is_some()), - "profile '{scheme}': vista requires executable, cwd, slug, version, and deadline-ms properties" + node.entries().len() == 3 && node.entries().iter().all(|entry| entry.name().is_some()), + "profile '{scheme}': vista requires executable, cwd, and deadline-ms properties" ); let executable = required_string_property(scheme, node, "executable")?; let cwd = required_string_property(scheme, node, "cwd")?; - let slug = required_string_property(scheme, node, "slug")?; - let version = required_u64_property(scheme, node, "version")?; let deadline_ms = required_u64_property(scheme, node, "deadline-ms")?; - anyhow::ensure!( - valid_vista_slug(&slug) && (1..=9_007_199_254_740_991).contains(&version), - "profile '{scheme}': Vista artifact scope is invalid" - ); anyhow::ensure!( deadline_ms > 0 && deadline_ms <= 60_000, "profile '{scheme}': Vista deadline must be between 1ms and 60000ms" @@ -503,22 +502,10 @@ fn parse_vista_capability( Ok(DeclaredProviderCapability::Vista { executable, cwd, - slug, - version, deadline_ms, }) } -fn valid_vista_slug(slug: &str) -> bool { - !slug.is_empty() - && slug.len() <= 128 - && slug.bytes().enumerate().all(|(index, byte)| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || (byte == b'-' && index > 0) - }) - && !slug.ends_with('-') - && !slug.contains("--") -} - fn required_string_property( scheme: &str, node: &kdl::KdlNode, @@ -536,11 +523,7 @@ fn required_string_property( }) } -fn required_u64_property( - scheme: &str, - node: &kdl::KdlNode, - name: &str, -) -> anyhow::Result { +fn required_u64_property(scheme: &str, node: &kdl::KdlNode, name: &str) -> anyhow::Result { node.get(name) .and_then(|value| value.as_integer()) .and_then(|value| u64::try_from(value).ok()) @@ -560,6 +543,18 @@ pub fn load(catalog_root: &Path) -> anyhow::Result { Err(e) => Err(e.into()), } } + +/// Read only the envelope of `/catalog.kdl`. +/// +/// A missing file is the default envelope, matching [`load`]. Non-envelope declarations must be +/// syntactically valid KDL but are otherwise left uninterpreted for raw-preimage repair. +pub(crate) fn load_envelope(catalog_root: &Path) -> anyhow::Result { + match std::fs::read_to_string(config_path(catalog_root)) { + Ok(text) => parse_envelope(&text), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(CatalogEnvelope::default()), + Err(e) => Err(e.into()), + } +} /// Resolve one declared module using the same expansion as runtime registry construction while /// preserving whether the module belongs to the catalog transaction. pub(crate) fn resolve_profile_module( @@ -985,7 +980,7 @@ mod tests { runtime { component "components/github-issue.wasm" demand #true - github-issue owner="rust-lang" repo="rust" number=1 connect-timeout-ms=3000 total-timeout-ms=10000 + github-issue auth-executable="/nix/store/example/bin/gh" connect-timeout-ms=3000 total-timeout-ms=10000 } } "#, @@ -996,9 +991,7 @@ mod tests { Some(DeclaredProfileRuntime { component: "components/github-issue.wasm".into(), capability: DeclaredProviderCapability::GitHubIssue { - owner: "rust-lang".into(), - repo: "rust".into(), - number: 1, + auth_executable: "/nix/store/example/bin/gh".into(), connect_timeout_ms: 3000, total_timeout_ms: 10000, }, @@ -1010,19 +1003,19 @@ mod tests { r#"profile "dev.x" { wasm "x.wasm"; runtime "shell" { component "x.wasm" } }"#, r#"profile "dev.x" { wasm "x.wasm"; runtime { } }"#, r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x.wasm" } }"#, - r#"profile "dev.x" { wasm "x.wasm"; runtime { component ""; pty-stats executable="pty" cwd="/" scope="all" deadline-ms=1000 } }"#, - r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x"; component "y"; pty-stats executable="pty" cwd="/" scope="all" deadline-ms=1000 } }"#, + r#"profile "dev.x" { wasm "x.wasm"; runtime { component ""; pty-stats executable="pty" cwd="/" deadline-ms=1000 } }"#, + r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x"; component "y"; pty-stats executable="pty" cwd="/" deadline-ms=1000 } }"#, r#"profile "dev.x" { wasm "x.wasm"; runtime { argv "x" } }"#, - r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x"; pty-stats executable="pty" cwd="/" scope="all" deadline-ms=1000; github-issue owner="o" repo="r" number=1 connect-timeout-ms=1 total-timeout-ms=2 } }"#, - r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x"; demand #true; demand #true; pty-stats executable="pty" cwd="/" scope="all" deadline-ms=1000 } }"#, - r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x"; pty-stats executable="pty" cwd="/" scope="shell" deadline-ms=1000 } }"#, + r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x"; pty-stats executable="pty" cwd="/" deadline-ms=1000; github-issue auth-executable="/bin/gh" connect-timeout-ms=1 total-timeout-ms=2 } }"#, + r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x"; demand #true; demand #true; pty-stats executable="pty" cwd="/" deadline-ms=1000 } }"#, + r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x"; pty-stats executable="pty" cwd="/" deadline-ms=1000 extra="no" } }"#, ] { assert!(parse(malformed).is_err(), "expected error for: {malformed}"); } } #[test] - fn github_pr_runtime_capability_is_exact_and_bounded() { + fn github_pr_runtime_capability_authorizes_dynamic_resources_with_bounded_transport() { let config = parse( r#" profile "github-pr" { @@ -1030,7 +1023,7 @@ mod tests { runtime { component "components/github-pr.component.wasm" demand #true - github-pr owner="example" repo="demo" number=389 connect-timeout-ms=3000 total-timeout-ms=10000 + github-pr auth-executable="/nix/store/example/bin/gh" connect-timeout-ms=3000 total-timeout-ms=10000 } } "#, @@ -1041,9 +1034,7 @@ mod tests { Some(DeclaredProfileRuntime { component: "components/github-pr.component.wasm".into(), capability: DeclaredProviderCapability::GitHubPr { - owner: "example".into(), - repo: "demo".into(), - number: 389, + auth_executable: "/nix/store/example/bin/gh".into(), connect_timeout_ms: 3000, total_timeout_ms: 10000, }, @@ -1051,16 +1042,17 @@ mod tests { }) ); for malformed in [ - r#"profile "github-pr" { wasm "x"; runtime { component "x"; github-pr owner="o" repo="r" number=0 connect-timeout-ms=1 total-timeout-ms=2 } }"#, - r#"profile "github-pr" { wasm "x"; runtime { component "x"; github-pr owner="o" repo="r" number=1 connect-timeout-ms=3 total-timeout-ms=2 } }"#, - r#"profile "github-pr" { wasm "x"; runtime { component "x"; github-pr owner="o" repo="r" number=1 connect-timeout-ms=1 total-timeout-ms=60001 } }"#, + r#"profile "github-pr" { wasm "x"; runtime { component "x"; github-pr auth-executable="/bin/gh" owner="o" connect-timeout-ms=1 total-timeout-ms=2 } }"#, + r#"profile "github-pr" { wasm "x"; runtime { component "x"; github-pr auth-executable="gh" connect-timeout-ms=1 total-timeout-ms=2 } }"#, + r#"profile "github-pr" { wasm "x"; runtime { component "x"; github-pr auth-executable="/bin/gh" connect-timeout-ms=3 total-timeout-ms=2 } }"#, + r#"profile "github-pr" { wasm "x"; runtime { component "x"; github-pr auth-executable="/bin/gh" connect-timeout-ms=1 total-timeout-ms=60001 } }"#, ] { assert!(parse(malformed).is_err(), "expected error for: {malformed}"); } } #[test] - fn vista_runtime_capability_is_exact_and_bounded() { + fn vista_runtime_capability_authorizes_dynamic_artifacts_with_bounded_execution() { let config = parse( r#" profile "vista" { @@ -1068,7 +1060,7 @@ mod tests { runtime { component "components/vista.component.wasm" demand #true - vista executable="/nix/store/example/bin/vista" cwd="/var/empty" slug="release-notes" version=7 deadline-ms=10000 + vista executable="/nix/store/example/bin/vista" cwd="/var/empty" deadline-ms=10000 } } "#, @@ -1081,20 +1073,15 @@ mod tests { capability: DeclaredProviderCapability::Vista { executable: "/nix/store/example/bin/vista".into(), cwd: "/var/empty".into(), - slug: "release-notes".into(), - version: 7, deadline_ms: 10000, }, demand: true, }) ); for malformed in [ - r#"profile "vista" { wasm "x"; runtime { component "x"; vista executable="vista" cwd="/" slug="release" version=1 deadline-ms=0 } }"#, - r#"profile "vista" { wasm "x"; runtime { component "x"; vista executable="vista" cwd="/" slug="release" version=1 deadline-ms=60001 } }"#, - r#"profile "vista" { wasm "x"; runtime { component "x"; vista executable="vista" cwd="/" slug="-release" version=1 deadline-ms=1 } }"#, - r#"profile "vista" { wasm "x"; runtime { component "x"; vista executable="vista" cwd="/" slug="release" version=0 deadline-ms=1 } }"#, - r#"profile "vista" { wasm "x"; runtime { component "x"; vista executable="vista" cwd="/" slug="release" version=9007199254740992 deadline-ms=1 } }"#, - r#"profile "vista" { wasm "x"; runtime { component "x"; vista executable="vista" cwd="/" slug="release" version=1 deadline-ms=1 extra="no" } }"#, + r#"profile "vista" { wasm "x"; runtime { component "x"; vista executable="vista" cwd="/" deadline-ms=0 } }"#, + r#"profile "vista" { wasm "x"; runtime { component "x"; vista executable="vista" cwd="/" deadline-ms=60001 } }"#, + r#"profile "vista" { wasm "x"; runtime { component "x"; vista executable="vista" cwd="/" deadline-ms=1 extra="no" } }"#, ] { assert!(parse(malformed).is_err(), "expected error for: {malformed}"); } diff --git a/src/catalog_transaction.rs b/src/catalog_transaction.rs index 874525b6..c5173358 100644 --- a/src/catalog_transaction.rs +++ b/src/catalog_transaction.rs @@ -1171,9 +1171,13 @@ pub fn snapshot(request: SnapshotRequest) -> Result { !catalog_is_strictly_valid(&catalog), "raw-preimage snapshot refuses an already-valid catalog" ); - let incumbent_config = crate::catalog::load(&catalog) + let incumbent_envelope = crate::catalog::load_envelope(&catalog) .context("raw-preimage snapshot requires a valid incumbent catalog envelope")?; - validate_external_pty_root(&catalog, &incumbent_config, "raw-preimage snapshot v1")?; + validate_external_pty_root( + &catalog, + incumbent_envelope.pty_root.as_deref(), + "raw-preimage snapshot v1", + )?; let projection = project_raw_current(&catalog)?; validate_projection_link_counts(&catalog, &projection, "raw live catalog")?; projection @@ -1278,7 +1282,11 @@ pub fn bootstrap(request: BootstrapRequest) -> Result { materialize_projection(&desired, admission.path())?; validate_full_catalog(admission.path())?; let desired_config = crate::catalog::load(admission.path())?; - validate_external_pty_root(&catalog, &desired_config, "catalog bootstrap v1")?; + validate_external_pty_root( + &catalog, + desired_config.pty_root.as_deref(), + "catalog bootstrap v1", + )?; match fs::symlink_metadata(&catalog) { Ok(_) => { @@ -1593,7 +1601,11 @@ pub fn apply(request: ApplyRequest) -> Result { materialize_projection(&desired, admission.path())?; validate_full_catalog(admission.path())?; let desired_config = crate::catalog::load(admission.path())?; - validate_external_pty_root(&catalog, &desired_config, "catalog apply v1")?; + validate_external_pty_root( + &catalog, + desired_config.pty_root.as_deref(), + "catalog apply v1", + )?; let stage_name = stage_name(&desired.root_sha256); let stage_path = control.join(&stage_name); @@ -1610,14 +1622,16 @@ pub fn apply(request: ApplyRequest) -> Result { "raw-preimage apply refuses an already-valid catalog" ); } - let live_config = if raw_preimage { - crate::catalog::load(&catalog) - .context("raw-preimage apply requires a valid incumbent catalog envelope")? + let live_pty_root = if raw_preimage { + let live_envelope = crate::catalog::load_envelope(&catalog) + .context("raw-preimage apply requires a valid incumbent catalog envelope")?; + effective_pty_root(&catalog, live_envelope.pty_root.as_deref()) } else { - crate::catalog::load(&catalog)? + let live_config = crate::catalog::load(&catalog)?; + effective_pty_root(&catalog, live_config.pty_root.as_deref()) }; - let same_pty_root = effective_pty_root(&catalog, &live_config) - == effective_pty_root(&catalog, &desired_config); + let same_pty_root = + live_pty_root == effective_pty_root(&catalog, desired_config.pty_root.as_deref()); if !raw_preimage { cleanup_writer_temporaries(&catalog)?; } @@ -2333,7 +2347,6 @@ pub(crate) fn read_provider_component(root: &Path, relative: &Path) -> Result Result<()> { Ok(()) } -fn effective_pty_root(live_catalog: &Path, config: &crate::catalog::CatalogConfig) -> PathBuf { - match &config.pty_root { +fn effective_pty_root(live_catalog: &Path, declared_pty_root: Option<&str>) -> PathBuf { + match declared_pty_root { Some(declared) => live_catalog.join(crate::expand::expand_catalog(declared, live_catalog)), None => live_catalog.join("pty"), } @@ -2920,14 +2933,14 @@ fn validate_live_workspace_facts(catalog: &Path, facts: &BTreeSet) -> Re fn validate_external_pty_root( catalog: &Path, - config: &crate::catalog::CatalogConfig, + declared_pty_root: Option<&str>, operation: &str, ) -> Result<()> { anyhow::ensure!( - config.pty_root.is_some(), + declared_pty_root.is_some(), "{operation} requires an explicit external pty-root" ); - let pty_root = lexical_absolute(&effective_pty_root(catalog, config))?; + let pty_root = lexical_absolute(&effective_pty_root(catalog, declared_pty_root))?; anyhow::ensure!( !pty_root.starts_with(catalog), "{operation} requires pty-root outside the catalog: {}", diff --git a/src/resource_profile_supervisor.rs b/src/resource_profile_supervisor.rs index abc77753..68feaddc 100644 --- a/src/resource_profile_supervisor.rs +++ b/src/resource_profile_supervisor.rs @@ -6,9 +6,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Component, Path, PathBuf}; -use std::sync::{Arc, Mutex}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::{self, Receiver, SyncSender}; +use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, SystemTime}; @@ -26,7 +26,7 @@ use st2_resource_protocol::ProposalFence; #[cfg(feature = "wasip2-provider-runtime")] use st2_resource_providers::{ GitHubIssueConfig, GitHubIssueModule, GitHubPrConfig, GitHubPrModule, PtyStatsConfig, - PtyStatsModule, PtyStatsScope, VistaConfig, VistaModule, + PtyStatsModule, VistaConfig, VistaModule, }; #[cfg(feature = "wasip2-provider-runtime")] use st2_resource_wasip2::{ @@ -280,8 +280,10 @@ impl ObservationCancellation { #[cfg(feature = "wasip2-provider-runtime")] fn catch_observation( - observe: impl FnOnce( - ) -> Result, + observe: impl FnOnce() -> Result< + st2_resource_protocol::ObservationResult, + st2_resource_wasip2::ObserveError, + >, ) -> Result { std::panic::catch_unwind(std::panic::AssertUnwindSafe(observe)) .map_err(|_| "provider observation panicked".to_owned())? @@ -337,8 +339,6 @@ impl ComponentSnapshotCache { } } - - #[derive(Debug, Clone, PartialEq)] struct DesiredBinding { stable_key: String, @@ -801,8 +801,7 @@ impl Worker { let spawn = thread::Builder::new() .name(format!("st2-resource-observe-{job_id}")) .spawn(move || { - let result = - catch_observation(|| provider.observe(&request, &thread_cancellation)); + let result = catch_observation(|| provider.observe(&request, &thread_cancellation)); let _ = completion_tx.send(Msg::ObservationCompleted(ObservationCompletion { job_id, runtime_key: thread_runtime_key, @@ -868,8 +867,7 @@ impl Worker { && active.registration == completion.authority.registration && active.desired.generation == completion.fence.generation() && active.revision == completion.fence.revision() - && active.catch_up.state().current_snapshot_digest() - == completion.fence.prior_digest() + && active.catch_up.state().current_snapshot_digest() == completion.fence.prior_digest() && active .demand .in_flight @@ -881,11 +879,11 @@ impl Worker { match completion.result { Ok(result) => { let failure_detail = match &result { - st2_resource_protocol::ObservationResult::Failed { diagnostic } => { - Some(diagnostic.clone().unwrap_or_else(|| { - "provider returned a failed observation".to_owned() - })) - } + st2_resource_protocol::ObservationResult::Failed { diagnostic } => Some( + diagnostic + .clone() + .unwrap_or_else(|| "provider returned a failed observation".to_owned()), + ), _ => None, }; let message = RuntimeMessage::ObservationResult { @@ -900,7 +898,8 @@ impl Worker { if let Err(error) = runtime.accept(message, &catalog_root, &this_host) { if let Some(active) = runtime.bindings.get_mut(&completion.stable_key) { active.health.state = RuntimeHealthState::Degraded; - active.health.detail = Some(format!("provider proposal rejected: {error:#}")); + active.health.detail = + Some(format!("provider proposal rejected: {error:#}")); let _ = settle_active_demand( &runtime.request_dir, &runtime.receipt_dir, @@ -1145,7 +1144,6 @@ impl Worker { } } - fn stop_all(&mut self) { for (_, mut runtime) in std::mem::take(&mut self.runtimes) { runtime.stop(); @@ -1220,8 +1218,6 @@ fn validate_provider_descriptor( Ok(()) } - - #[cfg(feature = "wasip2-provider-runtime")] impl ProviderRuntime { fn cancellation(&self) -> ObservationCancellation { @@ -1366,25 +1362,20 @@ impl RuntimeProcess { { let provider = match &sample.runtime.capability { crate::catalog::DeclaredProviderCapability::GitHubIssue { - owner, - repo, - number, + auth_executable, connect_timeout_ms, total_timeout_ms, } => { + let auth_executable = + crate::expand::expand_catalog(auth_executable, catalog_root); let module = GitHubIssueModule::new(GitHubIssueConfig { - owner: owner.clone(), - repo: repo.clone(), - number: *number, + auth_executable: PathBuf::from(auth_executable), connect_timeout: Duration::from_millis(*connect_timeout_ms), total_timeout: Duration::from_millis(*total_timeout_ms), }) .map_err(anyhow::Error::msg)?; - let executor = Wasip2Executor::new( - Wasip2RuntimeConfig::default(), - None, - module, - )?; + let executor = + Wasip2Executor::new(Wasip2RuntimeConfig::default(), None, module)?; let component = executor.load(&sample.component.bytes)?; let descriptor = executor.describe(&component, None)?; validate_provider_descriptor(&descriptor, &sample.descriptor)?; @@ -1394,25 +1385,20 @@ impl RuntimeProcess { } } crate::catalog::DeclaredProviderCapability::GitHubPr { - owner, - repo, - number, + auth_executable, connect_timeout_ms, total_timeout_ms, } => { + let auth_executable = + crate::expand::expand_catalog(auth_executable, catalog_root); let module = GitHubPrModule::new(GitHubPrConfig { - owner: owner.clone(), - repo: repo.clone(), - number: *number, + auth_executable: PathBuf::from(auth_executable), connect_timeout: Duration::from_millis(*connect_timeout_ms), total_timeout: Duration::from_millis(*total_timeout_ms), }) .map_err(anyhow::Error::msg)?; - let executor = Wasip2Executor::new( - Wasip2RuntimeConfig::default(), - None, - module, - )?; + let executor = + Wasip2Executor::new(Wasip2RuntimeConfig::default(), None, module)?; let component = executor.load(&sample.component.bytes)?; let descriptor = executor.describe(&component, None)?; validate_provider_descriptor(&descriptor, &sample.descriptor)?; @@ -1424,31 +1410,19 @@ impl RuntimeProcess { crate::catalog::DeclaredProviderCapability::PtyStats { executable, cwd, - scope, deadline_ms, } => { - let executable = - crate::expand::expand_catalog(executable, catalog_root); + let executable = crate::expand::expand_catalog(executable, catalog_root); let cwd = crate::expand::expand_catalog(cwd, catalog_root); - let scope = match scope { - crate::catalog::DeclaredPtyStatsScope::All => PtyStatsScope::All, - crate::catalog::DeclaredPtyStatsScope::Session(session) => { - PtyStatsScope::Session(session.clone()) - } - }; let config = PtyStatsConfig::resolve( executable, PathBuf::from(cwd), - scope, Duration::from_millis(*deadline_ms), ) .map_err(anyhow::Error::msg)?; let module = PtyStatsModule::new(config); - let executor = Wasip2Executor::new( - Wasip2RuntimeConfig::default(), - None, - module, - )?; + let executor = + Wasip2Executor::new(Wasip2RuntimeConfig::default(), None, module)?; let component = executor.load(&sample.component.bytes)?; let descriptor = executor.describe(&component, None)?; validate_provider_descriptor(&descriptor, &sample.descriptor)?; @@ -1460,27 +1434,19 @@ impl RuntimeProcess { crate::catalog::DeclaredProviderCapability::Vista { executable, cwd, - slug, - version, deadline_ms, } => { - let executable = - crate::expand::expand_catalog(executable, catalog_root); + let executable = crate::expand::expand_catalog(executable, catalog_root); let cwd = crate::expand::expand_catalog(cwd, catalog_root); let config = VistaConfig::resolve( executable, PathBuf::from(cwd), - slug.clone(), - *version, Duration::from_millis(*deadline_ms), ) .map_err(anyhow::Error::msg)?; let module = VistaModule::new(config); - let executor = Wasip2Executor::new( - Wasip2RuntimeConfig::default(), - None, - module, - )?; + let executor = + Wasip2Executor::new(Wasip2RuntimeConfig::default(), None, module)?; let component = executor.load(&sample.component.bytes)?; let descriptor = executor.describe(&component, None)?; validate_provider_descriptor(&descriptor, &sample.descriptor)?; @@ -1491,8 +1457,7 @@ impl RuntimeProcess { } }; let sequence = ID_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let incarnation = - RuntimeIncarnation::new(format!("{}-{sequence}", sample.generation))?; + let incarnation = RuntimeIncarnation::new(format!("{}-{sequence}", sample.generation))?; let claim = OwnerClaim::new(hash_text(&format!( "{}\0{}\0{}\0{sequence}", catalog_root.display(), @@ -1930,7 +1895,6 @@ impl RuntimeProcess { Ok(()) } - fn refresh_process_health(&mut self) { let degraded = self .bindings @@ -2151,7 +2115,6 @@ fn finalize_active_demand( } } - fn selector_topics(selector: &Value) -> anyhow::Result { let topics = selector .as_object() @@ -2381,7 +2344,6 @@ fn hash_path(path: &Path) -> String { hash_text(&path.to_string_lossy()) } - fn hash_text(value: &str) -> String { format!("{:x}", Sha256::digest(value.as_bytes())) } @@ -2391,7 +2353,6 @@ mod tests { use super::*; use std::fs; - #[test] fn runtime_keys_enforce_shared_and_per_binding_topology() { let shared_a = RuntimeKey::Shared { @@ -2540,7 +2501,6 @@ mod tests { ); } - #[test] fn publication_subject_renders_ordered_facts_and_reserves_topics() { let facts = vec![ @@ -2584,12 +2544,10 @@ mod tests { ); } - #[cfg(feature = "wasip2-provider-runtime")] #[test] fn observation_panic_becomes_a_typed_failed_completion() { let result = catch_observation(|| panic!("synthetic provider panic")); assert_eq!(result.unwrap_err(), "provider observation panicked"); } - } diff --git a/tests/catalog_apply.rs b/tests/catalog_apply.rs index 78024657..fc8a17a2 100644 --- a/tests/catalog_apply.rs +++ b/tests/catalog_apply.rs @@ -1867,6 +1867,117 @@ fn raw_preimage_repairs_an_invalid_catalog_and_preserves_mutable_state() { ); } +#[test] +fn raw_preimage_migrates_legacy_argv_profile_to_a_component_catalog() { + let temp = tempfile::tempdir().unwrap(); + let catalog = temp.path().join("catalog"); + write_agent(&catalog, "worker", false); + let pty_root = temp.path().join("shared-pty"); + let pty_root = pty_root.to_str().unwrap(); + let legacy_config = format!( + r#"catalog {{ pty-root {pty_root:?} }} +profile "dev.example.observe" {{ + wasm "resolvers/observe.wasm" + runtime {{ + argv "legacy-provider" "--unsafe" + }} +}} +"# + ); + fs::write(catalog.join("catalog.kdl"), &legacy_config).unwrap(); + let legacy_error = st2::catalog::load(&catalog).unwrap_err(); + assert!( + legacy_error + .to_string() + .contains("runtime field 'argv' is unknown"), + "{legacy_error:#}" + ); + + let agent = agent_dir(&catalog, "worker"); + fs::create_dir_all(agent.join("resources/context")).unwrap(); + fs::write( + agent.join("resources/context/now.md"), + "preserve mutable context", + ) + .unwrap(); + fs::write(agent.join("status"), "busy").unwrap(); + + let raw_capture_dir = temp.path().join("raw-capture-legacy"); + let captured = raw_snapshot(&catalog, &raw_capture_dir); + assert!( + captured.status.success(), + "{}", + String::from_utf8_lossy(&captured.stderr) + ); + let captured: Value = serde_json::from_slice(&captured.stdout).unwrap(); + assert_eq!( + fs::read_to_string(raw_capture_dir.join("catalog.kdl")).unwrap(), + legacy_config + ); + + let desired = temp.path().join("desired-component"); + write_agent(&desired, "worker", false); + fs::create_dir_all(desired.join("resolvers")).unwrap(); + fs::create_dir_all(desired.join("providers")).unwrap(); + fs::copy(DEMO_WASM_SRC, desired.join("resolvers/observe.wasm")).unwrap(); + fs::copy( + DEMO_WASM_SRC, + desired.join("providers/observe.component.wasm"), + ) + .unwrap(); + fs::write( + desired.join("catalog.kdl"), + format!( + r#"catalog {{ pty-root {pty_root:?} }} +profile "dev.example.observe" {{ + wasm "resolvers/observe.wasm" + runtime {{ + component "providers/observe.component.wasm" + pty-stats executable="/bin/true" cwd="/" deadline-ms=1000 + }} +}} +"# + ), + ) + .unwrap(); + let prepared = temp.path().join("prepared-component"); + snapshot(&desired, &prepared); + + let repaired = raw_apply( + &catalog, + &prepared, + captured["rootSha256"].as_str().unwrap(), + ); + assert!( + repaired.status.success(), + "{}", + String::from_utf8_lossy(&repaired.stderr) + ); + assert_eq!( + fs::read_to_string(agent.join("resources/context/now.md")).unwrap(), + "preserve mutable context" + ); + assert_eq!(fs::read_to_string(agent.join("status")).unwrap(), "busy"); + let applied = st2::catalog::load(&catalog).unwrap(); + assert_eq!(applied.pty_root.as_deref(), Some(pty_root)); + assert_eq!( + applied.profiles[0] + .runtime + .as_ref() + .map(|runtime| runtime.component.as_str()), + Some("providers/observe.component.wasm") + ); + assert!( + !fs::read_to_string(catalog.join("catalog.kdl")) + .unwrap() + .contains("argv") + ); + assert_eq!( + fs::read(catalog.join("providers/observe.component.wasm")).unwrap(), + fs::read(DEMO_WASM_SRC).unwrap() + ); +} + #[test] fn raw_preimage_refuses_valid_catalogs_and_wrong_cas_without_declaration_writes() { let temp = tempfile::tempdir().unwrap(); @@ -1943,15 +2054,32 @@ fn raw_preimage_rejects_hard_linked_declarations() { #[test] fn raw_preimage_requires_a_readable_envelope_and_an_unchanged_pty_root() { let temp = tempfile::tempdir().unwrap(); - let malformed_envelope = temp.path().join("malformed-envelope"); - write_invalid_agent(&malformed_envelope, "worker"); - fs::write(malformed_envelope.join("catalog.kdl"), "catalog {").unwrap(); - let rejected = raw_snapshot(&malformed_envelope, &temp.path().join("malformed-capture")); - assert!(!rejected.status.success()); - assert!( - String::from_utf8_lossy(&rejected.stderr) - .contains("requires a valid incumbent catalog envelope") - ); + for (case, envelope) in [ + ("malformed", "catalog {"), + ( + "duplicate-catalog", + "catalog { pty-root \"/tmp/a\" }\ncatalog { pty-root \"/tmp/a\" }\n", + ), + ( + "duplicate-pty-root", + "catalog { pty-root \"/tmp/a\"; pty-root \"/tmp/a\" }\n", + ), + ] { + let malformed_envelope = temp.path().join(format!("{case}-envelope")); + write_invalid_agent(&malformed_envelope, "worker"); + fs::write(malformed_envelope.join("catalog.kdl"), envelope).unwrap(); + let rejected = raw_snapshot( + &malformed_envelope, + &temp.path().join(format!("{case}-capture")), + ); + assert!(!rejected.status.success(), "{case}"); + assert!( + String::from_utf8_lossy(&rejected.stderr) + .contains("requires a valid incumbent catalog envelope"), + "{case}: {}", + String::from_utf8_lossy(&rejected.stderr) + ); + } let catalog = temp.path().join("catalog"); write_invalid_agent(&catalog, "worker"); diff --git a/tests/resource_profile_supervisor_e2e.rs b/tests/resource_profile_supervisor_e2e.rs index a5eef930..84785712 100755 --- a/tests/resource_profile_supervisor_e2e.rs +++ b/tests/resource_profile_supervisor_e2e.rs @@ -1,5 +1,6 @@ #![cfg(all(unix, feature = "wasip2-provider-runtime"))] +use parking_lot::Mutex; use std::ffi::CString; use std::fs::{self, OpenOptions}; use std::io::Write as _; @@ -8,7 +9,6 @@ use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; use std::sync::{Arc, Barrier}; use std::time::{Duration, Instant}; -use parking_lot::Mutex; use st2::resource_observe::{ObserveReceipt, ObserveReceiptStatus, ObserveRequest, submit_request}; use st2::resource_profile_supervisor::ResourceProfileSupervisor; @@ -27,24 +27,41 @@ fn supervisor_compatibility_contract_uses_the_production_pty_component() { let executable = temporary.path().join("fixture-pty"); write_executable( &executable, - "#!/bin/sh\nprintf '%s\\n' '{\"sessions\":1}'\n", + "#!/bin/sh\nprintf '%s\\n' '[{\"name\":\"subject\",\"status\":\"exited\",\"generation\":1}]'\n", ); let pty = ProviderFixture::new( temporary.path().join("pty"), - "dev.st2.pty-stats", + "pty", component("ST2_PTY_STATS_COMPONENT"), - r#"{"topics":["stats"]}"#, + r#"{"topics":["lifecycle","metadata"]}"#, &format!( - "pty-stats executable={:?} cwd={:?} scope=\"all\" deadline-ms=10000", + "pty-stats executable={:?} cwd={:?} deadline-ms=10000", executable, temporary.path() ), - "st2.resource.pty-stats.v1", - "stats", + "dev.schickling.pty.snapshot.v1", + &["lifecycle", "metadata", "runtime"], ); let first = pty.observe(None); - assert_eq!(first.status, ObserveReceiptStatus::SettledChanged, "{first:?}"); + assert_eq!( + first.status, + ObserveReceiptStatus::SettledChanged, + "{first:?}" + ); let first_bytes = fs::read(pty.snapshot()).unwrap(); + let first_snapshot: serde_json::Value = serde_json::from_slice(&first_bytes).unwrap(); + assert_eq!( + first_snapshot + .get("schema") + .and_then(serde_json::Value::as_str), + Some("dev.schickling.pty.snapshot.v1") + ); + assert_eq!( + first_snapshot + .get("uri") + .and_then(serde_json::Value::as_str), + Some("pty:subject") + ); let replay = pty.observe(first.digest); assert_eq!(replay.status, ObserveReceiptStatus::SettledUnchanged); assert_eq!(fs::read(pty.snapshot()).unwrap(), first_bytes); @@ -53,50 +70,58 @@ fn supervisor_compatibility_contract_uses_the_production_pty_component() { let failed = pty.observe(first.digest); assert_eq!(failed.status, ObserveReceiptStatus::SettledFailed); assert_eq!(fs::read(pty.snapshot()).unwrap(), first_bytes); - assert!(pty - .supervisor - .health() - .iter() - .any(|health| health.binding.as_deref() == Some("observed") - && health.state == st2::resource_profile::RuntimeHealthState::Degraded)); + assert!( + pty.supervisor + .health() + .iter() + .any(|health| health.binding.as_deref() == Some("observed") + && health.state == st2::resource_profile::RuntimeHealthState::Degraded) + ); write_executable( &executable, - "#!/bin/sh\nprintf '%s\\n' '{\"sessions\":2}'\n", + "#!/bin/sh\nprintf '%s\\n' '[{\"name\":\"subject\",\"status\":\"exited\",\"generation\":2}]'\n", ); let recovered = pty.observe(first.digest); assert_eq!(recovered.status, ObserveReceiptStatus::SettledChanged); - assert!(pty - .supervisor - .health() - .iter() - .any(|health| health.binding.as_deref() == Some("observed") - && health.state == st2::resource_profile::RuntimeHealthState::Ready)); + assert!( + pty.supervisor + .health() + .iter() + .any(|health| health.binding.as_deref() == Some("observed") + && health.state == st2::resource_profile::RuntimeHealthState::Ready) + ); let recovered_bytes = fs::read(pty.snapshot()).unwrap(); drop(pty); let restarted = ProviderFixture::new( temporary.path().join("pty"), - "dev.st2.pty-stats", + "pty", component("ST2_PTY_STATS_COMPONENT"), - r#"{"topics":["stats"]}"#, + r#"{"topics":["lifecycle","metadata"]}"#, &format!( - "pty-stats executable={:?} cwd={:?} scope=\"all\" deadline-ms=10000", + "pty-stats executable={:?} cwd={:?} deadline-ms=10000", executable, temporary.path() ), - "st2.resource.pty-stats.v1", - "stats", + "dev.schickling.pty.snapshot.v1", + &["lifecycle", "metadata", "runtime"], + ); + let observed_after_restart = restarted.observe(recovered.digest); + assert_eq!( + observed_after_restart.status, + ObserveReceiptStatus::SettledChanged ); - let unchanged_after_restart = restarted.observe(recovered.digest); + let restarted_snapshot: serde_json::Value = + serde_json::from_slice(&fs::read(restarted.snapshot()).unwrap()).unwrap(); assert_eq!( - unchanged_after_restart.status, - ObserveReceiptStatus::SettledUnchanged + restarted_snapshot + .get("schema") + .and_then(serde_json::Value::as_str), + Some("dev.schickling.pty.snapshot.v1") ); - assert_eq!(fs::read(restarted.snapshot()).unwrap(), recovered_bytes); } - #[test] fn supervisor_spawns_vista_capability_and_preserves_stable_snapshot() { let _guard = STATE_ENV.lock(); @@ -112,8 +137,7 @@ fi printf '%s\n' '{"schemaVersion":1,"uri":"vista://release-notes/v7","slug":"release-notes","version":7,"author":"agent","timestamp":"2026-09-02T10:00:00Z","changeSummary":"created","parent":null,"retired":false,"state":"ready","canonicalUrl":"https://vista.example/release-notes/v7"}' "#, ); - let selector = - r#"{"slug":"release-notes","version":7,"topics":["ready","updated","failed","expired"]}"#; + let selector = r#"{"topics":["ready","updated","failed","expired"]}"#; let vista = ProviderFixture::new_with_uri( temporary.path().join("catalog"), "vista", @@ -121,7 +145,7 @@ printf '%s\n' '{"schemaVersion":1,"uri":"vista://release-notes/v7","slug":"relea component("ST2_VISTA_COMPONENT"), selector, &format!( - "vista executable={:?} cwd={:?} slug=\"release-notes\" version=7 deadline-ms=10000", + "vista executable={:?} cwd={:?} deadline-ms=10000", executable, temporary.path() ), @@ -130,14 +154,18 @@ printf '%s\n' '{"schemaVersion":1,"uri":"vista://release-notes/v7","slug":"relea ); let first = vista.observe(None); - assert_eq!(first.status, ObserveReceiptStatus::SettledChanged, "{first:?}"); + assert_eq!( + first.status, + ObserveReceiptStatus::SettledChanged, + "{first:?}" + ); let first_bytes = fs::read(vista.snapshot()).unwrap(); let snapshot: serde_json::Value = serde_json::from_slice(&first_bytes).unwrap(); assert_eq!( snapshot.get("schema").and_then(serde_json::Value::as_str), Some("dev.schickling.vista.snapshot.v1") ); - assert!(snapshot.get("observedAt").is_none()); + assert!(snapshot.get("observedAt").is_some()); let replay = vista.observe(first.digest); assert_eq!(replay.status, ObserveReceiptStatus::SettledUnchanged); assert_eq!(fs::read(vista.snapshot()).unwrap(), first_bytes); @@ -152,7 +180,11 @@ fn production_component_preserves_resync_filter_catch_up_and_scope_isolation() { let primary_control = temporary.path().join("primary-control"); fs::create_dir_all(&primary_control).unwrap(); let primary_payload = primary_control.join("payload.json"); - fs::write(&primary_payload, r#"{"sessions":1}"#).unwrap(); + fs::write( + &primary_payload, + r#"[{"name":"subject","status":"exited","generation":1}]"#, + ) + .unwrap(); let primary_executable = primary_control.join("fixture-pty"); write_executable( &primary_executable, @@ -160,34 +192,46 @@ fn production_component_preserves_resync_filter_catch_up_and_scope_isolation() { ); let primary = ProviderFixture::new( temporary.path().join("primary"), - "dev.st2.pty-stats", + "pty", component("ST2_PTY_STATS_COMPONENT"), - r#"{"topics":["stats"]}"#, + r#"{"topics":["lifecycle"]}"#, &format!( - "pty-stats executable={:?} cwd={:?} scope=\"all\" deadline-ms=10000", + "pty-stats executable={:?} cwd={:?} deadline-ms=10000", primary_executable, primary_control ), - "st2.resource.pty-stats.v1", - "stats", + "dev.schickling.pty.snapshot.v1", + &["lifecycle", "metadata", "runtime"], ); let first = primary.observe(None); - assert_eq!(first.status, ObserveReceiptStatus::SettledChanged, "{first:?}"); + assert_eq!( + first.status, + ObserveReceiptStatus::SettledChanged, + "{first:?}" + ); let first_snapshot = fs::read(primary.snapshot()).unwrap(); let first_inbox = wait_until("first resync record", || { let inbox = resync_inbox(&primary.agent); (!inbox.is_empty()).then_some(inbox) }); assert_eq!(first_inbox.len(), 1); - assert!(first_inbox[0].contains("subject: observed · scope=all [stats]")); - assert!(first_inbox[0].contains(r#""facts":[{"key":"scope","after":"all"}]"#)); + assert!( + first_inbox[0].contains("subject: observed · session=subject; state=exited [lifecycle]") + ); + assert!(first_inbox[0].contains( + r#""facts":[{"key":"session","after":"subject"},{"key":"state","after":"exited"}]"# + )); let equal = primary.observe(first.digest); assert_eq!(equal.status, ObserveReceiptStatus::SettledUnchanged); assert_eq!(fs::read(primary.snapshot()).unwrap(), first_snapshot); assert_eq!(resync_inbox(&primary.agent), first_inbox); - fs::write(&primary_payload, r#"{"sessions":2}"#).unwrap(); - primary.rewrite_selector(r#"{"topics":["ignored"]}"#); + fs::write( + &primary_payload, + r#"[{"name":"subject","status":"exited","generation":2}]"#, + ) + .unwrap(); + primary.rewrite_selector(r#"{"topics":["metadata"]}"#); primary.refresh(); let filtered = primary.observe(first.digest); assert_eq!(filtered.status, ObserveReceiptStatus::SettledChanged); @@ -195,12 +239,16 @@ fn production_component_preserves_resync_filter_catch_up_and_scope_isolation() { assert_eq!( resync_inbox(&primary.agent), first_inbox, - "an unselected topic updates the snapshot without invalidation" + "a selector-excluded lifecycle transition does not invalidate the binding" ); - primary.rewrite_selector(r#"{"topics":["stats"]}"#); + primary.rewrite_selector(r#"{"topics":["lifecycle"]}"#); primary.refresh(); - fs::write(&primary_payload, r#"{"sessions":3}"#).unwrap(); + fs::write( + &primary_payload, + r#"[{"name":"subject","status":"exited","generation":3}]"#, + ) + .unwrap(); let catch_up_fifo = primary_control.join("catch-up.fifo"); let fifo_c = CString::new(catch_up_fifo.as_os_str().as_bytes()).unwrap(); // SAFETY: `fifo_c` is a live NUL-terminated pathname for this call. @@ -210,8 +258,7 @@ fn production_component_preserves_resync_filter_catch_up_and_scope_isolation() { "#!/bin/sh\nread release < catch-up.fifo\nread payload < payload.json\nprintf '%s\\n' \"$payload\"\n", ); let pending_request = primary.request(1, filtered.digest); - let pending_client = - submit_request(&primary.root, &primary.host, &pending_request).unwrap(); + let pending_client = submit_request(&primary.root, &primary.host, &pending_request).unwrap(); wait_receipt_status( &primary, &pending_request.request_id, @@ -219,7 +266,11 @@ fn production_component_preserves_resync_filter_catch_up_and_scope_isolation() { ); fs::remove_file(primary.owner_binding_path()).unwrap(); release_fifo(&catch_up_fifo); - let pending = pending_client.wait_for_terminal(WAIT).unwrap().receipt.unwrap(); + let pending = pending_client + .wait_for_terminal(WAIT) + .unwrap() + .receipt + .unwrap(); assert_eq!(pending.status, ObserveReceiptStatus::SettledChanged); assert_eq!(resync_inbox(&primary.agent), first_inbox); st2::event::publish_owner_binding_for_test(&primary.root, &primary.host).unwrap(); @@ -240,7 +291,11 @@ fn production_component_preserves_resync_filter_catch_up_and_scope_isolation() { let isolated_control = temporary.path().join("isolated-control"); fs::create_dir_all(&isolated_control).unwrap(); let isolated_payload = isolated_control.join("payload.json"); - fs::write(&isolated_payload, r#"{"sessions":10}"#).unwrap(); + fs::write( + &isolated_payload, + r#"[{"name":"subject","status":"exited","generation":10}]"#, + ) + .unwrap(); let isolated_executable = isolated_control.join("fixture-pty"); write_executable( &isolated_executable, @@ -248,15 +303,15 @@ fn production_component_preserves_resync_filter_catch_up_and_scope_isolation() { ); let isolated = ProviderFixture::new( temporary.path().join("isolated"), - "dev.st2.pty-stats", + "pty", component("ST2_PTY_STATS_COMPONENT"), - r#"{"topics":["stats"]}"#, + r#"{"topics":["lifecycle"]}"#, &format!( - "pty-stats executable={:?} cwd={:?} scope=\"all\" deadline-ms=10000", + "pty-stats executable={:?} cwd={:?} deadline-ms=10000", isolated_executable, isolated_control ), - "st2.resource.pty-stats.v1", - "stats", + "dev.schickling.pty.snapshot.v1", + &["lifecycle", "metadata", "runtime"], ); let isolated_first = isolated.observe(None); assert_eq!(isolated_first.status, ObserveReceiptStatus::SettledChanged); @@ -267,7 +322,11 @@ fn production_component_preserves_resync_filter_catch_up_and_scope_isolation() { isolated_before, "dropping one catalog scope must not mutate another" ); - fs::write(&isolated_payload, r#"{"sessions":11}"#).unwrap(); + fs::write( + &isolated_payload, + r#"[{"name":"subject","status":"exited","generation":11}]"#, + ) + .unwrap(); let isolated_second = isolated.observe(isolated_first.digest); assert_eq!(isolated_second.status, ObserveReceiptStatus::SettledChanged); assert_ne!( @@ -284,7 +343,11 @@ fn production_demand_jobs_coalesce_queue_disconnect_and_fence_generation() { unsafe { std::env::set_var("XDG_STATE_HOME", temporary.path().join("state")) }; let control = temporary.path().join("control"); fs::create_dir_all(&control).unwrap(); - fs::write(control.join("payload.json"), r#"{"sessions":1}"#).unwrap(); + fs::write( + control.join("payload.json"), + r#"[{"name":"subject","status":"exited","generation":1}]"#, + ) + .unwrap(); let fifo = control.join("release.fifo"); let fifo_c = CString::new(fifo.as_os_str().as_bytes()).unwrap(); // SAFETY: `fifo_c` is a live NUL-terminated pathname for this call. @@ -296,15 +359,15 @@ fn production_demand_jobs_coalesce_queue_disconnect_and_fence_generation() { ); let fixture = ProviderFixture::new( temporary.path().join("catalog"), - "dev.st2.pty-stats", + "pty", component("ST2_PTY_STATS_COMPONENT"), - r#"{"topics":["stats"]}"#, + r#"{"topics":["lifecycle"]}"#, &format!( - "pty-stats executable={:?} cwd={:?} scope=\"all\" deadline-ms=10000", + "pty-stats executable={:?} cwd={:?} deadline-ms=10000", executable, control ), - "st2.resource.pty-stats.v1", - "stats", + "dev.schickling.pty.snapshot.v1", + &["lifecycle", "metadata", "runtime"], ); let leading = fixture.request(1, None); @@ -339,8 +402,11 @@ fn production_demand_jobs_coalesce_queue_disconnect_and_fence_generation() { Some(1) ); for request in [&trailing_a, &trailing_b] { - let accepted = - wait_receipt_status(&fixture, &request.request_id, ObserveReceiptStatus::Accepted); + let accepted = wait_receipt_status( + &fixture, + &request.request_id, + ObserveReceiptStatus::Accepted, + ); assert_eq!(accepted.demand_watermark, Some(2)); } release_fifo(&fifo); @@ -354,23 +420,15 @@ fn production_demand_jobs_coalesce_queue_disconnect_and_fence_generation() { let disconnected = fixture.request(1, None); let disconnected_id = disconnected.request_id.clone(); - let disconnected_client = - submit_request(&fixture.root, &fixture.host, &disconnected).unwrap(); - wait_receipt_status( - &fixture, - &disconnected_id, - ObserveReceiptStatus::Accepted, - ); + let disconnected_client = submit_request(&fixture.root, &fixture.host, &disconnected).unwrap(); + wait_receipt_status(&fixture, &disconnected_id, ObserveReceiptStatus::Accepted); drop(disconnected_client); release_fifo(&fifo); let disconnected_receipt = wait_until("receipt after client disconnect", || { - st2::resource_observe::read_receipt( - &fixture.observe_receipt_dir(), - &disconnected_id, - ) - .ok() - .flatten() - .filter(|receipt| receipt.status.is_terminal()) + st2::resource_observe::read_receipt(&fixture.observe_receipt_dir(), &disconnected_id) + .ok() + .flatten() + .filter(|receipt| receipt.status.is_terminal()) }); assert!( matches!( @@ -388,24 +446,26 @@ fn production_demand_jobs_coalesce_queue_disconnect_and_fence_generation() { fixture.refresh_generation(1); assert!(future_path.is_file()); assert!( - st2::resource_observe::read_receipt( - &fixture.observe_receipt_dir(), - &future.request_id, - ) - .unwrap() - .is_none() + st2::resource_observe::read_receipt(&fixture.observe_receipt_dir(), &future.request_id,) + .unwrap() + .is_none() ); fixture.refresh_generation(2); wait_receipt_status(&fixture, &future.request_id, ObserveReceiptStatus::Accepted); release_fifo(&fifo); - assert_eq!( - future_client - .wait_for_terminal(WAIT) - .unwrap() - .receipt - .unwrap() - .status, - ObserveReceiptStatus::SettledUnchanged + // A generation change discards the provider's semantic cache, so the fenced request may + // republish the same source with a new observation timestamp. + let future_receipt = future_client + .wait_for_terminal(WAIT) + .unwrap() + .receipt + .unwrap(); + assert!( + matches!( + future_receipt.status, + ObserveReceiptStatus::SettledChanged | ObserveReceiptStatus::SettledUnchanged + ), + "{future_receipt:?}" ); let stale = fixture.request(2, None); @@ -509,7 +569,10 @@ fn wait_until(description: &str, mut probe: impl FnMut() -> Option) -> T { if let Some(value) = probe() { return value; } - assert!(Instant::now() < deadline, "timed out waiting for {description}"); + assert!( + Instant::now() < deadline, + "timed out waiting for {description}" + ); std::thread::yield_now(); } } @@ -579,17 +642,17 @@ impl ProviderFixture { selector: &str, capability: &str, schema_id: &str, - topic: &str, + topics: &[&str], ) -> Self { Self::new_with_uri( root, scheme, - &format!("{scheme}://subject"), + &format!("{scheme}:subject"), component, selector, capability, schema_id, - &[topic], + topics, ) } @@ -648,9 +711,9 @@ impl ProviderFixture { let (config, profiles) = st2::catalog::declared_profile_catalog(&self.root).unwrap(); let discovery = st2::discover_strict(&self.root); assert!(discovery.errors.is_empty(), "{:?}", discovery.errors); - let report = self - .supervisor - .refresh(&config, &profiles, Some(generation), &discovery.specs); + let report = + self.supervisor + .refresh(&config, &profiles, Some(generation), &discovery.specs); assert!(report.warnings.is_empty(), "{:?}", report.warnings); } @@ -746,7 +809,7 @@ fn observable_resolver_wasm(schema_id: &str, topics: &[&str], selector: &str) -> "capabilities": ["resolve", "read", "observe"], "selectorSchema": { "type": "object", "additionalProperties": true }, "defaultSelector": selector_value, - "topics": topics.iter().map(|name| serde_json::json!({"name": name})).chain([serde_json::json!({"name": "ignored"})]).collect::>(), + "topics": topics.iter().map(|name| serde_json::json!({"name": name})).collect::>(), "runtime": {"topology": "shared"}, "snapshot": {"mediaType": "application/json", "schemaId": schema_id} })) @@ -769,8 +832,16 @@ fn observable_resolver_wasm(schema_id: &str, topics: &[&str], selector: &str) -> push_section(&mut module, 7, &exports); let mut code = vec![3]; push_body(&mut code, 0x41, 16384); - push_body(&mut code, 0x42, (RESOLUTION_PTR << 32) | RESOLUTION.len() as i64); - push_body(&mut code, 0x42, (DESCRIPTOR_PTR << 32) | descriptor.len() as i64); + push_body( + &mut code, + 0x42, + (RESOLUTION_PTR << 32) | RESOLUTION.len() as i64, + ); + push_body( + &mut code, + 0x42, + (DESCRIPTOR_PTR << 32) | descriptor.len() as i64, + ); push_section(&mut module, 10, &code); let mut data = vec![2]; push_data(&mut data, DESCRIPTOR_PTR, &descriptor); @@ -812,9 +883,13 @@ fn push_u32(bytes: &mut Vec, mut value: u32) { loop { let mut byte = (value & 0x7f) as u8; value >>= 7; - if value != 0 { byte |= 0x80; } + if value != 0 { + byte |= 0x80; + } bytes.push(byte); - if value == 0 { return; } + if value == 0 { + return; + } } } @@ -824,6 +899,8 @@ fn push_i64(bytes: &mut Vec, mut value: i64) { value >>= 7; let done = (value == 0 && byte & 0x40 == 0) || (value == -1 && byte & 0x40 != 0); bytes.push(if done { byte } else { byte | 0x80 }); - if done { return; } + if done { + return; + } } } diff --git a/tests/resource_provider_e2e.rs b/tests/resource_provider_e2e.rs index 7441550a..7b7af2c0 100755 --- a/tests/resource_provider_e2e.rs +++ b/tests/resource_provider_e2e.rs @@ -9,24 +9,39 @@ use serde_json::json; use st2_resource_protocol::{ObservationResult, SnapshotDigest}; use st2_resource_providers::{ GitHubIssueConfig, GitHubIssueModule, GitHubPrConfig, GitHubPrModule, PtyStatsConfig, - PtyStatsModule, PtyStatsScope, VistaConfig, VistaModule, + PtyStatsModule, VistaConfig, VistaModule, }; use st2_resource_wasip2::{Executor, ObservationRequest, RuntimeConfig}; #[test] -fn pty_component_observes_replays_and_enforces_capability_scope() { +fn pty_component_observes_replays_and_rejects_invalid_identity_before_spawn() { let temporary = tempfile::tempdir().unwrap(); - let executable = temporary.path().join("pty-stats"); + let executable = temporary.path().join("pty"); write_executable( &executable, - "#!/bin/sh\nprintf '%s\\n' '{\"sessions\":2,\"bytes\":64}'\n", + r#"#!/bin/sh +set -eu +case "$*" in + "list --json") + printf '%s\n' "$*" >> "$PWD/invocations" + printf '%s\n' '[{"name":"demo","status":"running","command":"agent","cwd":"/workspace","createdAt":"2026-09-02T10:00:00Z","tags":{"private":"false"},"displayName":"Demo"}]' + ;; + "stats --json demo") + printf '%s\n' "$*" >> "$PWD/invocations" + printf '%s\n' '{"name":"demo","status":"running","terminal":{"cols":120,"rows":40,"cursorX":1,"cursorY":2,"scrollbackUsed":3,"scrollbackCapacity":1000},"process":{"alive":true,"exitCode":null,"resources":{"rssKb":64,"cpuPercent":1.5}},"clients":{"total":1,"attached":1,"readOnly":0},"modes":{"sgrMouse":false,"cursorHidden":false,"kittyKeyboard":false,"kittyKeyboardFlags":[]},"uptimeSeconds":10}' + ;; + *) + printf 'unexpected argv: %s\n' "$*" >&2 + exit 64 + ;; +esac +"#, ); let module = PtyStatsModule::new( PtyStatsConfig::resolve( &executable, temporary.path().to_path_buf(), - PtyStatsScope::All, Duration::from_secs(5), ) .unwrap(), @@ -35,30 +50,29 @@ fn pty_component_observes_replays_and_enforces_capability_scope() { let component_bytes = fs::read(component("ST2_PTY_STATS_COMPONENT")).unwrap(); let loaded = executor.load(&component_bytes).unwrap(); let descriptor = executor.describe(&loaded, None).unwrap(); - assert_eq!(descriptor.topics, ["stats"]); - assert_eq!(descriptor.snapshot_schema_id, "st2.resource.pty-stats.v1"); + assert_eq!(descriptor.topics, ["lifecycle", "metadata", "runtime"]); + assert_eq!( + descriptor.snapshot_schema_id, + "dev.schickling.pty.snapshot.v1" + ); assert_eq!(descriptor.snapshot_media_type, "application/json"); - + let request = |invocation_id, prior_digest, topics: &[&str]| ObservationRequest { + invocation_id, + uri: "pty:demo".into(), + selector: json!({ "topics": topics }), + prior_digest, + demand_watermark: Some(invocation_id), + }; let first = executor - .observe( - &loaded, - &ObservationRequest { - invocation_id: 1, - uri: "dev.st2.pty-stats://all".into(), - selector: json!({ "topics": ["stats"] }), - prior_digest: None, - demand_watermark: Some(1), - }, - None, - ) + .observe(&loaded, &request(1, None, &["lifecycle", "metadata"]), None) .unwrap(); let publication = match first { ObservationResult::Published { publication } => publication, other => panic!("first observation must publish, got {other:?}"), }; - assert_eq!(publication.schema_id, "st2.resource.pty-stats.v1"); - assert_eq!(publication.topics, ["stats"]); + assert_eq!(publication.schema_id, "dev.schickling.pty.snapshot.v1"); + assert_eq!(publication.topics, ["lifecycle", "metadata", "runtime"]); assert_eq!( publication .facts @@ -67,32 +81,25 @@ fn pty_component_observes_replays_and_enforces_capability_scope() { .iter() .map(|fact| fact.key()) .collect::>(), - ["scope"] + ["session", "state"] ); let prior = SnapshotDigest::of(publication.bytes.as_slice()); - let replay = executor - .observe( - &loaded, - &ObservationRequest { - invocation_id: 2, - uri: "dev.st2.pty-stats://all".into(), - selector: json!({ "topics": ["stats"] }), - prior_digest: Some(prior), - demand_watermark: Some(2), - }, - None, - ) - .unwrap(); - assert_eq!(replay, ObservationResult::Unchanged); + assert_eq!( + executor + .observe(&loaded, &request(2, Some(prior), &["runtime"]), None) + .unwrap(), + ObservationResult::Unchanged + ); + let invocations_before = fs::read_to_string(temporary.path().join("invocations")).unwrap(); let denied = executor .observe( &loaded, &ObservationRequest { invocation_id: 3, - uri: "dev.st2.pty-stats://session/other".into(), - selector: json!({ "session": "other", "topics": ["stats"] }), + uri: "pty:bad/other".into(), + selector: json!({ "topics": ["lifecycle"] }), prior_digest: None, demand_watermark: Some(3), }, @@ -103,16 +110,18 @@ fn pty_component_observes_replays_and_enforces_capability_scope() { &denied, ObservationResult::Failed { diagnostic: Some(diagnostic) - } if diagnostic.contains("PTY stats scope denied") + } if diagnostic.contains("invalid PTY URI") )); + assert_eq!( + fs::read_to_string(temporary.path().join("invocations")).unwrap(), + invocations_before + ); } #[test] -fn github_pr_component_describes_and_denies_out_of_scope_before_transport() { +fn github_pr_component_describes_and_rejects_noncanonical_uri_before_transport() { let module = GitHubPrModule::new(GitHubPrConfig { - owner: "example".into(), - repo: "demo".into(), - number: 389, + auth_executable: PathBuf::from("/nonexistent/gh"), connect_timeout: Duration::from_secs(3), total_timeout: Duration::from_secs(10), }) @@ -143,9 +152,6 @@ fn github_pr_component_describes_and_denies_out_of_scope_before_transport() { invocation_id: 1, uri: "github-pr://other/demo/389".into(), selector: json!({ - "owner": "other", - "repo": "demo", - "number": 389, "topics": ["ci.failure"] }), prior_digest: None, @@ -158,7 +164,7 @@ fn github_pr_component_describes_and_denies_out_of_scope_before_transport() { &denied, ObservationResult::Failed { diagnostic: Some(diagnostic) - } if diagnostic.contains("GitHub pull request scope denied") + } if diagnostic.contains("invalid canonical GitHub pull request URI") )); } @@ -202,8 +208,6 @@ esac VistaConfig::resolve( &executable, temporary.path().to_path_buf(), - "release-notes".into(), - 7, Duration::from_secs(5), ) .unwrap(), @@ -213,10 +217,7 @@ esac let loaded = executor.load(&component_bytes).unwrap(); let descriptor = executor.describe(&loaded, None).unwrap(); assert_eq!(descriptor.capabilities.len(), 1); - assert_eq!( - descriptor.topics, - ["ready", "updated", "failed", "expired"] - ); + assert_eq!(descriptor.topics, ["ready", "updated", "failed", "expired"]); assert_eq!( descriptor.snapshot_schema_id, "dev.schickling.vista.snapshot.v1" @@ -227,8 +228,6 @@ esac invocation_id, uri: "vista://release-notes/v7".into(), selector: json!({ - "slug": "release-notes", - "version": 7, "topics": ["ready", "updated", "failed", "expired"] }), prior_digest, @@ -248,15 +247,14 @@ esac .iter() .map(|fact| fact.key()) .collect::>(), - ["state"] + ["artifact", "state", "blocks"] ); - let carrier: serde_json::Value = - serde_json::from_slice(publication.bytes.as_slice()).unwrap(); + let carrier: serde_json::Value = serde_json::from_slice(publication.bytes.as_slice()).unwrap(); assert_eq!( carrier.get("schema").and_then(serde_json::Value::as_str), Some("dev.schickling.vista.snapshot.v1") ); - assert!(carrier.get("observedAt").is_none()); + assert!(carrier.get("observedAt").is_some()); let prior = SnapshotDigest::of(publication.bytes.as_slice()); assert_eq!( @@ -267,11 +265,13 @@ esac ); fs::write(temporary.path().join("mode"), "changed\n").unwrap(); - let changed = executor.observe(&loaded, &request(3, Some(prior)), None).unwrap(); + let changed = executor + .observe(&loaded, &request(3, Some(prior)), None) + .unwrap(); assert!(matches!( changed, ObservationResult::Published { publication } - if publication.topics == ["updated", "ready"] + if publication.topics == ["updated"] )); let invocations_before = fs::read_to_string(temporary.path().join("invocations")).unwrap(); @@ -281,7 +281,7 @@ esac &ObservationRequest { invocation_id: 4, uri: "vista://bad--slug/v7".into(), - selector: json!({ "slug": "bad--slug", "version": 7 }), + selector: json!({ "topics": ["ready"] }), prior_digest: None, demand_watermark: Some(4), }, @@ -300,9 +300,7 @@ esac ("nonzero", "artifact unavailable for release-notes"), ] { fs::write(temporary.path().join("mode"), format!("{mode}\n")).unwrap(); - let result = executor - .observe(&loaded, &request(5, None), None) - .unwrap(); + let result = executor.observe(&loaded, &request(5, None), None).unwrap(); assert!(matches!( &result, ObservationResult::Failed { @@ -316,9 +314,7 @@ esac #[ignore = "explicit read-only public GitHub smoke; requires network and ST2_GITHUB_ISSUE_COMPONENT"] fn github_component_public_read_only_smoke() { let module = GitHubIssueModule::new(GitHubIssueConfig { - owner: "rust-lang".into(), - repo: "rust".into(), - number: 1, + auth_executable: PathBuf::from("/nonexistent/gh"), connect_timeout: Duration::from_secs(3), total_timeout: Duration::from_secs(10), }) @@ -327,10 +323,13 @@ fn github_component_public_read_only_smoke() { let component_bytes = fs::read(component("ST2_GITHUB_ISSUE_COMPONENT")).unwrap(); let loaded = executor.load(&component_bytes).unwrap(); let descriptor = executor.describe(&loaded, None).unwrap(); - assert_eq!(descriptor.topics, ["issue"]); + assert_eq!( + descriptor.topics, + ["body", "state", "labels", "assignment", "discussion"] + ); assert_eq!( descriptor.snapshot_schema_id, - "st2.resource.github-issue.v1" + "dev.schickling.github-issue.snapshot.v1" ); assert_eq!(descriptor.snapshot_media_type, "application/json"); @@ -339,12 +338,9 @@ fn github_component_public_read_only_smoke() { &loaded, &ObservationRequest { invocation_id: 1, - uri: "dev.st2.github-issue://rust-lang/rust/1".into(), + uri: "github-issue://github.com/rust-lang/rust/issues/1".into(), selector: json!({ - "owner": "rust-lang", - "repo": "rust", - "number": 1, - "topics": ["issue"] + "topics": ["discussion"] }), prior_digest: None, demand_watermark: Some(1), diff --git a/wit/github-issue/deps/st2-github-issue/github-issue.wit b/wit/github-issue/deps/st2-github-issue/github-issue.wit index 859280c5..eb8ff297 100644 --- a/wit/github-issue/deps/st2-github-issue/github-issue.wit +++ b/wit/github-issue/deps/st2-github-issue/github-issue.wit @@ -5,12 +5,27 @@ interface github-issue { owner: string, repo: string, number: u64, + } + + record source-object { etag: option, + body: list, + } + + record source-snapshot { + issue: source-object, + latest-comment: option, + observed-at: string, + } + + record source-observation { + current: source-snapshot, + previous: option, } variant issue-response { - ok(tuple, list>), - not-modified(option), + ok(source-observation), + not-modified, } variant issue-error { @@ -21,4 +36,5 @@ interface github-issue { } get: func(request: issue-request) -> result; + bind-snapshot: func(digest: list) -> result<_, issue-error>; } diff --git a/wit/github-pr/deps/st2-github-pr/github-pr.wit b/wit/github-pr/deps/st2-github-pr/github-pr.wit index b9f2fa26..4717203c 100644 --- a/wit/github-pr/deps/st2-github-pr/github-pr.wit +++ b/wit/github-pr/deps/st2-github-pr/github-pr.wit @@ -7,15 +7,8 @@ interface github-pr { number: u64, } - record source-object { - etag: option, - body: list, - } - record source-snapshot { - pull-request: source-object, - check-runs: source-object, - combined-status: source-object, + graphql-data: list, observed-at: string, } @@ -24,18 +17,15 @@ interface github-pr { previous: option, } - variant pull-request-response { - ok(source-observation), - not-modified, - } variant pull-request-error { denied, + authentication-required, unavailable, resource-exhausted, deadline-exceeded, } - get: func(request: pull-request-request) -> result; + get: func(request: pull-request-request) -> result; bind-snapshot: func(digest: list) -> result<_, pull-request-error>; } diff --git a/wit/pty-stats/deps/st2-pty-stats/pty-stats.wit b/wit/pty-stats/deps/st2-pty-stats/pty-stats.wit index 95f43864..efb9faff 100644 --- a/wit/pty-stats/deps/st2-pty-stats/pty-stats.wit +++ b/wit/pty-stats/deps/st2-pty-stats/pty-stats.wit @@ -1,22 +1,86 @@ package compoundingtech:st2-pty-stats@0.1.0; interface pty-stats { - variant scope { - all, - session(string), + enum lifecycle { + running, + exited, + vanished, + absent, } - variant exit-status { - code(s32), - signal(s32), + variant generation { + number(u64), + timestamp(string), } - record outcome { - stdout: list, - stderr: list, - stdout-truncated: bool, - stderr-truncated: bool, - exit: exit-status, + record tag { + key: string, + value: string, + } + + record metadata { + display-name: option, + command: option, + cwd: option, + created-at: option, + exit-code: option, + exited-at: option, + tags: option>, + } + + record terminal { + cols: u32, + rows: u32, + cursor-x: u32, + cursor-y: u32, + scrollback-used: u64, + scrollback-capacity: u64, + } + + record process-resources { + rss-kb: u64, + cpu-percent: f64, + } + + record process { + alive: bool, + exit-code: option, + resources: option, + } + + record clients { + total: u32, + attached: u32, + read-only: u32, + } + + record modes { + sgr-mouse: bool, + cursor-hidden: bool, + kitty-keyboard: bool, + kitty-keyboard-flags: list, + } + + record runtime { + terminal: terminal, + process: process, + clients: clients, + modes: modes, + uptime-seconds: option, + } + + record session-source { + id: string, + observed-at: string, + lifecycle: lifecycle, + generation: option, + metadata: option, + runtime: option, + } + + record source-observation { + current: session-source, + previous: option, } variant pty-stats-error { @@ -27,5 +91,7 @@ interface pty-stats { cancelled, } - get: func(scope: scope) -> result; + list-session: func(session: string) -> result; + stats: func(session: string) -> result; + bind-snapshot: func(digest: list) -> result<_, pty-stats-error>; } diff --git a/wit/vista/deps/st2-vista/vista.wit b/wit/vista/deps/st2-vista/vista.wit index df334dc7..e6a5b0c2 100644 --- a/wit/vista/deps/st2-vista/vista.wit +++ b/wit/vista/deps/st2-vista/vista.wit @@ -6,19 +6,31 @@ interface vista { version: u64, } + record source-snapshot { + manifest-json: list, + observed-at: string, + } + + record source-observation { + current: source-snapshot, + previous: option, + } + variant exit-status { code(s32), signal(s32), } - record outcome { - stdout: list, + record command-failure { stderr: list, - stdout-truncated: bool, - stderr-truncated: bool, exit: exit-status, } + variant artifact-response { + ok(source-observation), + command-failed(command-failure), + } + variant vista-error { denied, unavailable, @@ -27,5 +39,6 @@ interface vista { cancelled, } - get: func(request: artifact-request) -> result; + get: func(request: artifact-request) -> result; + bind-snapshot: func(digest: list) -> result<_, vista-error>; }