From 315cb8a26df9c53bec6bd1a0f97a8d7af80cb3c1 Mon Sep 17 00:00:00 2001 From: Scott Crawford Date: Mon, 2 Nov 2026 12:33:04 -0700 Subject: [PATCH] Add app expiration and renewal commands --- README.md | 24 +++++++ src/bl/apps.rs | 181 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/README.md b/README.md index 06551cd..75719f3 100644 --- a/README.md +++ b/README.md @@ -357,3 +357,27 @@ The result separates verified policy from unknown access and does not claim recipient browser acceptance. `get` performs no sharing, session switch, or deployment. If policy inspection is unavailable, the audience is reported as unknown. Portal deep links require the matching portal and Kgoose workspace-selection changes. + +### App lifetime inspection and renewal + +When the selected control plane advertises lifetime operations, read the exact +app's deadline and renewal eligibility: + +```sh +bl apps expiration APP_ID --base-url "$BL_APPS_CONTROL_PLANE_URL" --json +``` + +Use the returned incarnation, expiration revision, and an allowed day count to +renew as the owner. Preserve all fields on a retry, including the request ID: + +```sh +bl apps renew APP_ID --base-url "$BL_APPS_CONTROL_PLANE_URL" \ + --app-incarnation-id INCARNATION --expected-revision REVISION \ + --request-id UNIQUE_RENEWAL_ID --days DAYS --json +``` + +Renewal adds days to the previous deadline. The server enforces ownership and +the renewal window. A conflict does not trigger a retry with a newer revision; +read current expiration before choosing a new request. An identical retained +retry returns the original receipt without another extension. These commands +do not enroll, restore, or delete an app, and do not prove serving readiness. diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 0844d22..0e9671d 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -109,6 +109,8 @@ pub fn command() -> Command { ), )) .subcommand(control_plane_args(app_get_command("get"))) + .subcommand(control_plane_args(expiration_command(false))) + .subcommand(control_plane_args(expiration_command(true))) .subcommand(control_plane_args( Command::new("versions") .about("List active and rollback-candidate versions for an app") @@ -516,6 +518,30 @@ pub fn command() -> Command { )) } +fn expiration_command(renew: bool) -> Command { + let command = Command::new(if renew { "renew" } else { "expiration" }) + .about(if renew { + "Extend an enrolled app's expiration as its owner" + } else { + "Read an app's expiration and renewal eligibility" + }) + .arg(Arg::new("app-id").required(true).value_name("APP_ID")) + .arg( + Arg::new("environment") + .long("environment") + .value_name("ENVIRONMENT"), + ); + if !renew { + return command; + } + command + .long_about("Renew within the server's renewal window. Read expiration first, then supply its exact incarnation and revision. Preserve every field, including request ID, on retries. A conflict requires a fresh read; this command never retries with a newer revision automatically.") + .arg(Arg::new("request-id").long("request-id").required(true).help("Stable ID for this renewal; reuse only for an identical retry")) + .arg(Arg::new("app-incarnation-id").long("app-incarnation-id").required(true).help("Exact incarnation from apps expiration")) + .arg(Arg::new("expected-revision").long("expected-revision").required(true).value_parser(clap::value_parser!(u64).range(1..=9_007_199_254_740_990))) + .arg(Arg::new("days").long("days").required(true).value_parser(clap::value_parser!(u32).range(1..)).help("Extension from the previous deadline, chosen from server renewal options")) +} + fn app_get_command(name: &'static str) -> Command { Command::new(name) .about("Show an app, who can open it, and its Share & manage link") @@ -609,6 +635,8 @@ fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { Some(("contract", contract_matches)) => run_contract(config, contract_matches), Some(("list", list_matches)) => run_list(config, list_matches), Some(("get", get_matches)) => run_get(config, get_matches), + Some(("expiration", args)) => run_expiration(config, args, false), + Some(("renew", args)) => run_expiration(config, args, true), Some(("handoff", handoff_matches)) => run_get(config, handoff_matches), Some(("versions", versions_matches)) => run_versions(config, versions_matches), Some(("create", create_matches)) => run_create(config, create_matches), @@ -647,6 +675,32 @@ fn run_list(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { print_json(&response) } +fn run_expiration(config: &SkillsConfig, matches: &ArgMatches, renew: bool) -> Result<()> { + let app_id = matches + .get_one::("app-id") + .context("expected app id")?; + let environment = matches.get_one::("environment"); + let (client, credential) = control_plane_context(config, matches)?; + let response = if renew { + let mut body = json!({ + "request_id": matches.get_one::("request-id").context("expected request id")?, + "app_incarnation_id": matches.get_one::("app-incarnation-id").context("expected incarnation")?, + "expected_revision": matches.get_one::("expected-revision").context("expected revision")?, + "days": matches.get_one::("days").context("expected days")?, + }); + if let Some(environment) = environment { + body["environment"] = json!(environment); + } + client.renew_expiration(&credential, app_id, &body)? + } else { + let query = environment + .map(|e| vec![("environment", e.clone())]) + .unwrap_or_default(); + client.get_app_resource(&credential, app_id, "expiration", &query)? + }; + print_json(&response) +} + fn run_get(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { let flag = matches.get_one::("management-base-url"); let flag_given = flag.is_some_and(|value| !value.trim().is_empty()); @@ -2554,6 +2608,25 @@ impl ControlPlaneClient { Ok(url) } + fn renew_expiration( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + body: &Value, + ) -> Result { + let mut url = self.app_resource_url(app_id, "expiration", &[])?; + url.path_segments_mut() + .map_err(|_| anyhow::anyhow!("invalid Apps URL"))? + .push("renew"); + let path = request_path(&url); + self.authorized_json_request(credential, "POST", &path, |authorization| { + self.standard_request(self.client.post(url.clone()), authorization) + .json(body) + .build() + .context("build app renewal request") + }) + } + fn get_app_resource( &self, credential: &ComposeSessionCredential, @@ -4513,6 +4586,114 @@ mod tests { } } + #[test] + fn lifetime_commands_preserve_owner_request_and_server_response() { + let credential = "apps-e2e-only.lifetime.session+credential"; + for environment in [None, Some("staging/west")] { + let view = json!({"ok": true, "app_incarnation_id": "inc-1", "expiration": {"expiration_revision": 7}, "renewal": {"eligible": true, "days": [2]}}); + let receipt = json!({"ok": true, "replayed": true, "receipt": {"previous_expires_at": "2026-11-04T12:00:00Z", "expires_at": "2026-11-06T12:00:00Z"}}); + let auth = ProcessServer::start(vec![process_auth_response(), process_auth_response()]); + let server = ProcessServer::start(vec![ + ProcessResponse::json(view.clone()), + ProcessResponse::json(receipt.clone()), + ]); + for (action, expected) in [("expiration", &view), ("renew", &receipt)] { + let mut args = vec![ + "apps", + action, + "test/app", + "--base-url", + APPROVED_TEST_BASE_URL, + "--json", + ]; + if let Some(env) = environment { + args.extend(["--environment", env]); + } + if action == "renew" { + args.extend([ + "--request-id", + "renew-1", + "--app-incarnation-id", + "inc-1", + "--expected-revision", + "7", + "--days", + "2", + ]); + } + let output = process_command(&auth, &server, &args, credential) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + &serde_json::from_str::(&process_stdout(&output)).unwrap(), + expected + ); + } + for request in auth.finish() { + assert_process_auth(&request, credential); + } + let requests = server.finish(); + assert_eq!(requests.len(), 2); + let path = if environment.is_some() { + "/v1/agent/apps/test%2Fapp/expiration?environment=staging%2Fwest" + } else { + "/v1/agent/apps/test%2Fapp/expiration" + }; + assert_process_control_plane(&requests[0], "GET", path, credential); + assert_process_control_plane( + &requests[1], + "POST", + "/v1/agent/apps/test%2Fapp/expiration/renew", + credential, + ); + let mut body = json!({"request_id":"renew-1","app_incarnation_id":"inc-1","expected_revision":7,"days":2}); + if let Some(env) = environment { + body["environment"] = json!(env); + } + assert_eq!(requests[1].body, body); + } + } + + #[test] + fn lifetime_renewal_conflict_does_not_retry_or_change_revision() { + let credential = "apps-e2e-only.lifetime.conflict"; + let auth = ProcessServer::start(vec![process_auth_response()]); + let server = ProcessServer::start(vec![ProcessResponse::json_status( + 409, + json!({"ok":false,"error":{"code":"outside_renewal_window","message":"Not yet eligible"},"next_action":"Read expiration before retrying."}), + )]); + let args = [ + "apps", + "renew", + "test-app", + "--base-url", + APPROVED_TEST_BASE_URL, + "--request-id", + "early-1", + "--app-incarnation-id", + "inc-1", + "--expected-revision", + "7", + "--days", + "2", + "--json", + ]; + let output = process_command(&auth, &server, &args, credential) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("outside_renewal_window")); + auth.finish(); + let requests = server.finish(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].body["expected_revision"], 7); + } + #[test] fn bl_apps_get_process_encodes_app_id_and_preserves_versions() { let credential = "apps-e2e-only.get.session+credential";