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
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/target
stackql
/.stackql
stackql*.sh
stackql*shell.sh
stackql*.zip
stackql*.pkg
stackql_history.txt
Expand All @@ -13,4 +13,5 @@ stackql-deploy
nohup.out
contributors.csv
.claude/
nohup.out
nohup.out
tmp/
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## 2.0.7 (2026-04-19)

### Fixes

- Fixed post-deploy failure for `createorupdate` resources whose `exports` anchor is a literal `SELECT` with no `FROM` clause (a supported and common pattern when the values to export are already known and an extra API round-trip is wasteful). Previously the exports query was executed as a statecheck proxy, and a FROM-less result caused the proxy check to report the resource was not in the desired state, aborting the run. With `createorupdate`, the DML is authoritative, so the exports-as-statecheck proxy is now skipped entirely - `exports` still runs to populate the global context for downstream resources.
- Fixed `stackql-deploy upgrade` downloading the stackql binary twice when the binary was missing: the pre-command binary check triggered a download, and the subcommand dispatch then triggered a second download. `upgrade` is now exempt from the pre-command binary check.
- Server-side notices from provider HTTP 4xx/5xx responses are now detected even when stackql wraps them as a generic `a notice level event has occurred` message with the real status code in the `DETAIL:` payload. Previously these escaped the error check and the `create`/`update`/`delete` operation silently appeared to succeed while the post-deploy `exists` check spun through its retries.
- Collapsed duplicate lines within a single notice's `DETAIL:` payload so repeated provider error bodies are printed once.
- Teardown now tolerates resources with unresolved template variables. If an `exists`, `exports`, or `delete` query references a variable that was never populated (because an upstream resource doesn't exist), the resource is treated as already torn down and skipped, instead of aborting the run. Stacks in a half-baked state can now be torn down cleanly.
- During teardown, `RETURNING` clauses are stripped from rendered `delete` DML when `return_vals.delete` is not configured for the resource. Some providers reject `RETURNING *` on `DELETE`, and teardown has no consumer for the returned data unless the manifest explicitly opts in via `return_vals.delete`. When `return_vals.delete` is configured the `RETURNING` clause is preserved and mapped fields are captured as `this.*` with non-fatal warnings if a mapping cannot be satisfied.
- Stale provider notices are no longer re-surfaced on subsequent queries. stackql emits a cumulative `NoticeResponse` on every query that includes every provider notice observed earlier in the session; the pgwire client now tracks each notice line already surfaced and drops byte-identical re-emissions. Dedup is exact-match (no canonicalization) so two distinct provider errors — which always differ in their embedded request/serving IDs — are never conflated. Fixes spurious `create`/`update`/`delete` failures where a 4xx provider response from an earlier `exists` SELECT was attributed to a later DML.

### Features

- When retries are exhausted on a `statecheck`, `exports` proxy, or post-deploy `exists` check, the last rendered query is now logged at `warn` level so the failing SQL is visible without needing `--show-queries` or `--log-level debug`. Pre-create exists checks (which fast-fail by design) stay silent.

## 2.0.6 (2026-03-28)

### Fixes
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "stackql-deploy"
version = "2.0.6"
version = "2.0.7"
edition = "2021"
rust-version = "1.75"
description = "Infrastructure-as-code framework for declarative cloud resource management using StackQL"
Expand Down
9 changes: 9 additions & 0 deletions internal-stackql-deploy-build-install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
rm -rf stackql-deploy
rm -rf stackql
rm -rf stackql-aws-cloud-shell.sh
rm -rf stackql-azure-cloud-shell.sh
rm -rf stackql-google-cloud-shell.sh
rm -rf stackql-databricks-shell.sh
cargo build --release
cp target/release/stackql-deploy stackql-deploy
./stackql-deploy upgrade
7 changes: 7 additions & 0 deletions public-stackql-deploy-install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
rm -rf stackql-deploy
rm -rf stackql
rm -rf stackql-aws-cloud-shell.sh
rm -rf stackql-azure-cloud-shell.sh
rm -rf stackql-google-cloud-shell.sh
rm -rf stackql-databricks-shell.sh
curl -L https://get-stackql-deploy.io | tar xzf -
2 changes: 1 addition & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ pub const STACKQL_BINARY_NAME: &str = "stackql";
pub const STACKQL_RELEASE_BASE_URL: &str = "https://releases.stackql.io/stackql/latest";

/// Commands exempt from binary check
pub const EXEMPT_COMMANDS: [&str; 1] = ["init"];
pub const EXEMPT_COMMANDS: [&str; 2] = ["init", "upgrade"];

/// The base URL for GitHub template repository
pub const GITHUB_TEMPLATE_BASE: &str =
Expand Down
6 changes: 6 additions & 0 deletions src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,12 @@ fn run_build(
);
is_correct_state = true;
}
} else if has_createorupdate {
info!(
"createorupdate for [{}] is authoritative, skipping exports-as-statecheck proxy",
resource.name
);
is_correct_state = true;
} else if let Some(ref eq_str) = exports_query_str {
info!(
"using exports query as post-deploy statecheck for [{}]",
Expand Down
156 changes: 127 additions & 29 deletions src/commands/teardown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
use std::time::Instant;

use clap::{ArgMatches, Command};
use log::{debug, info};
use log::{debug, info, warn};

use crate::commands::base::CommandRunner;
use crate::commands::common_args::{
dry_run, env_file, env_var, log_level, on_failure, show_queries, stack_dir, stack_env,
FailureAction,
};
use crate::core::config::get_resource_type;
use crate::core::utils::{has_returning_clause, strip_returning_clause};
use crate::utils::connection::create_client;
use crate::utils::display::{print_unicode_box, BorderColor};
use crate::utils::server::{check_and_start_server, stop_local_server};
Expand Down Expand Up @@ -106,17 +107,19 @@ fn collect_exports(runner: &mut CommandRunner, show_queries: bool, dry_run: bool
continue;
}

let (exports_query, exports_retries, exports_retry_delay) =
if let Some(sql_val) = resource.sql.as_ref().filter(|_| res_type == "query") {
let iq = runner.render_inline_template(&resource.name, sql_val, &full_context);
(Some(iq), 1u32, 0u32)
} else {
let queries = runner.get_queries(resource, &full_context);
// Run exists query first to capture this.* fields needed by
// exports (e.g. this.identifier).
if let Some(eq) = queries.get("exists") {
let rendered =
runner.render_query(&resource.name, "exists", &eq.template, &full_context);
let (exports_query, exports_retries, exports_retry_delay) = if let Some(sql_val) =
resource.sql.as_ref().filter(|_| res_type == "query")
{
let iq = runner.render_inline_template(&resource.name, sql_val, &full_context);
(Some(iq), 1u32, 0u32)
} else {
let queries = runner.get_queries(resource, &full_context);
// Run exists query first to capture this.* fields needed by
// exports (e.g. this.identifier).
if let Some(eq) = queries.get("exists") {
if let Some(rendered) =
runner.try_render_query(&resource.name, "exists", &eq.template, &full_context)
{
let (_exists, fields) = runner.check_if_resource_exists(
resource,
&rendered,
Expand All @@ -131,17 +134,35 @@ fn collect_exports(runner: &mut CommandRunner, show_queries: bool, dry_run: bool
full_context.insert(format!("{}.{}", resource.name, k), v.clone());
}
}
} else {
info!(
"[{}] exists query has unresolved variables, assuming resource does not exist",
resource.name
);
}
if let Some(eq) = queries.get("exports") {
let rendered =
runner.render_query(&resource.name, "exports", &eq.template, &full_context);
}
if let Some(eq) = queries.get("exports") {
match runner.try_render_query(
&resource.name,
"exports",
&eq.template,
&full_context,
) {
// During teardown use minimal retries - the resource may
// already be partially deleted.
(Some(rendered), 1u32, 0u32)
} else {
(None, 1u32, 0u32)
Some(rendered) => (Some(rendered), 1u32, 0u32),
None => {
info!(
"[{}] exports query has unresolved variables, skipping exports collection",
resource.name
);
(None, 1u32, 0u32)
}
}
};
} else {
(None, 1u32, 0u32)
}
};

if let Some(ref eq_str) = exports_query {
runner.process_exports(
Expand Down Expand Up @@ -227,9 +248,17 @@ fn run_teardown(runner: &mut CommandRunner, dry_run: bool, show_queries: bool, _
let (exists_query_str, exists_retries, exists_retry_delay) = if let Some(eq) =
resource_queries.get("exists")
{
let rendered =
runner.render_query(&resource.name, "exists", &eq.template, &full_context);
(rendered, eq.options.retries, eq.options.retry_delay)
if let Some(rendered) =
runner.try_render_query(&resource.name, "exists", &eq.template, &full_context)
{
(rendered, eq.options.retries, eq.options.retry_delay)
} else {
info!(
"[{}] exists query has unresolved variables, assuming resource does not exist, skipping...",
resource.name
);
continue;
}
} else if let Some(sq) = resource_queries.get("statecheck") {
info!(
"exists query not defined for [{}], trying statecheck query as exists query.",
Expand Down Expand Up @@ -290,15 +319,52 @@ fn run_teardown(runner: &mut CommandRunner, dry_run: bool, show_queries: bool, _
exists
};

// Render the delete query now (after exists fields are available).
let dq = resource_queries.get("delete").unwrap();
let delete_query =
runner.render_query(&resource.name, "delete", &dq.template, &full_context);
let delete_retries = dq.options.retries;
let delete_retry_delay = dq.options.retry_delay;

// Delete
if resource_exists {
// Render the delete query now (after exists fields are available).
let dq = resource_queries.get("delete").unwrap();
let rendered_delete = match runner.try_render_query(
&resource.name,
"delete",
&dq.template,
&full_context,
) {
Some(rendered) => rendered,
None => {
info!(
"[{}] delete query has unresolved variables, assuming resource does not exist, skipping...",
resource.name
);
continue;
}
};
let delete_retries = dq.options.retries;
let delete_retry_delay = dq.options.retry_delay;

// Only keep a RETURNING clause when return_vals.delete is configured
// for this resource. Otherwise strip it — teardown has no use for
// return values, and some providers reject RETURNING * on DELETE.
let delete_return_mappings = resource.get_return_val_mappings("delete");
let delete_query = if delete_return_mappings.is_empty() {
if has_returning_clause(&rendered_delete) {
debug!(
"[{}] stripping RETURNING clause from delete query (no return_vals.delete configured)",
resource.name
);
strip_returning_clause(&rendered_delete)
} else {
rendered_delete
}
} else if !has_returning_clause(&rendered_delete) {
warn!(
"return_vals.delete specified for [{}] but delete query has no RETURNING clause; capture will be skipped",
resource.name
);
rendered_delete
} else {
rendered_delete
};

let (returning_row, delete_confirmed) = runner.delete_and_confirm(
resource,
&delete_query,
Expand All @@ -312,7 +378,39 @@ fn run_teardown(runner: &mut CommandRunner, dry_run: bool, show_queries: bool, _

// Capture RETURNING * result.
if let Some(ref row) = returning_row {
debug!("RETURNING payload for [{}]: {:?}", resource.name, row);
runner.store_callback_data(&resource.name, row);

// Apply return_vals.delete mappings from manifest.
if !delete_return_mappings.is_empty() {
for (src, tgt) in &delete_return_mappings {
if let Some(val) = row.get(src.as_str()) {
if !val.is_empty() && val != "null" {
info!(
"RETURNING [{}] for [{}] captured as [this.{}] = [{}]",
src, resource.name, tgt, val
);
full_context
.insert(format!("{}.{}", resource.name, tgt), val.clone());
} else {
warn!(
"return_vals.delete for [{}]: field [{}] in RETURNING result is null or empty",
resource.name, src
);
}
} else {
warn!(
"return_vals.delete for [{}]: expected field [{}] not found in RETURNING result",
resource.name, src
);
}
}
}
} else if !delete_return_mappings.is_empty() {
warn!(
"return_vals.delete specified for [{}] but no RETURNING data received",
resource.name
);
}

// Run callback:delete block if present.
Expand Down
Loading
Loading