feat(cli): [group] manifest + pic plan/apply for a group node (Phase 5 C2)
Phase 5 C2. A `picloud.toml` can now declare a `[group]` node (instead of
`[app]`): the group's own scripts + `[vars]` (+ secret names). `pic plan` /
`pic apply` reconcile it via the C1 group endpoints — the same plan/apply/
bound-token flow as an app, routed by node kind.
* `Manifest` is now app-XOR-group: `app`/`group` are both optional with an
exactly-one check in `parse`, plus a group-node guard that rejects
`[[routes]]`/`[[triggers]]` (those bind to an app). Accessors `slug()` /
`is_group()` replace the hardcoded `manifest.app.slug`.
* `client`: `plan_node`/`apply_node` take a `NodeKind { App | Group }` that
selects the `apps` vs `groups` API path; the old `plan`/`apply` wrappers
are gone (callers pass the kind).
* `pic plan`/`apply` detect the node kind from the manifest and label output
`group`/`app`; `pic config --effective` cleanly rejects a group manifest
(effective config is an app's inherited view).
Live-validated: a `[group]` manifest with a script + var → `pic plan` (script
+ var creates) → `pic apply` (group-owned) → idempotent re-plan noop →
`pic scripts ls --group` shows it. Manifest unit test for group parse +
app-only-block rejection; init/pull/plan/apply/overlay journeys all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1168,33 +1168,47 @@ impl Client {
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/plan` — diff a desired-state
|
||||
/// bundle against the app's live state. Read-only.
|
||||
pub async fn plan(&self, app: &str, bundle: &serde_json::Value) -> Result<PlanDto> {
|
||||
let app = seg(app);
|
||||
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/plan` — diff a bundle
|
||||
/// against an app OR group node (Phase 5).
|
||||
pub async fn plan_node(
|
||||
&self,
|
||||
kind: NodeKind,
|
||||
slug: &str,
|
||||
bundle: &serde_json::Value,
|
||||
) -> Result<PlanDto> {
|
||||
let slug = seg(slug);
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/plan"))
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/{}/{slug}/plan", kind.path()),
|
||||
)
|
||||
.json(bundle)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/apply` — reconcile the live
|
||||
/// app to the bundle in one transaction.
|
||||
pub async fn apply(
|
||||
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/apply` — reconcile an
|
||||
/// app OR group node in one transaction (Phase 5).
|
||||
pub async fn apply_node(
|
||||
&self,
|
||||
app: &str,
|
||||
kind: NodeKind,
|
||||
slug: &str,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
expected_token: Option<&str>,
|
||||
) -> Result<ApplyReportDto> {
|
||||
let app = seg(app);
|
||||
let slug = seg(slug);
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"prune": prune,
|
||||
"expected_token": expected_token,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/apply"))
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/{}/{slug}/apply", kind.path()),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
@@ -1206,7 +1220,7 @@ impl Client {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
return Err(anyhow!(
|
||||
"{msg}\nThe app changed since your last `pic plan`. Re-run \
|
||||
"{msg}\nThe live state changed since your last `pic plan`. Re-run \
|
||||
`pic plan` to review the new diff, then `pic apply` — or \
|
||||
`pic apply --force` to apply without re-reviewing."
|
||||
));
|
||||
@@ -1235,6 +1249,23 @@ pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<Log
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// Which kind of node a plan/apply targets (Phase 5) — selects the API path
|
||||
/// prefix (`apps` vs `groups`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NodeKind {
|
||||
App,
|
||||
Group,
|
||||
}
|
||||
|
||||
impl NodeKind {
|
||||
fn path(self) -> &'static str {
|
||||
match self {
|
||||
Self::App => "apps",
|
||||
Self::Group => "groups",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||
|
||||
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
|
||||
|
||||
Reference in New Issue
Block a user