From f74fd0adc40412b26cbaedd5e63fa7cc5b6cfa19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 21:16:14 +0000 Subject: [PATCH 1/2] feat: add idempotency_token special variable per resource per session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `idempotency_token`, a UUID v4 that is generated once per resource at session start and remains stable for the lifetime of the invocation. This allows mutation queries (INSERT/UPDATE) to carry a provider-side client token that distinguishes genuine new requests from retries of the same request — a requirement for many async APIs such as the AWS Cloud Control API. Implementation: - `CommandRunner` gains an `idempotency_tokens: HashMap` field populated at startup (one UUID per resource in the manifest). - `get_full_context` accepts an `idempotency_token: Option<&str>` and injects two keys when a token is present: - `idempotency_token` (unscoped, for direct use) - `.idempotency_token` (scoped, for `this.` and downstream access) - `this.idempotency_token` inside a resource's `.iql` file preprocesses to `{{ .idempotency_token }}` via the existing `preprocess_this_prefix` mechanism. Documentation: - `template-filters.md`: new "Special Variables" section documenting `stack_name`, `stack_env`, `resource_name`, and `idempotency_token` with usage examples and a tip contrasting it with `uuid()`. - `resource-query-files.md`: new "Special Variables" section with a reference table and an `idempotency_token` usage example. https://claude.ai/code/session_01HAXNi38tB8nY9wiDnqVNWH --- src/commands/base.rs | 16 ++++ src/core/config.rs | 106 +++++++++++++++++++++++++-- website/docs/resource-query-files.md | 43 ++++++++++- website/docs/template-filters.md | 69 +++++++++++++++++ 4 files changed, 228 insertions(+), 6 deletions(-) diff --git a/src/commands/base.rs b/src/commands/base.rs index 9c14573..12519a7 100644 --- a/src/commands/base.rs +++ b/src/commands/base.rs @@ -37,6 +37,9 @@ pub struct CommandRunner { pub stack_name: String, #[allow(dead_code)] pub env_vars: HashMap, + /// Per-resource idempotency tokens (UUID v4), stable for the lifetime of + /// a single session (invocation). Keyed by resource name. + pub idempotency_tokens: HashMap, } impl CommandRunner { @@ -72,6 +75,16 @@ impl CommandRunner { // Render globals let global_context = render_globals(&engine, &env_vars, &manifest, stack_env, &stack_name); + // Generate a stable UUID v4 idempotency token for each resource once, + // at session start. The same token is reused on every retry within + // this invocation, allowing providers to distinguish retries from new + // requests. + let idempotency_tokens: HashMap = manifest + .resources + .iter() + .map(|r| (r.name.clone(), uuid::Uuid::new_v4().to_string())) + .collect(); + // Pull providers pull_providers(&manifest.providers, &mut client); @@ -84,16 +97,19 @@ impl CommandRunner { stack_env: stack_env.to_string(), stack_name, env_vars, + idempotency_tokens, } } /// Get the full context for a resource (global + resource properties). pub fn get_full_context(&self, resource: &Resource) -> HashMap { + let token = self.idempotency_tokens.get(&resource.name).map(|s| s.as_str()); get_full_context( &self.engine, &self.global_context, resource, &self.stack_env, + token, ) } diff --git a/src/core/config.rs b/src/core/config.rs index 6305939..9419d2b 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -288,11 +288,18 @@ pub fn render_properties( /// Injects `resource_name` as a special variable (like `stack_name` and `stack_env`) /// containing the current resource's name. Any global values that contain deferred /// template expressions (e.g., `{{ resource_name }}`) are re-rendered at this point. +/// +/// When `idempotency_token` is `Some`, injects two keys into the context: +/// - `idempotency_token` — unscoped form for direct use inside this resource's templates. +/// - `{resource_name}.idempotency_token` — scoped form so that `this.idempotency_token` +/// (which preprocesses to `{resource_name}.idempotency_token`) resolves correctly, and +/// so downstream resources can reference `{resource_name}.idempotency_token`. pub fn get_full_context( engine: &TemplateEngine, global_context: &HashMap, resource: &crate::resource::manifest::Resource, stack_env: &str, + idempotency_token: Option<&str>, ) -> HashMap { debug!("Getting full context for {}...", resource.name); @@ -300,6 +307,17 @@ pub fn get_full_context( let mut context_with_resource_name = global_context.clone(); context_with_resource_name.insert("resource_name".to_string(), resource.name.clone()); + // Inject the per-resource idempotency token when provided. + if let Some(token) = idempotency_token { + // Unscoped form: {{ idempotency_token }} + context_with_resource_name.insert("idempotency_token".to_string(), token.to_string()); + // Scoped form: {{ this.idempotency_token }} (preprocessed to + // {{ {resource_name}.idempotency_token }}) and + // {{ {resource_name}.idempotency_token }} for downstream resources. + let scoped_key = format!("{}.idempotency_token", resource.name); + context_with_resource_name.insert(scoped_key, token.to_string()); + } + // Re-render any global values that contain deferred template expressions. // This allows globals (e.g., global_tags) to use {{ resource_name }} which couldn't // be resolved at global rendering time since the resource wasn't known yet. @@ -442,7 +460,7 @@ mod tests { let resource = make_resource("cross_account_role", vec![]); - let ctx = get_full_context(&engine, &global_context, &resource, "dev"); + let ctx = get_full_context(&engine, &global_context, &resource, "dev", None); assert_eq!(ctx.get("resource_name").unwrap(), "cross_account_role"); // Existing variables still present @@ -462,7 +480,7 @@ mod tests { vec![make_prop("tag_value", "{{ resource_name }}")], ); - let ctx = get_full_context(&engine, &global_context, &resource, "dev"); + let ctx = get_full_context(&engine, &global_context, &resource, "dev", None); assert_eq!(ctx.get("tag_value").unwrap(), "cross_account_role"); } @@ -482,7 +500,7 @@ mod tests { let resource = make_resource("cross_account_role", vec![]); - let ctx = get_full_context(&engine, &global_context, &resource, "dev"); + let ctx = get_full_context(&engine, &global_context, &resource, "dev", None); let global_tags = ctx.get("global_tags").unwrap(); assert!( @@ -510,8 +528,8 @@ mod tests { let res1 = make_resource("vpc_network", vec![]); let res2 = make_resource("storage_bucket", vec![]); - let ctx1 = get_full_context(&engine, &global_context, &res1, "dev"); - let ctx2 = get_full_context(&engine, &global_context, &res2, "dev"); + let ctx1 = get_full_context(&engine, &global_context, &res1, "dev", None); + let ctx2 = get_full_context(&engine, &global_context, &res2, "dev", None); assert_eq!(ctx1.get("resource_name").unwrap(), "vpc_network"); assert_eq!(ctx2.get("resource_name").unwrap(), "storage_bucket"); @@ -546,4 +564,82 @@ mod tests { assert_eq!(result.get("tag").unwrap(), "resource:my_resource"); } + + // ------------------------------------------------------------------ + // idempotency_token tests + // ------------------------------------------------------------------ + + #[test] + fn test_idempotency_token_injected_into_context() { + let engine = TemplateEngine::new(); + let mut global_context = HashMap::new(); + global_context.insert("stack_name".to_string(), "my-stack".to_string()); + global_context.insert("stack_env".to_string(), "dev".to_string()); + + let resource = make_resource("my_resource", vec![]); + let token = "550e8400-e29b-41d4-a716-446655440000"; + + let ctx = get_full_context(&engine, &global_context, &resource, "dev", Some(token)); + + // Unscoped form is available + assert_eq!(ctx.get("idempotency_token").unwrap(), token); + // Scoped form is available (for `this.idempotency_token` and downstream access) + assert_eq!( + ctx.get("my_resource.idempotency_token").unwrap(), + token + ); + } + + #[test] + fn test_idempotency_token_none_not_injected() { + let engine = TemplateEngine::new(); + let mut global_context = HashMap::new(); + global_context.insert("stack_name".to_string(), "my-stack".to_string()); + global_context.insert("stack_env".to_string(), "dev".to_string()); + + let resource = make_resource("my_resource", vec![]); + + let ctx = get_full_context(&engine, &global_context, &resource, "dev", None); + + assert!(ctx.get("idempotency_token").is_none()); + assert!(ctx.get("my_resource.idempotency_token").is_none()); + } + + #[test] + fn test_idempotency_token_scoped_key_uses_resource_name() { + let engine = TemplateEngine::new(); + let global_context = HashMap::new(); + let token = "aaaabbbb-cccc-dddd-eeee-ffffffffffff"; + + let res1 = make_resource("vpc_network", vec![]); + let res2 = make_resource("storage_bucket", vec![]); + + let ctx1 = get_full_context(&engine, &global_context, &res1, "dev", Some(token)); + let ctx2 = get_full_context(&engine, &global_context, &res2, "dev", Some(token)); + + assert_eq!(ctx1.get("vpc_network.idempotency_token").unwrap(), token); + assert_eq!(ctx2.get("storage_bucket.idempotency_token").unwrap(), token); + // Unscoped form is the same token in both + assert_eq!(ctx1.get("idempotency_token").unwrap(), token); + assert_eq!(ctx2.get("idempotency_token").unwrap(), token); + } + + #[test] + fn test_idempotency_token_usable_in_template() { + let engine = TemplateEngine::new(); + let global_context = HashMap::new(); + let token = "test-token-1234"; + + let resource = make_resource( + "my_res", + vec![make_prop( + "client_token", + "{{ idempotency_token }}", + )], + ); + + let ctx = get_full_context(&engine, &global_context, &resource, "dev", Some(token)); + + assert_eq!(ctx.get("client_token").unwrap(), token); + } } diff --git a/website/docs/resource-query-files.md b/website/docs/resource-query-files.md index 721fb59..13718e2 100644 --- a/website/docs/resource-query-files.md +++ b/website/docs/resource-query-files.md @@ -279,9 +279,50 @@ AND project = '{{ project }}' AND zone = '{{ zone }}' ``` +## Special Variables + +In addition to the properties defined in the manifest, StackQL Deploy injects a set of built-in variables into every template context automatically. + +| Variable | Scope | Description | +|---|---|---| +| `stack_name` | Global | Name of the stack as declared in the manifest | +| `stack_env` | Global | Environment name supplied to the CLI (`dev`, `prd`, etc.) | +| `resource_name` | Per-resource | Name of the resource currently being processed | +| `idempotency_token` | Per-resource | Stable UUID v4 for this resource for the lifetime of the session | +| `this.idempotency_token` | Per-resource (inside `.iql`) | Preferred alias — expands to `{{ .idempotency_token }}` | +| `.idempotency_token` | Global | Scoped form, usable in any downstream resource | + +### `idempotency_token` + +`idempotency_token` is generated once per resource at session start and stays constant for all retries within that run. Many providers (for example the AWS Cloud Control API) accept a client-side token to identify whether a request is a genuine new operation or a retry of an earlier one — `idempotency_token` is designed exactly for that purpose. + +```sql +/*+ create */ +INSERT INTO awscc.cloudformation.stacks( + StackName, + TemplateURL, + ClientRequestToken, + region +) +SELECT + '{{ stack_name }}-{{ stack_env }}', + '{{ template_url }}', + '{{ this.idempotency_token }}', + '{{ region }}' +RETURNING * +``` + +:::tip + +Use `{{ this.idempotency_token }}` (which expands to `{{ .idempotency_token }}`) when writing queries inside a resource's own `.iql` file. Use `{{ .idempotency_token }}` to access another resource's token from a downstream resource. + +Unlike `{{ uuid() }}`, which generates a **new** UUID on every render, `idempotency_token` is stable for the entire session, making it safe to include in queries that may be retried. + +::: + ## Template Filters -StackQL Deploy uses a Jinja2-compatible templating engine and extends it with custom filters for infrastructure provisioning. For a complete reference of all available filters, see the [__Template Filters__](template-filters) documentation. +StackQL Deploy uses a Jinja2-compatible templating engine and extends it with custom filters for infrastructure provisioning. For a complete reference of all available filters and special variables, see the [__Template Filters__](template-filters) documentation. Here are a few commonly used filters: diff --git a/website/docs/template-filters.md b/website/docs/template-filters.md index a9c24e0..a8827f4 100644 --- a/website/docs/template-filters.md +++ b/website/docs/template-filters.md @@ -149,6 +149,75 @@ SELECT ; ``` +## Special Variables + +StackQL Deploy injects the following built-in variables automatically — no manifest configuration is required. + +### `stack_name` + +The name of the stack as declared in `stackql_manifest.yml`. Available in every template context. + +```sql +INSERT INTO google.compute.networks (project, data__name) +SELECT '{{ project }}', '{{ stack_name }}-{{ stack_env }}-vpc' +``` + +### `stack_env` + +The environment name supplied to the CLI (e.g. `dev`, `sit`, `prd`). Available in every template context. + +### `resource_name` + +The name of the resource currently being processed. Available in every resource template context. + +```sql +/*+ create */ +INSERT INTO google.logging.sinks (parent, data__name) +SELECT 'projects/{{ project }}', '{{ resource_name }}-sink' +``` + +### `idempotency_token` + +A UUID v4 that is generated **once per resource per session (invocation)** and remains stable for the lifetime of that run. This is particularly important for asynchronous mutation operations where a provider needs to reliably distinguish a genuine new request from a retry of an earlier request. + +| Access form | Where available | +|---|---| +| `{{ idempotency_token }}` | Inside the resource's own `.iql` file | +| `{{ this.idempotency_token }}` | Inside the resource's own `.iql` file (preferred, explicit) | +| `{{ .idempotency_token }}` | In any downstream resource template | + +**Example — passing a client token to AWS Cloud Control API:** + +```sql +/*+ create */ +INSERT INTO awscc.cloudformation.stacks( + StackName, + TemplateURL, + ClientRequestToken, + region +) +SELECT + '{{ stack_name }}-{{ stack_env }}', + '{{ template_url }}', + '{{ this.idempotency_token }}', + '{{ region }}' +RETURNING * +``` + +**Example — referencing another resource's token from a downstream resource:** + +```sql +/*+ create */ +INSERT INTO awscc.some.resource(ParentToken, region) +SELECT '{{ my_upstream_resource.idempotency_token }}', '{{ region }}' +``` + +:::note + +`{{ uuid() }}` (see below) generates a **new** UUID on every template render, so retrying the same query produces a different value each time. Use `{{ this.idempotency_token }}` instead when you need a stable, retry-safe identifier. + +::: + ## Global Functions ### `uuid` From 15752c949d128d144d7d2aa19639f862b58718a8 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Mon, 16 Mar 2026 10:31:42 +1100 Subject: [PATCH 2/2] fixed formatting --- src/commands/base.rs | 5 ++++- src/core/config.rs | 10 ++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/commands/base.rs b/src/commands/base.rs index 12519a7..f568088 100644 --- a/src/commands/base.rs +++ b/src/commands/base.rs @@ -103,7 +103,10 @@ impl CommandRunner { /// Get the full context for a resource (global + resource properties). pub fn get_full_context(&self, resource: &Resource) -> HashMap { - let token = self.idempotency_tokens.get(&resource.name).map(|s| s.as_str()); + let token = self + .idempotency_tokens + .get(&resource.name) + .map(|s| s.as_str()); get_full_context( &self.engine, &self.global_context, diff --git a/src/core/config.rs b/src/core/config.rs index 9419d2b..895d777 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -584,10 +584,7 @@ mod tests { // Unscoped form is available assert_eq!(ctx.get("idempotency_token").unwrap(), token); // Scoped form is available (for `this.idempotency_token` and downstream access) - assert_eq!( - ctx.get("my_resource.idempotency_token").unwrap(), - token - ); + assert_eq!(ctx.get("my_resource.idempotency_token").unwrap(), token); } #[test] @@ -632,10 +629,7 @@ mod tests { let resource = make_resource( "my_res", - vec![make_prop( - "client_token", - "{{ idempotency_token }}", - )], + vec![make_prop("client_token", "{{ idempotency_token }}")], ); let ctx = get_full_context(&engine, &global_context, &resource, "dev", Some(token));