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
19 changes: 19 additions & 0 deletions src/commands/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ pub struct CommandRunner {
pub stack_name: String,
#[allow(dead_code)]
pub env_vars: HashMap<String, String>,
/// Per-resource idempotency tokens (UUID v4), stable for the lifetime of
/// a single session (invocation). Keyed by resource name.
pub idempotency_tokens: HashMap<String, String>,
}

impl CommandRunner {
Expand Down Expand Up @@ -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<String, String> = manifest
.resources
.iter()
.map(|r| (r.name.clone(), uuid::Uuid::new_v4().to_string()))
.collect();

// Pull providers
pull_providers(&manifest.providers, &mut client);

Expand All @@ -84,16 +97,22 @@ 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<String, String> {
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,
)
}

Expand Down
100 changes: 95 additions & 5 deletions src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,18 +288,36 @@ 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<String, String>,
resource: &crate::resource::manifest::Resource,
stack_env: &str,
idempotency_token: Option<&str>,
) -> HashMap<String, String> {
debug!("Getting full context for {}...", resource.name);

// Inject resource_name into the context so it's available in props and re-rendered globals
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.
Expand Down Expand Up @@ -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
Expand All @@ -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");
}
Expand All @@ -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!(
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -546,4 +564,76 @@ 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);
}
}
43 changes: 42 additions & 1 deletion website/docs/resource-query-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `{{ <resource_name>.idempotency_token }}` |
| `<resource_name>.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 `{{ <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.

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:

Expand Down
69 changes: 69 additions & 0 deletions website/docs/template-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
| `{{ <resource_name>.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`
Expand Down
Loading