Skip to content

Commit 947b0d8

Browse files
authored
Merge pull request #30 from stackql/claude/add-idempotency-token-XYHOD
feat: add idempotency_token special variable per resource per session
2 parents 52388df + 15752c9 commit 947b0d8

4 files changed

Lines changed: 225 additions & 6 deletions

File tree

src/commands/base.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ pub struct CommandRunner {
3737
pub stack_name: String,
3838
#[allow(dead_code)]
3939
pub env_vars: HashMap<String, String>,
40+
/// Per-resource idempotency tokens (UUID v4), stable for the lifetime of
41+
/// a single session (invocation). Keyed by resource name.
42+
pub idempotency_tokens: HashMap<String, String>,
4043
}
4144

4245
impl CommandRunner {
@@ -72,6 +75,16 @@ impl CommandRunner {
7275
// Render globals
7376
let global_context = render_globals(&engine, &env_vars, &manifest, stack_env, &stack_name);
7477

78+
// Generate a stable UUID v4 idempotency token for each resource once,
79+
// at session start. The same token is reused on every retry within
80+
// this invocation, allowing providers to distinguish retries from new
81+
// requests.
82+
let idempotency_tokens: HashMap<String, String> = manifest
83+
.resources
84+
.iter()
85+
.map(|r| (r.name.clone(), uuid::Uuid::new_v4().to_string()))
86+
.collect();
87+
7588
// Pull providers
7689
pull_providers(&manifest.providers, &mut client);
7790

@@ -84,16 +97,22 @@ impl CommandRunner {
8497
stack_env: stack_env.to_string(),
8598
stack_name,
8699
env_vars,
100+
idempotency_tokens,
87101
}
88102
}
89103

90104
/// Get the full context for a resource (global + resource properties).
91105
pub fn get_full_context(&self, resource: &Resource) -> HashMap<String, String> {
106+
let token = self
107+
.idempotency_tokens
108+
.get(&resource.name)
109+
.map(|s| s.as_str());
92110
get_full_context(
93111
&self.engine,
94112
&self.global_context,
95113
resource,
96114
&self.stack_env,
115+
token,
97116
)
98117
}
99118

src/core/config.rs

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -288,18 +288,36 @@ pub fn render_properties(
288288
/// Injects `resource_name` as a special variable (like `stack_name` and `stack_env`)
289289
/// containing the current resource's name. Any global values that contain deferred
290290
/// template expressions (e.g., `{{ resource_name }}`) are re-rendered at this point.
291+
///
292+
/// When `idempotency_token` is `Some`, injects two keys into the context:
293+
/// - `idempotency_token` — unscoped form for direct use inside this resource's templates.
294+
/// - `{resource_name}.idempotency_token` — scoped form so that `this.idempotency_token`
295+
/// (which preprocesses to `{resource_name}.idempotency_token`) resolves correctly, and
296+
/// so downstream resources can reference `{resource_name}.idempotency_token`.
291297
pub fn get_full_context(
292298
engine: &TemplateEngine,
293299
global_context: &HashMap<String, String>,
294300
resource: &crate::resource::manifest::Resource,
295301
stack_env: &str,
302+
idempotency_token: Option<&str>,
296303
) -> HashMap<String, String> {
297304
debug!("Getting full context for {}...", resource.name);
298305

299306
// Inject resource_name into the context so it's available in props and re-rendered globals
300307
let mut context_with_resource_name = global_context.clone();
301308
context_with_resource_name.insert("resource_name".to_string(), resource.name.clone());
302309

310+
// Inject the per-resource idempotency token when provided.
311+
if let Some(token) = idempotency_token {
312+
// Unscoped form: {{ idempotency_token }}
313+
context_with_resource_name.insert("idempotency_token".to_string(), token.to_string());
314+
// Scoped form: {{ this.idempotency_token }} (preprocessed to
315+
// {{ {resource_name}.idempotency_token }}) and
316+
// {{ {resource_name}.idempotency_token }} for downstream resources.
317+
let scoped_key = format!("{}.idempotency_token", resource.name);
318+
context_with_resource_name.insert(scoped_key, token.to_string());
319+
}
320+
303321
// Re-render any global values that contain deferred template expressions.
304322
// This allows globals (e.g., global_tags) to use {{ resource_name }} which couldn't
305323
// be resolved at global rendering time since the resource wasn't known yet.
@@ -442,7 +460,7 @@ mod tests {
442460

443461
let resource = make_resource("cross_account_role", vec![]);
444462

445-
let ctx = get_full_context(&engine, &global_context, &resource, "dev");
463+
let ctx = get_full_context(&engine, &global_context, &resource, "dev", None);
446464

447465
assert_eq!(ctx.get("resource_name").unwrap(), "cross_account_role");
448466
// Existing variables still present
@@ -462,7 +480,7 @@ mod tests {
462480
vec![make_prop("tag_value", "{{ resource_name }}")],
463481
);
464482

465-
let ctx = get_full_context(&engine, &global_context, &resource, "dev");
483+
let ctx = get_full_context(&engine, &global_context, &resource, "dev", None);
466484

467485
assert_eq!(ctx.get("tag_value").unwrap(), "cross_account_role");
468486
}
@@ -482,7 +500,7 @@ mod tests {
482500

483501
let resource = make_resource("cross_account_role", vec![]);
484502

485-
let ctx = get_full_context(&engine, &global_context, &resource, "dev");
503+
let ctx = get_full_context(&engine, &global_context, &resource, "dev", None);
486504

487505
let global_tags = ctx.get("global_tags").unwrap();
488506
assert!(
@@ -510,8 +528,8 @@ mod tests {
510528
let res1 = make_resource("vpc_network", vec![]);
511529
let res2 = make_resource("storage_bucket", vec![]);
512530

513-
let ctx1 = get_full_context(&engine, &global_context, &res1, "dev");
514-
let ctx2 = get_full_context(&engine, &global_context, &res2, "dev");
531+
let ctx1 = get_full_context(&engine, &global_context, &res1, "dev", None);
532+
let ctx2 = get_full_context(&engine, &global_context, &res2, "dev", None);
515533

516534
assert_eq!(ctx1.get("resource_name").unwrap(), "vpc_network");
517535
assert_eq!(ctx2.get("resource_name").unwrap(), "storage_bucket");
@@ -546,4 +564,76 @@ mod tests {
546564

547565
assert_eq!(result.get("tag").unwrap(), "resource:my_resource");
548566
}
567+
568+
// ------------------------------------------------------------------
569+
// idempotency_token tests
570+
// ------------------------------------------------------------------
571+
572+
#[test]
573+
fn test_idempotency_token_injected_into_context() {
574+
let engine = TemplateEngine::new();
575+
let mut global_context = HashMap::new();
576+
global_context.insert("stack_name".to_string(), "my-stack".to_string());
577+
global_context.insert("stack_env".to_string(), "dev".to_string());
578+
579+
let resource = make_resource("my_resource", vec![]);
580+
let token = "550e8400-e29b-41d4-a716-446655440000";
581+
582+
let ctx = get_full_context(&engine, &global_context, &resource, "dev", Some(token));
583+
584+
// Unscoped form is available
585+
assert_eq!(ctx.get("idempotency_token").unwrap(), token);
586+
// Scoped form is available (for `this.idempotency_token` and downstream access)
587+
assert_eq!(ctx.get("my_resource.idempotency_token").unwrap(), token);
588+
}
589+
590+
#[test]
591+
fn test_idempotency_token_none_not_injected() {
592+
let engine = TemplateEngine::new();
593+
let mut global_context = HashMap::new();
594+
global_context.insert("stack_name".to_string(), "my-stack".to_string());
595+
global_context.insert("stack_env".to_string(), "dev".to_string());
596+
597+
let resource = make_resource("my_resource", vec![]);
598+
599+
let ctx = get_full_context(&engine, &global_context, &resource, "dev", None);
600+
601+
assert!(ctx.get("idempotency_token").is_none());
602+
assert!(ctx.get("my_resource.idempotency_token").is_none());
603+
}
604+
605+
#[test]
606+
fn test_idempotency_token_scoped_key_uses_resource_name() {
607+
let engine = TemplateEngine::new();
608+
let global_context = HashMap::new();
609+
let token = "aaaabbbb-cccc-dddd-eeee-ffffffffffff";
610+
611+
let res1 = make_resource("vpc_network", vec![]);
612+
let res2 = make_resource("storage_bucket", vec![]);
613+
614+
let ctx1 = get_full_context(&engine, &global_context, &res1, "dev", Some(token));
615+
let ctx2 = get_full_context(&engine, &global_context, &res2, "dev", Some(token));
616+
617+
assert_eq!(ctx1.get("vpc_network.idempotency_token").unwrap(), token);
618+
assert_eq!(ctx2.get("storage_bucket.idempotency_token").unwrap(), token);
619+
// Unscoped form is the same token in both
620+
assert_eq!(ctx1.get("idempotency_token").unwrap(), token);
621+
assert_eq!(ctx2.get("idempotency_token").unwrap(), token);
622+
}
623+
624+
#[test]
625+
fn test_idempotency_token_usable_in_template() {
626+
let engine = TemplateEngine::new();
627+
let global_context = HashMap::new();
628+
let token = "test-token-1234";
629+
630+
let resource = make_resource(
631+
"my_res",
632+
vec![make_prop("client_token", "{{ idempotency_token }}")],
633+
);
634+
635+
let ctx = get_full_context(&engine, &global_context, &resource, "dev", Some(token));
636+
637+
assert_eq!(ctx.get("client_token").unwrap(), token);
638+
}
549639
}

website/docs/resource-query-files.md

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,9 +279,50 @@ AND project = '{{ project }}'
279279
AND zone = '{{ zone }}'
280280
```
281281

282+
## Special Variables
283+
284+
In addition to the properties defined in the manifest, StackQL Deploy injects a set of built-in variables into every template context automatically.
285+
286+
| Variable | Scope | Description |
287+
|---|---|---|
288+
| `stack_name` | Global | Name of the stack as declared in the manifest |
289+
| `stack_env` | Global | Environment name supplied to the CLI (`dev`, `prd`, etc.) |
290+
| `resource_name` | Per-resource | Name of the resource currently being processed |
291+
| `idempotency_token` | Per-resource | Stable UUID v4 for this resource for the lifetime of the session |
292+
| `this.idempotency_token` | Per-resource (inside `.iql`) | Preferred alias — expands to `{{ <resource_name>.idempotency_token }}` |
293+
| `<resource_name>.idempotency_token` | Global | Scoped form, usable in any downstream resource |
294+
295+
### `idempotency_token`
296+
297+
`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.
298+
299+
```sql
300+
/*+ create */
301+
INSERT INTO awscc.cloudformation.stacks(
302+
StackName,
303+
TemplateURL,
304+
ClientRequestToken,
305+
region
306+
)
307+
SELECT
308+
'{{ stack_name }}-{{ stack_env }}',
309+
'{{ template_url }}',
310+
'{{ this.idempotency_token }}',
311+
'{{ region }}'
312+
RETURNING *
313+
```
314+
315+
:::tip
316+
317+
Use `{{ this.idempotency_token }}` (which expands to `{{ <resource_name>.idempotency_token }}`) when writing queries inside a resource's own `.iql` file. Use `{{ <resource_name>.idempotency_token }}` to access another resource's token from a downstream resource.
318+
319+
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.
320+
321+
:::
322+
282323
## Template Filters
283324

284-
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.
325+
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.
285326

286327
Here are a few commonly used filters:
287328

website/docs/template-filters.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,75 @@ SELECT
149149
;
150150
```
151151

152+
## Special Variables
153+
154+
StackQL Deploy injects the following built-in variables automatically — no manifest configuration is required.
155+
156+
### `stack_name`
157+
158+
The name of the stack as declared in `stackql_manifest.yml`. Available in every template context.
159+
160+
```sql
161+
INSERT INTO google.compute.networks (project, data__name)
162+
SELECT '{{ project }}', '{{ stack_name }}-{{ stack_env }}-vpc'
163+
```
164+
165+
### `stack_env`
166+
167+
The environment name supplied to the CLI (e.g. `dev`, `sit`, `prd`). Available in every template context.
168+
169+
### `resource_name`
170+
171+
The name of the resource currently being processed. Available in every resource template context.
172+
173+
```sql
174+
/*+ create */
175+
INSERT INTO google.logging.sinks (parent, data__name)
176+
SELECT 'projects/{{ project }}', '{{ resource_name }}-sink'
177+
```
178+
179+
### `idempotency_token`
180+
181+
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.
182+
183+
| Access form | Where available |
184+
|---|---|
185+
| `{{ idempotency_token }}` | Inside the resource's own `.iql` file |
186+
| `{{ this.idempotency_token }}` | Inside the resource's own `.iql` file (preferred, explicit) |
187+
| `{{ <resource_name>.idempotency_token }}` | In any downstream resource template |
188+
189+
**Example — passing a client token to AWS Cloud Control API:**
190+
191+
```sql
192+
/*+ create */
193+
INSERT INTO awscc.cloudformation.stacks(
194+
StackName,
195+
TemplateURL,
196+
ClientRequestToken,
197+
region
198+
)
199+
SELECT
200+
'{{ stack_name }}-{{ stack_env }}',
201+
'{{ template_url }}',
202+
'{{ this.idempotency_token }}',
203+
'{{ region }}'
204+
RETURNING *
205+
```
206+
207+
**Example — referencing another resource's token from a downstream resource:**
208+
209+
```sql
210+
/*+ create */
211+
INSERT INTO awscc.some.resource(ParentToken, region)
212+
SELECT '{{ my_upstream_resource.idempotency_token }}', '{{ region }}'
213+
```
214+
215+
:::note
216+
217+
`{{ 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.
218+
219+
:::
220+
152221
## Global Functions
153222

154223
### `uuid`

0 commit comments

Comments
 (0)