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>
121 lines
3.9 KiB
Rust
121 lines
3.9 KiB
Rust
//! `pic apply [--file picloud.toml]` — reconcile the live app to the
|
|
//! manifest's desired state in one server-side transaction. Creates and
|
|
//! updates are applied; `--prune` additionally deletes live scripts/routes/
|
|
//! triggers absent from the manifest (secrets are never pruned).
|
|
|
|
use std::io::{IsTerminal, Write};
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
use crate::client::{Client, NodeKind};
|
|
use crate::cmds::plan::build_bundle;
|
|
use crate::config;
|
|
use crate::manifest::Manifest;
|
|
use crate::output::{KvBlock, OutputMode};
|
|
|
|
pub async fn run(
|
|
manifest_path: &Path,
|
|
env: Option<&str>,
|
|
prune: bool,
|
|
yes: bool,
|
|
force: bool,
|
|
mode: OutputMode,
|
|
) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
|
|
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
|
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
|
let bundle = build_bundle(&manifest, base_dir)?;
|
|
let kind = if manifest.is_group() {
|
|
NodeKind::Group
|
|
} else {
|
|
NodeKind::App
|
|
};
|
|
let slug = manifest.slug().to_string();
|
|
|
|
if prune && !yes {
|
|
confirm_prune(&slug)?;
|
|
}
|
|
|
|
// Bound-plan check: replay the token from the last `pic plan` (for this
|
|
// node) so the server refuses if it changed since it was reviewed.
|
|
// `--force` skips it; no recorded plan means no check (apply still works
|
|
// standalone). The token is single-use — cleared after a successful apply.
|
|
let expected_token = if force {
|
|
None
|
|
} else {
|
|
crate::linkstate::read_plan(base_dir)
|
|
.filter(|l| l.app == slug)
|
|
.map(|l| l.state_token)
|
|
};
|
|
|
|
let report = client
|
|
.apply_node(kind, &slug, &bundle, prune, expected_token.as_deref())
|
|
.await?;
|
|
crate::linkstate::clear_plan(base_dir, &slug);
|
|
|
|
let label = if manifest.is_group() { "group" } else { "app" };
|
|
let mut block = KvBlock::new();
|
|
block
|
|
.field(label, slug.clone())
|
|
.field(
|
|
"scripts",
|
|
format!(
|
|
"+{} ~{} -{}",
|
|
report.scripts_created, report.scripts_updated, report.scripts_deleted
|
|
),
|
|
)
|
|
.field(
|
|
"routes",
|
|
format!(
|
|
"+{} ~{} -{}",
|
|
report.routes_created, report.routes_updated, report.routes_deleted
|
|
),
|
|
)
|
|
.field(
|
|
"triggers",
|
|
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
|
|
)
|
|
.field(
|
|
"vars",
|
|
format!(
|
|
"+{} ~{} -{}",
|
|
report.vars_created, report.vars_updated, report.vars_deleted
|
|
),
|
|
);
|
|
for w in &report.warnings {
|
|
block.field("warning", w.clone());
|
|
}
|
|
block.print(mode);
|
|
Ok(())
|
|
}
|
|
|
|
/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
|
|
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
|
|
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
|
|
/// rather than silently deleting (review the deletions first with `pic plan`).
|
|
fn confirm_prune(slug: &str) -> Result<()> {
|
|
if !std::io::stdin().is_terminal() {
|
|
anyhow::bail!(
|
|
"refusing to `apply --prune` non-interactively without `--yes`: prune \
|
|
deletes resources absent from the manifest and cannot be undone. \
|
|
Review with `pic plan`, then re-run with `--yes`."
|
|
);
|
|
}
|
|
eprint!(
|
|
"apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
|
|
absent from the manifest. This cannot be undone. Continue? [y/N] "
|
|
);
|
|
std::io::stderr().flush().ok();
|
|
let mut answer = String::new();
|
|
std::io::stdin()
|
|
.read_line(&mut answer)
|
|
.context("read confirmation")?;
|
|
if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
|
|
anyhow::bail!("aborted");
|
|
}
|
|
Ok(())
|
|
}
|