diff --git a/rust/src/application/client.rs b/rust/src/application/client.rs index f6d99b34..235c43f0 100644 --- a/rust/src/application/client.rs +++ b/rust/src/application/client.rs @@ -624,6 +624,32 @@ mod tests { assert_eq!(data["updateApplication"]["label"], "Updated app"); } + #[tokio::test] + async fn update_application_can_activate_an_application() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/apps/app-registry-subgraph") + .is_true(|req| { + let body = req.body_string(); + body.contains("updateApplication") && body.contains(r#""status":"ACTIVE""#) + }); + then.status(200).json_body(json!({ + "data": { "updateApplication": { "id": "app-1", "status": "ACTIVE" } } + })); + }) + .await; + + let data = ApplicationClient::new(server.base_url(), "test-token") + .update_application("app-1", json!({ "status": "ACTIVE" })) + .await + .expect("activate application"); + + mock.assert_async().await; + assert_eq!(data["updateApplication"]["status"], "ACTIVE"); + } + // httpmock can't sequence responses, so retries are verified by hit count // (exhaustion) rather than a fail-then-succeed sequence. diff --git a/rust/src/application/commands/deploy/mod.rs b/rust/src/application/commands/deploy/mod.rs index 956709d9..9e93774a 100644 --- a/rust/src/application/commands/deploy/mod.rs +++ b/rust/src/application/commands/deploy/mod.rs @@ -84,6 +84,7 @@ fn deploy_result_event( "releaseId": release_id, "extensions": extensions, "releaseStatus": "ACTIVE", + "applicationStatus": "ACTIVE", }, "next_actions": deploy_next_actions(name), }) @@ -120,8 +121,10 @@ pub(super) fn command() -> RuntimeCommandSpec { (SEC001–SEC010/SEC012 AST + SEC011 package scripts) and the \ post-bundle regex scanner (SEC101–SEC115) on each extension, then \ upload the artifacts to the latest release of the named \ - application. Progress is streamed as JSON events. A release must \ - exist before deploying; create one with \ + application and activate both the release and application. An \ + already-active application skips the lifecycle mutation. Progress \ + is streamed as JSON events. A release must exist before deploying; \ + create one with \ `gddy platform app release`.", ) .with_system("applications") @@ -175,6 +178,7 @@ pub(super) fn command() -> RuntimeCommandSpec { return Err(fail_deploy(&sender, err).await); } let application_id = app["id"].as_str().unwrap_or("").to_owned(); + let application_status = app["status"].as_str().map(str::to_owned); sender .send(json!({ "type": "step", "name": "application.lookup", "status": "completed", "id": application_id })) .await; @@ -233,6 +237,18 @@ pub(super) fn command() -> RuntimeCommandSpec { ) .await?; + tap_deploy_err( + &sender, + activate_application( + &client, + &sender, + &application_id, + application_status.as_deref(), + ) + .await, + ) + .await?; + tap_deploy_err( &sender, sync_manifest_metadata(&client, &sender, &application_id, &config).await, @@ -262,8 +278,7 @@ pub(super) fn command() -> RuntimeCommandSpec { ) } -/// Activate the release. The App Registry lifecycle owns the parent -/// application's status transition; deploy must not directly mutate it. +/// Activate the release before activating the parent application. async fn activate_release( client: &ApplicationClient, sender: &StreamSender, @@ -284,6 +299,44 @@ async fn activate_release( Ok(()) } +/// Promote the parent application to `ACTIVE` after its release is active. +/// Avoid the lifecycle mutation when lookup already returned `ACTIVE`. +async fn activate_application( + client: &ApplicationClient, + sender: &StreamSender, + application_id: &str, + current_status: Option<&str>, +) -> cli_engine::Result<()> { + if !application_needs_activation(current_status) { + sender + .send(json!({ + "type": "step", + "name": "application.activate", + "status": "skipped", + "reason": "already active", + })) + .await; + return Ok(()); + } + + sender + .send(json!({ "type": "step", "name": "application.activate", "status": "started" })) + .await; + client + .update_application(application_id, json!({ "status": "ACTIVE" })) + .await + .map_err(super::client_err)?; + sender + .send(json!({ "type": "step", "name": "application.activate", "status": "completed" })) + .await; + + Ok(()) +} + +fn application_needs_activation(current_status: Option<&str>) -> bool { + current_status != Some("ACTIVE") +} + /// Synchronize application-level manifest fields without changing lifecycle /// status. Actions and subscriptions belong to the release and are handled by /// `platform app release`. @@ -404,6 +457,7 @@ mod tests { assert_eq!(event["result"]["releaseId"], "rel-456"); assert_eq!(event["result"]["extensions"], 2); assert_eq!(event["result"]["releaseStatus"], "ACTIVE"); + assert_eq!(event["result"]["applicationStatus"], "ACTIVE"); assert_eq!( event["next_actions"].as_array().map(|a| a.len()), Some(3), @@ -423,6 +477,14 @@ mod tests { ); } + #[test] + fn application_activation_is_skipped_only_when_already_active() { + assert!(!super::application_needs_activation(Some("ACTIVE"))); + assert!(super::application_needs_activation(Some("INACTIVE"))); + assert!(super::application_needs_activation(Some("VERIFYING"))); + assert!(super::application_needs_activation(None)); + } + #[test] fn manifest_metadata_input_syncs_all_application_fields_without_status() { let config = crate::config::Config { diff --git a/rust/src/platform/guides/platform-overview.md b/rust/src/platform/guides/platform-overview.md index 6d477617..ef61c64d 100644 --- a/rust/src/platform/guides/platform-overview.md +++ b/rust/src/platform/guides/platform-overview.md @@ -39,7 +39,7 @@ Resends every action, subscription, UI extension, and settings entry currently i gddy platform app deploy ``` -Bundles, security-scans, and uploads the extensions declared in `godaddy.toml`, streaming progress as JSON events. +Bundles, security-scans, and uploads the extensions declared in `godaddy.toml`, then activates the latest release and its parent application. An application that is already active skips the application lifecycle mutation. Progress is streamed as JSON events. ## 5. Enable / disable per store