Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .claude/skills/releasing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ concept and nothing merges there. Work lands on a feature branch, goes
into `main` through a PR, and the merge is what publishes.

1. Commit on the feature branch (only when the [user] says so).
If the change touches `extension-vscode/`, `packages/weft-graph/` or
`packages/weft-syntax/` (the VS Code extension ships all three), bump
`extension-vscode`; if it touches `extension-browser/`, bump that one.
The browser extension is the task popup and uses neither shared
package. Bump IN THE SAME PR (`pnpm version patch --no-git-tag-version`
If the change touches `extension-vscode/`, `packages/weft-graph/`,
`packages/weft-syntax/` or `packages/weft-connect/` (the VS Code
extension ships all four), bump `extension-vscode`; if it touches
`extension-browser/` or `packages/weft-connect/` (its connections page
is built on the connect library), bump that one too. Bump IN THE SAME PR (`pnpm version patch --no-git-tag-version`
in the package directory): a merge without a bump publishes nothing
to the stores. The `extension versions bumped` CI check refuses a
PR that forgot; `scripts/check-extension-bump.sh origin/main` is the
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/install-gcp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ jobs:

- uses: google-github-actions/setup-gcloud@v3

# GitHub signs in as the install's account through a grant made just
# before the first run (the cloud chapter's commands), and Google
# can take a few minutes to apply it: until then every call is
# refused with `iam.serviceAccounts.getAccessToken`. Asking for a
# token is that same exchange, so it is asked until Google says yes.
- name: wait for Google to accept GitHub's sign-in
run: |
for attempt in $(seq 1 40); do
if err=$(gcloud auth print-access-token 2>&1 >/dev/null); then
exit 0
fi
echo "Google does not accept this workflow's sign-in yet (a new grant takes a few minutes to apply); asking again in 15s"
sleep 15
done
echo "::error::Google still refuses this workflow's sign-in after 10 minutes, so the grant is missing rather than still applying. Check the GCP_WORKLOAD_IDENTITY_PROVIDER and GCP_INSTALL_SERVICE_ACCOUNT variables and the commands in the cloud chapter. Google's answer: $err"
exit 1

- uses: hashicorp/setup-terraform@v4
with:
terraform_wrapper: false
Expand Down
34 changes: 32 additions & 2 deletions crates/weft-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,7 @@ impl DispatcherClient {
let completing = status == reqwest::StatusCode::CONFLICT
&& resp.headers().contains_key(weft_core::storage::COMPLETING_HEADER);
let body = resp.text().await.unwrap_or_default();
let msg = body.trim();
let msg = if msg.is_empty() { format!("dispatcher returned {status}") } else { msg.to_string() };
let msg = refusal_text(&body).unwrap_or_else(|| format!("dispatcher returned {status}"));
if completing {
return Err(anyhow::Error::new(StoreCompleting).context(msg));
}
Expand Down Expand Up @@ -258,3 +257,34 @@ impl DispatcherClient {
}
}


/// What a refusal's body says, as lines a person reads: a structured
/// refusal (`{"errors": [...]}`, the shape every validation answers
/// with) one error per line, any other body as it came. `None` when the
/// body is empty.
pub(crate) fn refusal_text(body: &str) -> Option<String> {
let body = body.trim();
if body.is_empty() {
return None;
}
match serde_json::from_str::<weft_core::run_spec::Refusal>(body) {
Ok(refusal) if !refusal.is_empty() => Some(refusal.to_string()),
_ => Some(body.to_string()),
}
}

#[cfg(test)]
mod tests {
use super::refusal_text;

#[test]
fn a_structured_refusal_reads_as_its_errors() {
assert_eq!(
refusal_text(r#"{"errors":["'keys' has no connection picked","'db' is not running"]}"#).as_deref(),
Some("'keys' has no connection picked\n'db' is not running")
);
assert_eq!(refusal_text("project is already activating").as_deref(), Some("project is already activating"));
assert_eq!(refusal_text(r#"{"errors":[]}"#).as_deref(), Some(r#"{"errors":[]}"#));
assert_eq!(refusal_text(" \n"), None);
}
}
2 changes: 1 addition & 1 deletion crates/weft-cli/src/commands/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ pub async fn publish_references(
// The build's own file is what is missing here (a version's is a
// warning, never a refusal): the upload just made did not land.
bail!("update project asset lifetimes: {}\nRun the command again; if it repeats, `weft files ls` shows what storage holds for your account",
if text.trim().is_empty() { format!("dispatcher returned {status}") } else { text.trim().to_string() });
crate::client::refusal_text(&text).unwrap_or_else(|| format!("dispatcher returned {status}")));
}
let published: weft_core::storage::AssetsPublished = serde_json::from_str(&text).context("parse the publish answer")?;
Ok(published.warnings)
Expand Down
45 changes: 19 additions & 26 deletions crates/weft-cli/src/commands/ensure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,44 +299,37 @@ pub async fn build_compiled(
// While the install builds, name each image it is building and
// where its log is, once: a build can take many minutes, and a
// bare "building" says nothing about where it is.
let asked = client.post_json_status(&path, &body);
let mut asked = std::pin::pin!(asked);
// Each image once, and again when its log's address arrives.
let mut told: std::collections::BTreeMap<String, bool> = std::collections::BTreeMap::new();
// A status that cannot be read is said once, not every look.
let mut warned = false;
let mut every = tokio::time::interval(BUILD_LOOK_EVERY);
every.tick().await;
loop {
tokio::select! {
answer = &mut asked => break answer.context("ask the install to build")?,
_ = every.tick() => {
match builds_running(&client, &id).await {
Ok(builds) => {
for build in builds {
let with_log = build.log_url.is_some();
if told.get(&build.image).is_none_or(|had_log| !had_log && with_log) {
told.insert(build.image.clone(), with_log);
progress.build_image(&build);
}
}
crate::progress::while_waiting(client.post_json_status(&path, &body), BUILD_LOOK_EVERY, async |_| {
match builds_running(&client, &id).await {
Ok(builds) => {
for build in builds {
let with_log = build.log_url.is_some();
if told.get(&build.image).is_none_or(|had_log| !had_log && with_log) {
told.insert(build.image.clone(), with_log);
progress.build_image(&build);
}
Err(e) if !warned => {
warned = true;
progress.warn(&format!(
"cannot show which images are building or where their logs are ({e:#}); the build goes on"
));
}
Err(_) => {}
}
}
Err(e) if !warned => {
warned = true;
progress.warn(&format!(
"cannot show which images are building or where their logs are ({e:#}); the build goes on"
));
}
Err(_) => {}
}
}
})
.await
.context("ask the install to build")?
};
if !(200..300).contains(&status) {
anyhow::bail!(
"the build failed:\n{}",
if text.trim().is_empty() { format!("the install answered {status}") } else { text.trim().to_string() }
crate::client::refusal_text(&text).unwrap_or_else(|| format!("the install answered {status}"))
);
}
let built: BuiltProgram = serde_json::from_str(&text).context("read the build's answer")?;
Expand Down
17 changes: 15 additions & 2 deletions crates/weft-cli/src/commands/frontend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,22 @@ pub async fn run(ctx: Ctx, action: FrontendAction) -> Result<()> {
// install, before anything has run there, and it needs only
// the project to exist, not a build of it.
super::ensure::ensure_project_known(&ctx).await?;
let hosted = body.host == FrontendHost::CloudRun;
let asked = serde_json::to_value(&body)?;
let made = client.post_json(&base, &asked);
// A hosted frontend's service takes Cloud Run a minute or two
// to make, and the call answers only once it is ready.
let made = if hosted {
eprintln!("making the frontend's Cloud Run service (this takes a minute or two)");
crate::progress::while_waiting(made, std::time::Duration::from_secs(15), async |elapsed| {
eprintln!("still making the frontend's Cloud Run service ({}s so far)", elapsed.as_secs())
})
.await
} else {
made.await
};
let made: FrontendWithToken =
serde_json::from_value(client.post_json(&base, &serde_json::to_value(&body)?).await?)
.context("read the frontend the install made")?;
serde_json::from_value(made?).context("read the frontend the install made")?;
hand_over(&ctx, &project, &install_url, made)?;
}
FrontendAction::Token { name, done: None } => {
Expand Down
33 changes: 31 additions & 2 deletions crates/weft-cli/src/commands/infra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,15 @@ async fn infra_sync(
wait_for_command(progress, &handle.client, &handle.id, issued.command_id, "upgrade").await?;
infra_status_of(&handle.client, &handle.id).await?
} else {
let answer = handle.client.post_json(&path, &serde_json::to_value(&sync)?).await?;
// The sync answers once every copy is up, which on a cloud install
// is minutes of a machine booting: say what each is doing meanwhile.
let body = serde_json::to_value(&sync)?;
let answer = crate::progress::while_waiting(
handle.client.post_json(&path, &body),
std::time::Duration::from_secs(10),
async |elapsed| progress.infra_wait("start", elapsed.as_secs(), &starting_now(&handle.client, &handle.id).await),
)
.await?;
progress.dispatcher_call_done(serde_json::json!({ "project_id": handle.id }));
serde_json::from_value(answer).context("read the infra status the sync answered")?
};
Expand Down Expand Up @@ -484,7 +492,7 @@ async fn wait_for_command(
}
let now = std::time::Instant::now();
if now >= next_breadcrumb {
progress.infra_wait(verb, (now - start).as_secs());
progress.infra_wait(verb, (now - start).as_secs(), &starting_now(client, project_id).await);
next_breadcrumb = now + breadcrumb_every;
}
}
Expand Down Expand Up @@ -614,6 +622,27 @@ async fn infra_status(ctx: &Ctx) -> Result<()> {
Ok(())
}

/// What each copy still starting is doing, one line each, for a wait's
/// breadcrumb; a status that cannot be read is that one line instead.
async fn starting_now(client: &crate::client::DispatcherClient, project_id: &str) -> Vec<String> {
let status = match infra_status_of(client, project_id).await {
Ok(status) => status,
Err(e) => return vec![format!("cannot read what each copy is doing ({e:#}); the wait goes on")],
};
status
.nodes
.iter()
.filter_map(|n| {
let progress = n.progress.as_ref()?;
let node = match &n.instance {
Some(instance) => format!("{} (instance {instance})", n.node),
None => n.node.clone(),
};
Some(format!("{node}: {}", progress.describe_now()))
})
.collect()
}

/// Every infra copy of the project, and its state.
async fn infra_status_of(client: &crate::client::DispatcherClient, project_id: &str) -> Result<InfraStatus> {
serde_json::from_value(client.get_json(&format!("/projects/{project_id}/infra/status")).await?)
Expand Down
2 changes: 1 addition & 1 deletion crates/weft-cli/src/commands/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ pub async fn start_run(
let refusal: Refusal = serde_json::from_str(&text).context("parse the refusal")?;
bail!("the run cannot start:\n{refusal}")
}
_ => bail!("{}", if text.trim().is_empty() { format!("dispatcher returned {status}") } else { text.trim().to_string() }),
_ => bail!("{}", crate::client::refusal_text(&text).unwrap_or_else(|| format!("dispatcher returned {status}"))),
}
}

Expand Down
37 changes: 37 additions & 0 deletions crates/weft-cli/src/commands/target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ async fn export(
.context("read the project's frontends")?;
let frontend = hosted_frontend(&frontends, frontend, repo.as_ref())?;

let unpicked = unpicked_connections(&client, project).await;
let labels = ExportLabels::of(project);
let (keys, minted) = mint_ci_keys(&client, name, project, &labels, front_env, frontend).await?;
let prepared = async {
Expand Down Expand Up @@ -365,6 +366,7 @@ async fn export(
"frontend '{frontend}' keeps its old token working until the deploy workflow puts the new one in place and retires it"
);
}
print_unpicked(&unpicked, name);
return Ok(());
}
if let Err(e) = set_repository_values(&project.root, &settings) {
Expand All @@ -389,9 +391,44 @@ async fn export(
for id in &stale {
println!("revoked an earlier export's key {id}");
}
print_unpicked(&unpicked, name);
Ok(())
}

/// What activating this program on `client`'s install would refuse of its
/// connection picks, each gap as the line that says how to fix it: the
/// deploy workflow's activation refuses the program until they are fixed,
/// and that is a late place to find out. The install answers with
/// activation's own check, run on this machine's compile of the program
/// (the install may not have built it yet). A program that does not
/// compile, or an install that cannot answer, come back as one line saying
/// so.
async fn unpicked_connections(client: &crate::client::DispatcherClient, project: &weft_compiler::project::Project) -> Vec<String> {
let definition = match weft_compiler::hash::load_enriched_project_with_diagnostics(project) {
Ok((definition, _)) => definition,
Err(_) => return vec!["the program does not compile here, so its connections were not checked".into()],
};
let asked = async {
let body = serde_json::to_value(&definition)?;
let answer = client.post_json(&format!("/projects/{}/picks/check", project.id()), &body).await?;
anyhow::Ok(serde_json::from_value::<weft_core::run_spec::Refusal>(answer)?)
};
match asked.await {
Ok(refusal) => refusal.errors,
Err(e) => vec![format!("could not check the install's connection picks ({e:#})")],
}
}

fn print_unpicked(unpicked: &[String], target: &str) {
if unpicked.is_empty() {
return;
}
println!("the deploy workflow cannot turn the program on until these are fixed on {target} (add `--on {target}`):");
for line in unpicked {
println!(" {line}");
}
}

/// An export that failed after minting: nobody holds the keys it minted,
/// so they are revoked, and the frontend's new token is dropped (it keeps
/// the one it has). The export's own failure stays the message, and what
Expand Down
39 changes: 37 additions & 2 deletions crates/weft-cli/src/progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,12 +346,16 @@ impl Progress {
self.emit(Phase::TriggerRegisterStart, None);
}

pub fn infra_wait(&self, verb: &str, elapsed_seconds: u64) {
/// Still waiting on an infra verb, and what the copies starting under
/// it are doing now (`weft infra status`'s progress lines), when any.
/// SYNC: the infra_wait detail <-> packages/weft-graph/src/webview/lib/components/project/ActionBar.svelte (infra_wait)
pub fn infra_wait(&self, verb: &str, elapsed_seconds: u64, doing: &[String]) {
self.emit(
Phase::InfraWait,
Some(serde_json::json!({
"verb": verb,
"elapsedSeconds": elapsed_seconds,
"doing": doing,
})),
);
}
Expand Down Expand Up @@ -460,7 +464,17 @@ fn human_line(ev: &Event<'_>) -> Option<String> {
.and_then(|d| d.get("elapsedSeconds"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
format!("still waiting on infra {verb} (elapsed {elapsed}s; Ctrl+C to back out)")
let doing: Vec<&str> = ev
.detail
.and_then(|d| d.get("doing"))
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let mut line = format!("still waiting on infra {verb} (elapsed {elapsed}s; Ctrl+C to back out)");
for d in doing {
line.push_str(&format!("\n {d}"));
}
line
}
Phase::Warning => format!(
"warning: {}",
Expand All @@ -486,6 +500,27 @@ fn human_line(ev: &Event<'_>) -> Option<String> {
})
}

/// `work`'s answer. Every `every` until it comes, `look` runs with how long
/// it has been, to say what is happening meanwhile: a call that takes
/// minutes is never silent. The one shape of every such wait (a build, an
/// infra start, a frontend's service), so each says what it sees and
/// nothing else.
pub async fn while_waiting<T>(
work: impl std::future::Future<Output = T>,
every: std::time::Duration,
mut look: impl AsyncFnMut(std::time::Duration),
) -> T {
let started = std::time::Instant::now();
let mut work = std::pin::pin!(work);
let mut timer = tokio::time::interval_at(tokio::time::Instant::now() + every, every);
loop {
tokio::select! {
done = &mut work => return done,
_ = timer.tick() => look(started.elapsed()).await,
}
}
}

#[cfg(test)]
mod tests {
use super::error_detail;
Expand Down
Loading
Loading