From 0e43c3cec4e0a07d9b4daadf629f0d0de7ec2967 Mon Sep 17 00:00:00 2001 From: Scott Crawford Date: Wed, 21 Oct 2026 10:52:03 -0700 Subject: [PATCH 1/3] Support managed MySQL in Apps commands --- README.md | 28 +++ src/bl/apps.rs | 628 +++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 563 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 9fca77a..2a783b2 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,34 @@ Use `--json` for machine-readable output and `--verbose` for request diagnostics. Do not include credentials or other sensitive data when sharing verbose output. +## Managed MySQL apps + +When the selected Apps Platform contract and server support managed MySQL, +create a process app with: + +```bash +bl apps create --app-id inventory --runtime-profile process --persistence mysql --json +``` + +The command plans, reserves the exact app ID, and initializes its database +before returning success. MySQL is unavailable for `static` and `fetch-js`. +The client preserves server availability errors; it does not fall back to SQLite. + +For an existing app, inspect it first. To resume failed initialization or +enable MySQL on a compatible stateless process app, use: + +```bash +bl apps get inventory --json +bl apps initialize inventory --runtime-profile process --persistence mysql --json +``` + +`initialize` checks that the app exists, plans the requested settings, and +initializes the same ID without another reservation. It does not reclaim +deleted apps or migrate an existing database to another engine. Use the same +selected endpoint and environment throughout. Check the returned +`initialize.persistence` is `mysql` before deploying the process artifact. +Deleting a MySQL app also deletes its managed database. + For inherited Apps Platform trace context and failure correlation, see [Apps operation correlation](docs/apps-operation-correlation.md). diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 03a6d45..005c55d 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -195,10 +195,27 @@ pub fn command() -> Command { Arg::new("persistence") .long("persistence") .value_name("MODE") - .value_parser(["none", "sqlite"]) - .help("Requested persistence mode"), + .value_parser(["none", "sqlite", "mysql"]) + .help("Requested persistence mode (mysql requires --runtime-profile process)"), ), )) + .subcommand(control_plane_args( + Command::new("initialize") + .about("Initialize or repair an existing app stack and persistence") + .long_about("Inspect an existing app, plan its requested settings, and initialize \ + that exact app without reserving another identity. Use for initialization \ + recovery or an explicitly requested persistence update. MySQL requires \ + --runtime-profile process. This does not migrate database engines or reclaim deleted apps.") + .arg(Arg::new("app-id").value_name("APP_ID").required(true) + .help("Exact existing app identifier returned by list, get, or create")) + .arg(Arg::new("environment").long("environment").value_name("ENVIRONMENT") + .help("Optional Compose environment override")) + .arg(Arg::new("runtime-profile").long("runtime-profile").value_name("PROFILE") + .help("Requested runtime profile; existing apps retain their deployed profile")) + .arg(Arg::new("persistence").long("persistence").value_name("MODE") + .value_parser(["none", "sqlite", "mysql"]) + .help("Requested persistence mode; database engine migration is unsupported")), + )) .subcommand(control_plane_args( Command::new("deploy") .about("Deploy a prebuilt app artifact") @@ -523,6 +540,7 @@ fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { Some(("get", get_matches)) => run_get(config, get_matches), Some(("versions", versions_matches)) => run_versions(config, versions_matches), Some(("create", create_matches)) => run_create(config, create_matches), + Some(("initialize", initialize_matches)) => run_initialize(config, initialize_matches), Some(("deploy", deploy_matches)) => run_deploy(config, deploy_matches), Some(("rollback", rollback_matches)) => run_rollback(config, rollback_matches), Some(("delete", delete_matches)) => run_delete(config, delete_matches), @@ -576,7 +594,22 @@ fn run_versions(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { print_json(&response) } +fn validate_mysql_runtime(matches: &ArgMatches) -> Result { + let mysql_requested = + matches.get_one::("persistence").map(String::as_str) == Some("mysql"); + if mysql_requested + && matches + .get_one::("runtime-profile") + .map(String::as_str) + != Some("process") + { + anyhow::bail!("managed MySQL requires --runtime-profile process"); + } + Ok(mysql_requested) +} + fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let mysql_requested = validate_mysql_runtime(matches)?; let (client, credential) = control_plane_context(config, matches)?; let requested_app_id = matches.get_one::("app-id").map(String::as_str); let request = PlanRequest { @@ -610,6 +643,12 @@ fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { ) .map_err(|error| correlate_response_error(error, &plan))?; let plan_requests_initialize = initialize_required || initialize_recommended; + if mysql_requested { + validate_mysql_plan(&plan).map_err(|error| correlate_response_error(error, &plan))?; + if !plan_requests_initialize { + return Err(correlate_response_error(anyhow::anyhow!("Apps Platform mysql plan must request initialization before deployment; refusing to reserve the app"), &plan)); + } + } let mutation_request = mutation_request_from_plan(&plan); let (reservation, reservation_reconciled) = reconcile_create_reservation(&client, &credential, &app_id, &plan, &mutation_request)?; @@ -617,14 +656,17 @@ fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { let response = client .initialize(&credential, &app_id, &mutation_request) .with_context(|| { - format!( - "Apps Platform reserved app_id {app_id:?}, but initialization did not complete. Retry the same `bl apps create` command to reconcile the reservation and continue initialization." - ) + if mysql_requested { + format!("Apps Platform reserved app_id {app_id:?}, but MySQL initialization did not complete. Inspect it with `bl apps get`, then resume the same app with `bl apps initialize` using the same endpoint, environment, --runtime-profile process and --persistence mysql.") + } else { + format!("Apps Platform reserved app_id {app_id:?}, but initialization did not complete. Retry the same `bl apps create` command to reconcile the reservation and continue initialization.") + } })?; let initialized_app_id = required_response_string(&response, "app_id", "Apps Platform initialize")?; require_exact_app_id("initialize", &app_id, initialized_app_id) .map_err(|error| correlate_response_error(error, &response))?; + confirm_mysql_initialization(&response, mysql_requested)?; Some(response) } else { None @@ -652,6 +694,71 @@ fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { })) } +fn validate_mysql_plan(plan: &Value) -> Result<()> { + if plan.get("persistence").and_then(Value::as_str) != Some("mysql") + || plan.get("runtime_profile").and_then(Value::as_str) != Some("process") + { + anyhow::bail!("Apps Platform plan did not preserve mysql persistence and the process runtime; refusing to reserve or initialize the app"); + } + Ok(()) +} + +fn confirm_mysql_initialization(response: &Value, mysql_requested: bool) -> Result<()> { + if mysql_requested && response.get("persistence").and_then(Value::as_str) != Some("mysql") { + return Err(correlate_response_error( + anyhow::anyhow!("Apps Platform initialization did not confirm mysql persistence; inspect the app before deploying"), response, + )); + } + Ok(()) +} + +fn run_initialize(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let mysql_requested = validate_mysql_runtime(matches)?; + let app_id = matches + .get_one::("app-id") + .context("expected app id")?; + let environment = matches.get_one::("environment").map(String::as_str); + let (client, credential) = control_plane_context(config, matches)?; + let existing = client.get_app(&credential, app_id, environment)?; + let app = existing + .get("app") + .context("Apps Platform get response did not include app")?; + let observed_id = required_response_string(app, "app_id", "Apps Platform get")?; + require_exact_app_id("get", app_id, observed_id) + .map_err(|error| correlate_response_error(error, &existing))?; + if !value_is_absent_or_empty_text(app.get("deleted_at")) + || app.get("status").and_then(Value::as_str) == Some("deleted") + { + anyhow::bail!( + "Apps Platform initialize cannot reclaim a deleted app; select an active app" + ); + } + let request = PlanRequest { + app_id: Some(app_id), + name: None, + environment, + runtime_profile: matches + .get_one::("runtime-profile") + .map(String::as_str), + persistence: matches.get_one::("persistence").map(String::as_str), + client_version: client.client_version_text(), + }; + let plan = client.plan(&credential, &request)?; + let planned_id = required_response_string(&plan, "app_id", "Apps Platform plan")?; + require_exact_app_id("plan", app_id, planned_id) + .map_err(|error| correlate_response_error(error, &plan))?; + if mysql_requested { + validate_mysql_plan(&plan).map_err(|error| correlate_response_error(error, &plan))?; + } + let response = client.initialize(&credential, app_id, &mutation_request_from_plan(&plan))?; + let initialized_id = required_response_string(&response, "app_id", "Apps Platform initialize")?; + require_exact_app_id("initialize", app_id, initialized_id) + .map_err(|error| correlate_response_error(error, &response))?; + confirm_mysql_initialization(&response, mysql_requested)?; + print_json(&json!({"ok":true, "app_id":app_id, "initialized":true, + "external_url":response.get("external_url"), "plan":plan, "initialize":response})) +} + fn reconcile_create_reservation( client: &ControlPlaneClient, credential: &ComposeSessionCredential, @@ -745,6 +852,7 @@ fn is_matching_incomplete_reservation( ("environment", "environment"), ("persistence", "persistence"), ("runtime_class", "runtime_class"), + ("runtime_profile", "runtime_profile"), ("name", "name"), ] .into_iter() @@ -1049,7 +1157,12 @@ struct DeployOptions { fn mutation_request_from_plan(plan: &Value) -> Value { let mut request = Map::new(); - for field in ["environment", "persistence", "runtime_class"] { + for field in [ + "environment", + "persistence", + "runtime_class", + "runtime_profile", + ] { if let Some(value) = plan.get(field).and_then(Value::as_str) { if !value.is_empty() { request.insert(field.to_string(), Value::String(value.to_string())); @@ -2979,106 +3092,432 @@ mod tests { #[test] fn bl_apps_create_process_runs_plan_reserve_and_initialize() { - let credential = "apps-e2e-only.create.session+credential"; - let plan = json!({ - "app_id": "merchant-lookup", - "display_name": "Merchant Lookup", - "environment": "staging", - "persistence": "sqlite", - "runtime_class": "default", - "initialize": {"required": true, "recommended": false} - }); - let initialized = json!({ - "app_id": "merchant-lookup", - "external_url": "https://merchant-lookup--bpsites.example/" - }); - let reservation = json!({ - "app_id": "merchant-lookup", - "external_url": "https://merchant-lookup--bpsites.example/" - }); + for (persistence, profile) in [("sqlite", "fetch-js"), ("mysql", "process")] { + let credential = "apps-e2e-only.create.session+credential"; + let plan = json!({ + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": persistence, + "runtime_profile": profile, + "runtime_class": "default", + "initialize": {"required": true, "recommended": false} + }); + let initialized = json!({ + "app_id": "merchant-lookup", + "external_url": "https://merchant-lookup--bpsites.example/", + "persistence": persistence + }); + let reservation = json!({ + "app_id": "merchant-lookup", + "external_url": "https://merchant-lookup--bpsites.example/" + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(plan.clone()), + ProcessResponse::json(reservation.clone()), + ProcessResponse::json(initialized.clone()), + ]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "merchant-lookup", + "--name", + "Merchant Lookup", + "--environment", + "staging", + "--runtime-profile", + profile, + "--persistence", + persistence, + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps create process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value = serde_json::from_str::(&process_stdout(&output)) + .expect("parse create process output"); + assert_eq!(value["app_id"], "merchant-lookup"); + assert_eq!(value["reserved"], true); + assert_eq!(value["initialized"], true); + assert_eq!(value["plan"], plan); + assert_eq!(value["reservation"], reservation); + assert_eq!(value["initialize"], initialized); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 3); + assert_process_control_plane(&requests[0], "POST", APPS_PLAN_PATH, credential); + assert_eq!( + requests[0].body, + json!({ + "app_id": "merchant-lookup", + "name": "Merchant Lookup", + "environment": "staging", + "runtime_profile": profile, + "persistence": persistence, + "client_version": "0.2.0" + }) + ); + assert_process_control_plane( + &requests[1], + "POST", + "/v1/agent/apps/merchant-lookup/reserve", + credential, + ); + assert_eq!( + requests[1].body, + json!({ + "environment": "staging", + "persistence": persistence, + "runtime_profile": profile, + "runtime_class": "default", + "name": "Merchant Lookup" + }) + ); + assert_process_control_plane( + &requests[2], + "POST", + "/v1/agent/apps/merchant-lookup/initialize", + credential, + ); + assert_eq!(requests[2].body, requests[1].body); + } + } + + #[test] + fn bl_apps_create_mysql_requires_explicit_process_before_authentication() { + for profile in [None, Some("fetch-js"), Some("static")] { + let mut args = vec![ + "apps", + "create", + "--app-id", + "mysql-app", + "--persistence", + "mysql", + "--base-url", + APPROVED_TEST_BASE_URL, + ]; + if let Some(profile) = profile { + args.extend(["--runtime-profile", profile]); + } + let matches = command() + .try_get_matches_from(args) + .expect("parse mysql create"); + let (_, create) = matches.subcommand().unwrap(); + let error = validate_mysql_runtime(create).unwrap_err(); + assert!(error + .to_string() + .contains("requires --runtime-profile process")); + } + } + + #[test] + fn bl_apps_create_mysql_rejects_unsafe_plan_before_reservation() { + for (persistence, profile, initialize) in [ + (Some("sqlite"), Some("process"), true), + (None, Some("process"), true), + (Some("mysql"), Some("fetch-js"), true), + (Some("mysql"), None, true), + (Some("mysql"), Some("process"), false), + ] { + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(json!({ + "app_id": "mysql-app", "persistence": persistence, "runtime_profile": profile, + "initialize": {"required": initialize, "recommended": false} + }))]); + let output = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "mysql-app", + "--persistence", + "mysql", + "--runtime-profile", + "process", + "--base-url", + APPROVED_TEST_BASE_URL, + "--json", + ], + "apps-e2e-only.mysql-plan", + ) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("refusing to reserve")); + auth_server.finish(); + let requests = control_plane.finish(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, APPS_PLAN_PATH); + } + } + + #[test] + fn bl_apps_create_mysql_preserves_server_rejections_without_fallback() { + for (failed_stage, code) in [(0, "mysql_unavailable"), (2, "mysql_not_ready")] { + let plan = ProcessResponse::json(json!({ + "app_id": "mysql-app", "persistence": "mysql", "runtime_profile": "process", + "environment": "staging", "initialize": {"required": true, "recommended": true} + })); + let failure = ProcessResponse::json_status( + 409, + json!({ + "ok": false, "error": {"code": code, "message": "private provider diagnostic"}, + "next_action": "Inspect this app on the selected control plane." + }), + ); + let responses = if failed_stage == 0 { + vec![failure] + } else { + vec![ + plan, + ProcessResponse::json(json!({"app_id":"mysql-app"})), + failure, + ] + }; + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(responses); + let credential = "apps-e2e-only.mysql-failure"; + let output = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "mysql-app", + "--persistence", + "mysql", + "--runtime-profile", + "process", + "--base-url", + APPROVED_TEST_BASE_URL, + "--json", + ], + credential, + ) + .output() + .unwrap(); + assert!(!output.status.success()); + let error = process_error(&output); + assert_eq!(error["error"]["code"], code); + assert!(error["next_action"] + .as_str() + .unwrap() + .contains("selected control plane")); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("private provider diagnostic")); + assert!(!stderr.contains(credential)); + auth_server.finish(); + let requests = control_plane.finish(); + assert_eq!( + requests.len(), + failed_stage + 1, + "no retries or sqlite fallback" + ); + } + } + + #[test] + fn bl_apps_create_mysql_requires_initialization_to_confirm_persistence() { + for persistence in [None, Some("sqlite")] { + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(json!({"app_id":"mysql-app", "persistence":"mysql", + "runtime_profile":"process", "initialize":{"required":true,"recommended":false}})), + ProcessResponse::json(json!({"app_id":"mysql-app"})), + ProcessResponse::json(json!({"app_id":"mysql-app", "persistence":persistence, + "external_url":"https://mysql-app.example/"})), + ]); + let output = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "mysql-app", + "--persistence", + "mysql", + "--runtime-profile", + "process", + "--base-url", + APPROVED_TEST_BASE_URL, + "--json", + ], + "apps-e2e-only.mysql-confirm", + ) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("did not confirm mysql persistence")); + auth_server.finish(); + assert_eq!(control_plane.finish().len(), 3); + } + } + + #[test] + fn bl_apps_initialize_mysql_plans_and_repairs_existing_app_without_reserve() { + for recommended in [false, true] { + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json( + json!({"ok":true,"app":{"app_id":"mysql-app","persistence":"mysql","status":"active"}}), + ), + ProcessResponse::json(json!({"app_id":"mysql-app","environment":"staging", + "persistence":"mysql","runtime_profile":"process", + "initialize":{"required":false,"recommended":recommended}})), + ProcessResponse::json(json!({"app_id":"mysql-app","persistence":"mysql", + "external_url":"https://mysql-app.example/"})), + ]); + let output = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "initialize", + "mysql-app", + "--environment", + "staging", + "--persistence", + "mysql", + "--runtime-profile", + "process", + "--base-url", + APPROVED_TEST_BASE_URL, + "--json", + ], + "apps-e2e-only.mysql-initialize", + ) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let response: Value = serde_json::from_str(&process_stdout(&output)).unwrap(); + assert_eq!(response["initialized"], true); + assert_eq!(response["initialize"]["persistence"], "mysql"); + auth_server.finish(); + let requests = control_plane.finish(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].method, "GET"); + assert_eq!( + requests[0].path, + "/v1/agent/apps/mysql-app?environment=staging" + ); + assert_eq!(requests[1].path, APPS_PLAN_PATH); + assert_eq!(requests[1].body["persistence"], "mysql"); + assert_eq!(requests[1].body["runtime_profile"], "process"); + assert_eq!(requests[2].path, "/v1/agent/apps/mysql-app/initialize"); + assert_eq!( + requests[2].body, + json!({"environment":"staging", + "runtime_profile":"process","persistence":"mysql"}) + ); + } + } + + #[test] + fn bl_apps_initialize_rejects_missing_deleted_or_substituted_app_before_plan() { + for existing in [ + ProcessResponse::json_status(404, json!({"error":{"code":"app_not_found"}})), + ProcessResponse::json(json!({"app":{"app_id":"mysql-app","status":"deleted"}})), + ProcessResponse::json( + json!({"app":{"app_id":"mysql-app","deleted_at":"2026-01-01T00:00:00Z"}}), + ), + ProcessResponse::json(json!({"app":{"app_id":"another-app"}})), + ] { + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![existing]); + let output = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "initialize", + "mysql-app", + "--persistence", + "mysql", + "--runtime-profile", + "process", + "--base-url", + APPROVED_TEST_BASE_URL, + "--json", + ], + "apps-e2e-only.mysql-initialize-stop", + ) + .output() + .unwrap(); + assert!(!output.status.success()); + auth_server.finish(); + let requests = control_plane.finish(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "GET"); + } + } + + #[test] + fn bl_apps_initialize_mysql_rejects_downgraded_plan_before_mutation() { let auth_server = ProcessServer::start(vec![process_auth_response()]); let control_plane = ProcessServer::start(vec![ - ProcessResponse::json(plan.clone()), - ProcessResponse::json(reservation.clone()), - ProcessResponse::json(initialized.clone()), + ProcessResponse::json(json!({"app":{"app_id":"mysql-app"}})), + ProcessResponse::json( + json!({"app_id":"mysql-app", "persistence":"sqlite","runtime_profile":"process"}), + ), ]); - let mut command = process_command( + let output = process_command( &auth_server, &control_plane, &[ "apps", - "create", - "--app-id", - "merchant-lookup", - "--name", - "Merchant Lookup", - "--environment", - "staging", - "--runtime-profile", - "fetch-js", + "initialize", + "mysql-app", "--persistence", - "sqlite", + "mysql", + "--runtime-profile", + "process", "--base-url", APPROVED_TEST_BASE_URL, - "--client-version", - "0.2.0", "--json", ], - credential, - ); + "apps-e2e-only.mysql-initialize-downgrade", + ) + .output() + .unwrap(); + assert!(!output.status.success()); + auth_server.finish(); + assert_eq!(control_plane.finish().len(), 2); + } - let output = command.output().expect("run Apps create process command"); - assert!( - output.status.success(), - "stderr was: {}", - String::from_utf8_lossy(&output.stderr) - ); - let value = serde_json::from_str::(&process_stdout(&output)) - .expect("parse create process output"); - assert_eq!(value["app_id"], "merchant-lookup"); - assert_eq!(value["reserved"], true); - assert_eq!(value["initialized"], true); - assert_eq!(value["plan"], plan); - assert_eq!(value["reservation"], reservation); - assert_eq!(value["initialize"], initialized); - let auth_requests = auth_server.finish(); - let requests = control_plane.finish(); - assert_process_auth(&auth_requests[0], credential); - assert_eq!(requests.len(), 3); - assert_process_control_plane(&requests[0], "POST", APPS_PLAN_PATH, credential); - assert_eq!( - requests[0].body, - json!({ - "app_id": "merchant-lookup", - "name": "Merchant Lookup", - "environment": "staging", - "runtime_profile": "fetch-js", - "persistence": "sqlite", - "client_version": "0.2.0" - }) - ); - assert_process_control_plane( - &requests[1], - "POST", - "/v1/agent/apps/merchant-lookup/reserve", - credential, - ); - assert_eq!( - requests[1].body, - json!({ - "environment": "staging", - "persistence": "sqlite", - "runtime_class": "default", - "name": "Merchant Lookup" - }) - ); - assert_process_control_plane( - &requests[2], - "POST", - "/v1/agent/apps/merchant-lookup/initialize", - credential, - ); - assert_eq!(requests[2].body, requests[1].body); + #[test] + fn incomplete_reservation_with_another_runtime_cannot_be_adopted() { + let existing = json!({"ok":true,"app":{"app_id":"mysql-app","role":"owner", + "route_status":"idle","route_revision":1,"runtime_profile":"static"},"versions":[]}); + assert!(!is_matching_incomplete_reservation( + &existing, + "mysql-app", + &json!({"runtime_profile":"process"}) + )); } #[test] @@ -3484,6 +3923,7 @@ mod tests { "persistence": "none", "runtime_class": "default", "role": "owner", + "runtime_profile": "process", "status": "idle", "route_status": "idle", "route_revision": 1 @@ -3593,6 +4033,7 @@ mod tests { "persistence": "none", "runtime_class": "default", "role": "owner", + "runtime_profile": "process", "status": "idle", "route_status": "idle", "route_revision": 1 @@ -3686,6 +4127,7 @@ mod tests { "persistence": "none", "runtime_class": "default", "role": "owner", + "runtime_profile": "fetch-js", "status": "idle", "route_status": "idle", "route_revision": 1 From 3d64eb5eb49fb6f9c0f61f0416f7cf04c12f1e84 Mon Sep 17 00:00:00 2001 From: Scott Crawford Date: Wed, 21 Oct 2026 15:13:42 -0700 Subject: [PATCH 2/3] Clarify MySQL data retention after app deletion --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2a783b2..6d46e75 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ initializes the same ID without another reservation. It does not reclaim deleted apps or migrate an existing database to another engine. Use the same selected endpoint and environment throughout. Check the returned `initialize.persistence` is `mysql` before deploying the process artifact. -Deleting a MySQL app also deletes its managed database. +Deleting a MySQL app revokes its database access, but the database data is retained. For inherited Apps Platform trace context and failure correlation, see [Apps operation correlation](docs/apps-operation-correlation.md). From 1d0970a94497680147073b7e25d968502c872c20 Mon Sep 17 00:00:00 2001 From: Scott Crawford Date: Thu, 22 Oct 2026 11:57:42 -0700 Subject: [PATCH 3/3] Support fetch-js in MySQL app commands --- README.md | 13 +- src/bl/apps.rs | 559 +++++++++++++++++++++++++++---------------------- 2 files changed, 311 insertions(+), 261 deletions(-) diff --git a/README.md b/README.md index 6d46e75..97d3263 100644 --- a/README.md +++ b/README.md @@ -75,29 +75,30 @@ verbose output. ## Managed MySQL apps When the selected Apps Platform contract and server support managed MySQL, -create a process app with: +create a fetch-js app with: ```bash -bl apps create --app-id inventory --runtime-profile process --persistence mysql --json +bl apps create --app-id inventory --runtime-profile fetch-js --persistence mysql --json ``` The command plans, reserves the exact app ID, and initializes its database -before returning success. MySQL is unavailable for `static` and `fetch-js`. +before returning success. MySQL supports `fetch-js` and `process` apps; pass +the runtime explicitly. MySQL is unavailable for `static` apps. The client preserves server availability errors; it does not fall back to SQLite. For an existing app, inspect it first. To resume failed initialization or -enable MySQL on a compatible stateless process app, use: +enable MySQL on a compatible stateless server app, use its existing runtime: ```bash bl apps get inventory --json -bl apps initialize inventory --runtime-profile process --persistence mysql --json +bl apps initialize inventory --runtime-profile fetch-js --persistence mysql --json ``` `initialize` checks that the app exists, plans the requested settings, and initializes the same ID without another reservation. It does not reclaim deleted apps or migrate an existing database to another engine. Use the same selected endpoint and environment throughout. Check the returned -`initialize.persistence` is `mysql` before deploying the process artifact. +`initialize.persistence` is `mysql` before deploying the matching runtime artifact. Deleting a MySQL app revokes its database access, but the database data is retained. For inherited Apps Platform trace context and failure correlation, see diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 005c55d..79dd5fc 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -196,7 +196,7 @@ pub fn command() -> Command { .long("persistence") .value_name("MODE") .value_parser(["none", "sqlite", "mysql"]) - .help("Requested persistence mode (mysql requires --runtime-profile process)"), + .help("Requested persistence mode (mysql requires --runtime-profile process or fetch-js)"), ), )) .subcommand(control_plane_args( @@ -205,7 +205,7 @@ pub fn command() -> Command { .long_about("Inspect an existing app, plan its requested settings, and initialize \ that exact app without reserving another identity. Use for initialization \ recovery or an explicitly requested persistence update. MySQL requires \ - --runtime-profile process. This does not migrate database engines or reclaim deleted apps.") + --runtime-profile process or fetch-js. This does not migrate database engines or reclaim deleted apps.") .arg(Arg::new("app-id").value_name("APP_ID").required(true) .help("Exact existing app identifier returned by list, get, or create")) .arg(Arg::new("environment").long("environment").value_name("ENVIRONMENT") @@ -598,12 +598,14 @@ fn validate_mysql_runtime(matches: &ArgMatches) -> Result { let mysql_requested = matches.get_one::("persistence").map(String::as_str) == Some("mysql"); if mysql_requested - && matches - .get_one::("runtime-profile") - .map(String::as_str) - != Some("process") + && !matches!( + matches + .get_one::("runtime-profile") + .map(String::as_str), + Some("process" | "fetch-js") + ) { - anyhow::bail!("managed MySQL requires --runtime-profile process"); + anyhow::bail!("managed MySQL requires --runtime-profile process or fetch-js"); } Ok(mysql_requested) } @@ -644,7 +646,8 @@ fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { .map_err(|error| correlate_response_error(error, &plan))?; let plan_requests_initialize = initialize_required || initialize_recommended; if mysql_requested { - validate_mysql_plan(&plan).map_err(|error| correlate_response_error(error, &plan))?; + validate_mysql_plan(&plan, request.runtime_profile) + .map_err(|error| correlate_response_error(error, &plan))?; if !plan_requests_initialize { return Err(correlate_response_error(anyhow::anyhow!("Apps Platform mysql plan must request initialization before deployment; refusing to reserve the app"), &plan)); } @@ -657,7 +660,8 @@ fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { .initialize(&credential, &app_id, &mutation_request) .with_context(|| { if mysql_requested { - format!("Apps Platform reserved app_id {app_id:?}, but MySQL initialization did not complete. Inspect it with `bl apps get`, then resume the same app with `bl apps initialize` using the same endpoint, environment, --runtime-profile process and --persistence mysql.") + let profile = request.runtime_profile.expect("validated MySQL runtime"); + format!("Apps Platform reserved app_id {app_id:?}, but MySQL initialization did not complete. Inspect it with `bl apps get`, then resume the same app with `bl apps initialize` using the same endpoint, environment, --runtime-profile {profile} and --persistence mysql.") } else { format!("Apps Platform reserved app_id {app_id:?}, but initialization did not complete. Retry the same `bl apps create` command to reconcile the reservation and continue initialization.") } @@ -694,11 +698,11 @@ fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { })) } -fn validate_mysql_plan(plan: &Value) -> Result<()> { +fn validate_mysql_plan(plan: &Value, requested_profile: Option<&str>) -> Result<()> { if plan.get("persistence").and_then(Value::as_str) != Some("mysql") - || plan.get("runtime_profile").and_then(Value::as_str) != Some("process") + || plan.get("runtime_profile").and_then(Value::as_str) != requested_profile { - anyhow::bail!("Apps Platform plan did not preserve mysql persistence and the process runtime; refusing to reserve or initialize the app"); + anyhow::bail!("Apps Platform plan did not preserve mysql persistence and the requested runtime; refusing to reserve or initialize the app"); } Ok(()) } @@ -748,7 +752,8 @@ fn run_initialize(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { require_exact_app_id("plan", app_id, planned_id) .map_err(|error| correlate_response_error(error, &plan))?; if mysql_requested { - validate_mysql_plan(&plan).map_err(|error| correlate_response_error(error, &plan))?; + validate_mysql_plan(&plan, request.runtime_profile) + .map_err(|error| correlate_response_error(error, &plan))?; } let response = client.initialize(&credential, app_id, &mutation_request_from_plan(&plan))?; let initialized_id = required_response_string(&response, "app_id", "Apps Platform initialize")?; @@ -3091,8 +3096,12 @@ mod tests { } #[test] - fn bl_apps_create_process_runs_plan_reserve_and_initialize() { - for (persistence, profile) in [("sqlite", "fetch-js"), ("mysql", "process")] { + fn bl_apps_create_server_runs_plan_reserve_and_initialize() { + for (persistence, profile) in [ + ("sqlite", "fetch-js"), + ("mysql", "process"), + ("mysql", "fetch-js"), + ] { let credential = "apps-e2e-only.create.session+credential"; let plan = json!({ "app_id": "merchant-lookup", @@ -3200,240 +3209,263 @@ mod tests { } #[test] - fn bl_apps_create_mysql_requires_explicit_process_before_authentication() { - for profile in [None, Some("fetch-js"), Some("static")] { - let mut args = vec![ - "apps", - "create", - "--app-id", - "mysql-app", - "--persistence", - "mysql", - "--base-url", - APPROVED_TEST_BASE_URL, - ]; - if let Some(profile) = profile { - args.extend(["--runtime-profile", profile]); + fn bl_apps_mysql_requires_explicit_server_runtime_before_authentication() { + for subcommand in ["create", "initialize"] { + for profile in [None, Some("static"), Some("unknown")] { + let mut args = vec!["apps", subcommand]; + if subcommand == "create" { + args.push("--app-id"); + } + args.extend([ + "mysql-app", + "--persistence", + "mysql", + "--base-url", + APPROVED_TEST_BASE_URL, + ]); + if let Some(profile) = profile { + args.extend(["--runtime-profile", profile]); + } + let matches = command() + .try_get_matches_from(args) + .expect("parse mysql command"); + let (_, args) = matches.subcommand().unwrap(); + let error = validate_mysql_runtime(args).unwrap_err(); + assert!(error + .to_string() + .contains("requires --runtime-profile process or fetch-js")); } - let matches = command() - .try_get_matches_from(args) - .expect("parse mysql create"); - let (_, create) = matches.subcommand().unwrap(); - let error = validate_mysql_runtime(create).unwrap_err(); - assert!(error - .to_string() - .contains("requires --runtime-profile process")); } } #[test] fn bl_apps_create_mysql_rejects_unsafe_plan_before_reservation() { - for (persistence, profile, initialize) in [ - (Some("sqlite"), Some("process"), true), - (None, Some("process"), true), - (Some("mysql"), Some("fetch-js"), true), - (Some("mysql"), None, true), - (Some("mysql"), Some("process"), false), - ] { - let auth_server = ProcessServer::start(vec![process_auth_response()]); - let control_plane = ProcessServer::start(vec![ProcessResponse::json(json!({ - "app_id": "mysql-app", "persistence": persistence, "runtime_profile": profile, - "initialize": {"required": initialize, "recommended": false} - }))]); - let output = process_command( - &auth_server, - &control_plane, - &[ - "apps", - "create", - "--app-id", - "mysql-app", - "--persistence", - "mysql", - "--runtime-profile", - "process", - "--base-url", - APPROVED_TEST_BASE_URL, - "--json", - ], - "apps-e2e-only.mysql-plan", - ) - .output() - .unwrap(); - assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("refusing to reserve")); - auth_server.finish(); - let requests = control_plane.finish(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].path, APPS_PLAN_PATH); + for requested_profile in ["process", "fetch-js"] { + for (persistence, profile, initialize) in [ + (Some("sqlite"), Some("process"), true), + (None, Some("process"), true), + ( + Some("mysql"), + Some(if requested_profile == "process" { + "fetch-js" + } else { + "process" + }), + true, + ), + (Some("mysql"), Some("static"), true), + (Some("mysql"), None, true), + (Some("mysql"), Some(requested_profile), false), + ] { + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(json!({ + "app_id": "mysql-app", "persistence": persistence, "runtime_profile": profile, + "initialize": {"required": initialize, "recommended": false} + }))]); + let output = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "mysql-app", + "--persistence", + "mysql", + "--runtime-profile", + requested_profile, + "--base-url", + APPROVED_TEST_BASE_URL, + "--json", + ], + "apps-e2e-only.mysql-plan", + ) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("refusing to reserve")); + auth_server.finish(); + let requests = control_plane.finish(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, APPS_PLAN_PATH); + } } } #[test] fn bl_apps_create_mysql_preserves_server_rejections_without_fallback() { - for (failed_stage, code) in [(0, "mysql_unavailable"), (2, "mysql_not_ready")] { - let plan = ProcessResponse::json(json!({ - "app_id": "mysql-app", "persistence": "mysql", "runtime_profile": "process", - "environment": "staging", "initialize": {"required": true, "recommended": true} - })); - let failure = ProcessResponse::json_status( - 409, - json!({ - "ok": false, "error": {"code": code, "message": "private provider diagnostic"}, - "next_action": "Inspect this app on the selected control plane." - }), - ); - let responses = if failed_stage == 0 { - vec![failure] - } else { - vec![ - plan, - ProcessResponse::json(json!({"app_id":"mysql-app"})), - failure, - ] - }; - let auth_server = ProcessServer::start(vec![process_auth_response()]); - let control_plane = ProcessServer::start(responses); - let credential = "apps-e2e-only.mysql-failure"; - let output = process_command( - &auth_server, - &control_plane, - &[ - "apps", - "create", - "--app-id", - "mysql-app", - "--persistence", - "mysql", - "--runtime-profile", - "process", - "--base-url", - APPROVED_TEST_BASE_URL, - "--json", - ], - credential, - ) - .output() - .unwrap(); - assert!(!output.status.success()); - let error = process_error(&output); - assert_eq!(error["error"]["code"], code); - assert!(error["next_action"] - .as_str() - .unwrap() - .contains("selected control plane")); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(!stderr.contains("private provider diagnostic")); - assert!(!stderr.contains(credential)); - auth_server.finish(); - let requests = control_plane.finish(); - assert_eq!( - requests.len(), - failed_stage + 1, - "no retries or sqlite fallback" - ); + for requested_profile in ["process", "fetch-js"] { + for (failed_stage, code) in [(0, "mysql_unavailable"), (2, "mysql_not_ready")] { + let plan = ProcessResponse::json(json!({ + "app_id": "mysql-app", "persistence": "mysql", "runtime_profile": requested_profile, + "environment": "staging", "initialize": {"required": true, "recommended": true} + })); + let failure = ProcessResponse::json_status( + 409, + json!({ + "ok": false, "error": {"code": code, "message": "private provider diagnostic"}, + "next_action": "Inspect this app on the selected control plane." + }), + ); + let responses = if failed_stage == 0 { + vec![failure] + } else { + vec![ + plan, + ProcessResponse::json(json!({"app_id":"mysql-app"})), + failure, + ] + }; + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(responses); + let credential = "apps-e2e-only.mysql-failure"; + let output = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "mysql-app", + "--persistence", + "mysql", + "--runtime-profile", + requested_profile, + "--base-url", + APPROVED_TEST_BASE_URL, + "--json", + ], + credential, + ) + .output() + .unwrap(); + assert!(!output.status.success()); + let error = process_error(&output); + assert_eq!(error["error"]["code"], code); + assert!(error["next_action"] + .as_str() + .unwrap() + .contains("selected control plane")); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("private provider diagnostic")); + assert!(!stderr.contains(credential)); + if failed_stage == 2 { + assert!(stderr.contains(&format!("--runtime-profile {requested_profile}"))); + } + auth_server.finish(); + let requests = control_plane.finish(); + assert_eq!( + requests.len(), + failed_stage + 1, + "no retries or sqlite fallback" + ); + } } } #[test] fn bl_apps_create_mysql_requires_initialization_to_confirm_persistence() { - for persistence in [None, Some("sqlite")] { - let auth_server = ProcessServer::start(vec![process_auth_response()]); - let control_plane = ProcessServer::start(vec![ - ProcessResponse::json(json!({"app_id":"mysql-app", "persistence":"mysql", - "runtime_profile":"process", "initialize":{"required":true,"recommended":false}})), - ProcessResponse::json(json!({"app_id":"mysql-app"})), - ProcessResponse::json(json!({"app_id":"mysql-app", "persistence":persistence, + for requested_profile in ["process", "fetch-js"] { + for persistence in [None, Some("sqlite")] { + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(json!({"app_id":"mysql-app", "persistence":"mysql", + "runtime_profile":requested_profile, "initialize":{"required":true,"recommended":false}})), + ProcessResponse::json(json!({"app_id":"mysql-app"})), + ProcessResponse::json(json!({"app_id":"mysql-app", "persistence":persistence, "external_url":"https://mysql-app.example/"})), - ]); - let output = process_command( - &auth_server, - &control_plane, - &[ - "apps", - "create", - "--app-id", - "mysql-app", - "--persistence", - "mysql", - "--runtime-profile", - "process", - "--base-url", - APPROVED_TEST_BASE_URL, - "--json", - ], - "apps-e2e-only.mysql-confirm", - ) - .output() - .unwrap(); - assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr) - .contains("did not confirm mysql persistence")); - auth_server.finish(); - assert_eq!(control_plane.finish().len(), 3); + ]); + let output = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "mysql-app", + "--persistence", + "mysql", + "--runtime-profile", + requested_profile, + "--base-url", + APPROVED_TEST_BASE_URL, + "--json", + ], + "apps-e2e-only.mysql-confirm", + ) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("did not confirm mysql persistence")); + auth_server.finish(); + assert_eq!(control_plane.finish().len(), 3); + } } } #[test] fn bl_apps_initialize_mysql_plans_and_repairs_existing_app_without_reserve() { - for recommended in [false, true] { - let auth_server = ProcessServer::start(vec![process_auth_response()]); - let control_plane = ProcessServer::start(vec![ - ProcessResponse::json( - json!({"ok":true,"app":{"app_id":"mysql-app","persistence":"mysql","status":"active"}}), - ), - ProcessResponse::json(json!({"app_id":"mysql-app","environment":"staging", - "persistence":"mysql","runtime_profile":"process", + for requested_profile in ["process", "fetch-js"] { + for recommended in [false, true] { + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json( + json!({"ok":true,"app":{"app_id":"mysql-app","persistence":"mysql","status":"active"}}), + ), + ProcessResponse::json(json!({"app_id":"mysql-app","environment":"staging", + "persistence":"mysql","runtime_profile":requested_profile, "initialize":{"required":false,"recommended":recommended}})), - ProcessResponse::json(json!({"app_id":"mysql-app","persistence":"mysql", + ProcessResponse::json(json!({"app_id":"mysql-app","persistence":"mysql", "external_url":"https://mysql-app.example/"})), - ]); - let output = process_command( - &auth_server, - &control_plane, - &[ - "apps", - "initialize", - "mysql-app", - "--environment", - "staging", - "--persistence", - "mysql", - "--runtime-profile", - "process", - "--base-url", - APPROVED_TEST_BASE_URL, - "--json", - ], - "apps-e2e-only.mysql-initialize", - ) - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let response: Value = serde_json::from_str(&process_stdout(&output)).unwrap(); - assert_eq!(response["initialized"], true); - assert_eq!(response["initialize"]["persistence"], "mysql"); - auth_server.finish(); - let requests = control_plane.finish(); - assert_eq!(requests.len(), 3); - assert_eq!(requests[0].method, "GET"); - assert_eq!( - requests[0].path, - "/v1/agent/apps/mysql-app?environment=staging" - ); - assert_eq!(requests[1].path, APPS_PLAN_PATH); - assert_eq!(requests[1].body["persistence"], "mysql"); - assert_eq!(requests[1].body["runtime_profile"], "process"); - assert_eq!(requests[2].path, "/v1/agent/apps/mysql-app/initialize"); - assert_eq!( - requests[2].body, - json!({"environment":"staging", - "runtime_profile":"process","persistence":"mysql"}) - ); + ]); + let output = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "initialize", + "mysql-app", + "--environment", + "staging", + "--persistence", + "mysql", + "--runtime-profile", + requested_profile, + "--base-url", + APPROVED_TEST_BASE_URL, + "--json", + ], + "apps-e2e-only.mysql-initialize", + ) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let response: Value = serde_json::from_str(&process_stdout(&output)).unwrap(); + assert_eq!(response["initialized"], true); + assert_eq!(response["initialize"]["persistence"], "mysql"); + auth_server.finish(); + let requests = control_plane.finish(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].method, "GET"); + assert_eq!( + requests[0].path, + "/v1/agent/apps/mysql-app?environment=staging" + ); + assert_eq!(requests[1].path, APPS_PLAN_PATH); + assert_eq!(requests[1].body["persistence"], "mysql"); + assert_eq!(requests[1].body["runtime_profile"], requested_profile); + assert_eq!(requests[2].path, "/v1/agent/apps/mysql-app/initialize"); + assert_eq!( + requests[2].body, + json!({"environment":"staging", + "runtime_profile":requested_profile,"persistence":"mysql"}) + ); + } } } @@ -3477,36 +3509,53 @@ mod tests { } #[test] - fn bl_apps_initialize_mysql_rejects_downgraded_plan_before_mutation() { - let auth_server = ProcessServer::start(vec![process_auth_response()]); - let control_plane = ProcessServer::start(vec![ - ProcessResponse::json(json!({"app":{"app_id":"mysql-app"}})), - ProcessResponse::json( - json!({"app_id":"mysql-app", "persistence":"sqlite","runtime_profile":"process"}), - ), - ]); - let output = process_command( - &auth_server, - &control_plane, - &[ - "apps", - "initialize", - "mysql-app", - "--persistence", - "mysql", - "--runtime-profile", - "process", - "--base-url", - APPROVED_TEST_BASE_URL, - "--json", - ], - "apps-e2e-only.mysql-initialize-downgrade", - ) - .output() - .unwrap(); - assert!(!output.status.success()); - auth_server.finish(); - assert_eq!(control_plane.finish().len(), 2); + fn bl_apps_initialize_mysql_rejects_changed_plan_before_mutation() { + for requested_profile in ["process", "fetch-js"] { + let other_profile = if requested_profile == "process" { + "fetch-js" + } else { + "process" + }; + for (persistence, profile) in [ + (Some("sqlite"), Some(requested_profile)), + (None, Some(requested_profile)), + (Some("mysql"), Some(other_profile)), + (Some("mysql"), Some("static")), + (Some("mysql"), None), + ] { + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(json!({"app":{"app_id":"mysql-app"}})), + ProcessResponse::json( + json!({"app_id":"mysql-app", "persistence":persistence,"runtime_profile":profile}), + ), + ]); + let output = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "initialize", + "mysql-app", + "--persistence", + "mysql", + "--runtime-profile", + requested_profile, + "--base-url", + APPROVED_TEST_BASE_URL, + "--json", + ], + "apps-e2e-only.mysql-initialize-changed-plan", + ) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("refusing to reserve or initialize")); + auth_server.finish(); + assert_eq!(control_plane.finish().len(), 2); + } + } } #[test]