Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Stream Analytics JavaScript UDA Terraform Module

One JavaScript user-defined aggregate on a Stream Analytics job β€” a routine the job's query calls over a window of rows β€” targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • πŸͺŸ Declares one JavaScript aggregate the job's query can call over a window rather than a row.
  • πŸ”Ž Reports whether the script declares init, accumulate and computeResult β€” the shape that makes it an aggregate at all, and which nothing else checks.
  • πŸ”€ Emits the aggregate's signature in order β€” the order is the positional argument contract.
  • πŸͺž Names the two ways this resource differs from its scalar twin: this one takes the job as a single Resource ID, and validates name far more strictly.
  • πŸ” Emits the script's length and hash, never the body β€” the provider does not mark it sensitive.
  • 🏷️ Carries no tags and no location; the universal tail is timeouts only.

πŸ’‘ Why it matters: an aggregate written like a scalar function β€” a plain function name(args) { ... } β€” applies perfectly cleanly and aggregates nothing. Stream Analytics expects an object with three named members, and no check anywhere in Terraform or Azure looks for them before the job runs. This module looks, and reports.


❀️ Support this project

If this module saved you time:


πŸ—ΊοΈ Where this fits in the family

flowchart TB
  RG["terraform-azurerm-resource-group"]
  JOB["terraform-azurerm-stream-analytics-job"]
  THIS["terraform-azurerm-stream-analytics-function-javascript-uda"]
  UDF["terraform-azurerm-stream-analytics-function-javascript-udf"]
  QUERY["the job's transformation query, which calls it OVER A WINDOW"]

  RG -->|"name"| JOB
  JOB -->|"id, a single Resource ID"| THIS
  JOB -->|"name, plus the resource group separately"| UDF
  THIS -.->|"parses out the job name and resource group the function wants"| UDF
  THIS -.->|"is called from, but does not manage"| QUERY

  classDef me fill:#0078D4,stroke:#004578,stroke-width:2px,color:#ffffff
  classDef target fill:#004578,stroke:#00243d,stroke-width:2px,color:#ffffff
  classDef sib fill:#eef3f8,stroke:#9db4c9,color:#1a2733

  class THIS me
  class JOB target
  class RG,UDF,QUERY sib
Loading

The two dotted edges are the point. This module parses out the job name and resource group that the scalar function resource takes as arguments β€” and the transformation query, which is where the aggregate is actually called from, lives on the job and is not managed here at all.


🧬 What this module builds

flowchart TB
  NAME["name, 3 to 63 letters digits hyphens -- stricter than the function"]
  JOBID["stream_analytics_job_id, one Resource ID"]
  IN["input, an ORDERED list, at least one"]
  OUT["output, one value PER WINDOW"]
  SCRIPT["script, needs init accumulate computeResult"]
  THIS["azurerm_stream_analytics_function_javascript_uda.this"]
  OID["id"]
  OSIG["input_types in order, output_type"]
  OPARSED["job name and resource group, what the function twin wants"]
  OMEM["declares_all_three_aggregate_members, reported not enforced"]

  NAME --> THIS
  JOBID --> THIS
  IN --> THIS
  OUT --> THIS
  SCRIPT --> THIS
  THIS --> OID
  THIS --> OSIG
  THIS --> OPARSED
  THIS --> OMEM

  classDef me fill:#0078D4,stroke:#004578,stroke-width:2px,color:#ffffff
  classDef target fill:#004578,stroke:#00243d,stroke-width:2px,color:#ffffff
  classDef sib fill:#eef3f8,stroke:#9db4c9,color:#1a2733

  class THIS me
  class OID target
  class NAME,JOBID,IN,OUT,SCRIPT,OSIG,OPARSED,OMEM sib
Loading

Resource inventory

Resource Count Notes
azurerm_stream_analytics_function_javascript_uda 1 (this) one aggregate on one job
/subscriptions/SUB/resourceGroups/RG/providers/Microsoft.StreamAnalytics/streamingJobs/JOB/functions/NAME

The same ID shape the scalar function produces β€” from a different set of arguments.


βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module β€” the caller configures the provider, its authentication, and the mandatory features {} block
Resources created 1

Schema notes that bite

  • πŸ”΄ name enforces ^[a-zA-Z0-9-]{3,63}$ here, while the scalar function twin checks only that the name is non-empty. my_uda, ab and my.uda are all accepted there and refused here β€” verified by comparing both validators.
  • πŸ”΄ The job is taken as a single Resource ID, while the scalar function takes a name plus a resource group. Both compose the same Function Resource ID; neither accepts the other's shape, and this resource has no resource_group_name argument at all.
  • πŸ”΄ An aggregate's script must expose init, accumulate and computeResult β€” called once per window, once per row, and once at the end. Nothing checks for them. A script shaped like a plain function body, which is correct for the scalar twin, applies cleanly and aggregates nothing.
  • πŸ”΄ Nothing compiles, parses or runs the script.
  • πŸ”΄ The script is not marked sensitive, so anything embedded in it is in plan output and in state in clear.
  • ⚠️ input requires at least one block, and the provider reports its absence as a bare minimum-items error.
  • ⚠️ Input order is the positional argument contract. Reordering the list silently rebinds the query's arguments.
  • ⚠️ The provider's own name-validation message prints the VALUE where a field name belongs β€” it reads "<your value> contain only letters, numbers and hyphens" β€” so this module supplies its own wording.
  • ⚠️ The script has no difference suppressor, so reindenting the body produces a diff and no service-side change.
  • The seven types are any, array, bigint, datetime, float, nvarchar(max), record β€” the same seven the scalar function accepts; the two lists differ only in the order the provider's source writes them.
  • Force-new: name and stream_analytics_job_id. Note that is two arguments where the scalar twin has three, because that resource splits the job reference.

πŸ”‘ Required Azure RBAC Roles / Permissions

Least-privilege, at the smallest scope that works:

  • Microsoft.StreamAnalytics/streamingJobs/functions/write, .../read and .../delete on the target job. A custom role scoped to the job is enough; Contributor on the job, or on its resource group, covers this more broadly than necessary.

⚠️ Write access here is the ability to change what the job computes, over whole windows of data. The script runs inside the streaming pipeline, and a plan shows a string.

ℹ️ Plan access is not credential access β€” provided the script carries no credential. The provider does not mark it sensitive.


Azure Prerequisites

  • An existing Stream Analytics job.
  • A transformation query on that job that calls this aggregate over a window, or it computes nothing. That query lives on the job resource and is not managed by this module.
  • The caller configures provider "azurerm" { features {} }, authentication and subscription.

πŸ“ Module Structure

terraform-azurerm-stream-analytics-function-javascript-uda/
β”œβ”€β”€ providers.tf     # required_version + pinned azurerm; no provider block
β”œβ”€β”€ variables.tf     # 6 inputs, 11 validations, the twin's differences documented inline
β”œβ”€β”€ main.tf          # the single keystone `this` + a dynamic input block, output rendered statically
β”œβ”€β”€ outputs.tf       # 31 outputs; the signature, the three members, the hash but never the body
β”œβ”€β”€ README.md        # this file
β”œβ”€β”€ SCOPE.md         # the cross-module contract
β”œβ”€β”€ LICENSE          # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "uda_weighted_avg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-function-javascript-uda.git?ref=v1.0.0"

  name                    = "uda-weighted-avg"
  stream_analytics_job_id = module.job.id

  input  = [{ type = "float" }]
  output = { type = "float" }

  script = <<-JS
    function main() {
      this.init = function () {
        this.total = 0.0;
        this.count = 0;
      }
      this.accumulate = function (value, timestamp) {
        this.total += value;
        this.count += 1;
      }
      this.computeResult = function () {
        return this.count === 0 ? 0.0 : this.total / this.count;
      }
    }
  JS
}

⚠️ Note the name: hyphens, not underscores. uda_weighted_avg is rejected here and would be accepted on the scalar function resource.

ℹ️ The caller configures the provider, its authentication, and the mandatory features {} block. This module declares none of them.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
stream_analytics_job_id string terraform-azurerm-stream-analytics-job output id

Emits

Output Description Consumed by
id Resource ID of the aggregate review
name Name the job's query must call the transformation query
stream_analytics_job_id The job it belongs to sibling modules
stream_analytics_job_name Job name, parsed from that ID the scalar twin
resource_group_name Resource group, parsed from that ID the scalar twin
input_types Declared parameter types, in order signature review
input_count How many parameters signature review
configuration_parameter_positions Which parameters are per-window constants design review
accumulates_no_per_row_value True when nothing varies per row design review
output_type The single return type per window signature review
returns_a_structure True for record or array query review
declares_init Script appears to declare init script review
declares_accumulate Script appears to declare accumulate script review
declares_compute_result Script appears to declare computeResult script review
declares_all_three_aggregate_members All three appear script review
script_length Characters in the script change review
script_sha256 Fingerprint of the script change review
the_name_rule_is_stricter_here_than_on_the_scalar_function Constant true naming standards
the_scalar_twin_names_the_job_by_name_instead Constant true design review
an_aggregate_expects_an_object_not_a_function_body Constant true script review
nothing_compiles_or_runs_the_script Constant true operational review
the_declared_types_are_a_contract_with_the_query_not_the_script Constant true design rationale
input_order_is_the_argument_contract Constant true change planning
script_formatting_changes_produce_a_diff Constant true drift review
the_script_is_not_marked_sensitive Constant true security review
force_new_fields The name and the job ID change planning
fields_azure_returns_on_read Where drift is detectable drift review
create_refuses_an_existing_aggregate Constant true import review
the_import_guard_can_be_disabled_by_a_provider_feature Constant true design rationale
delete_tolerates_an_aggregate_that_is_already_gone Constant true destroy review
this_resource_supports_no_azure_resource_tags Constant true tagging policy

πŸ“š Example Library

1 Β· A running average over a window
module "uda_average" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-function-javascript-uda.git?ref=v1.0.0"

  name                    = "uda-average"
  stream_analytics_job_id = module.job.id

  input  = [{ type = "float" }]
  output = { type = "float" }

  script = <<-JS
    function main() {
      this.init = function () {
        this.total = 0.0;
        this.count = 0;
      }
      this.accumulate = function (value, timestamp) {
        this.total += value;
        this.count += 1;
      }
      this.computeResult = function () {
        return this.count === 0 ? 0.0 : this.total / this.count;
      }
    }
  JS
}

πŸ’‘ init runs once per window, accumulate once per row, computeResult once at the end. That three-part shape is the whole difference between an aggregate and a scalar function.

2 Β· πŸ”΄ The mistake that applies cleanly and aggregates nothing
module "uda_broken" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-function-javascript-uda.git?ref=v1.0.0"

  name                    = "uda-broken"
  stream_analytics_job_id = module.job.id

  input  = [{ type = "float" }]
  output = { type = "float" }

  # This is a SCALAR function body. It is correct for the udf resource and
  # wrong here -- and nothing at any stage says so.
  script = "function average(value) { return value; }"
}

output "is_it_actually_an_aggregate" {
  value = module.uda_broken.declares_all_three_aggregate_members # false
}

πŸ”΄ an_aggregate_expects_an_object_not_a_function_body is true, and nothing_compiles_or_runs_the_script is too. This applies successfully and reads back cleanly. The declares_* outputs are the only warning anything gives β€” and they are text searches, described as such, not a compiler.

3 Β· Configuration parameters β€” constants for the whole window
module "uda_weighted_avg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-function-javascript-uda.git?ref=v1.0.0"

  name                    = "uda-weighted-avg"
  stream_analytics_job_id = module.job.id

  input = [
    { type = "float" },                                  # per row
    { type = "bigint", configuration_parameter = true }, # once per window
  ]

  output = { type = "float" }

  script = <<-JS
    function main() {
      this.init = function () { this.total = 0.0; this.weight = 0; }
      this.accumulate = function (value, weight) {
        this.total += value * weight;
        this.weight += weight;
      }
      this.computeResult = function () {
        return this.weight === 0 ? 0.0 : this.total / this.weight;
      }
    }
  JS
}

⚠️ If every parameter is a configuration parameter, accumulates_no_per_row_value is true β€” which on an aggregate specifically is almost always wrong: it computes the same answer for every window.

4 Β· Returning several statistics at once
module "uda_stats" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-function-javascript-uda.git?ref=v1.0.0"

  name                    = "uda-stats"
  stream_analytics_job_id = module.job.id

  input  = [{ type = "float" }]
  output = { type = "record" }

  script = <<-JS
    function main() {
      this.init = function () { this.n = 0; this.sum = 0.0; this.min = null; this.max = null; }
      this.accumulate = function (v, t) {
        this.n += 1;
        this.sum += v;
        if (this.min === null || v < this.min) { this.min = v; }
        if (this.max === null || v > this.max) { this.max = v; }
      }
      this.computeResult = function () {
        return { n: this.n, mean: this.n ? this.sum / this.n : 0.0, min: this.min, max: this.max };
      }
    }
  JS
}

πŸ’‘ returns_a_structure is true. An aggregate returning a record is the efficient way to compute several statistics in one pass β€” but the query then has to unpack it, and nothing here can tell you whether it does.

5 Β· The name rule, and what it rejects
module "uda_weighted_avg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-function-javascript-uda.git?ref=v1.0.0"

  name                    = "uda_weighted_avg" # <-- rejected
  stream_analytics_job_id = module.job.id

  input  = [{ type = "float" }]
  output = { type = "float" }
  script = "function main() { this.init = function () {}; this.accumulate = function () {}; this.computeResult = function () { return 0; } }"
}
Error: Invalid value for variable

  name contains an underscore, which this resource does not permit -- although
  the SCALAR function resource does. Use a hyphen instead: "weighted_average"
  becomes "weighted-average".

πŸ’‘ The provider's own message for this reads "<your value> contain only letters, numbers and hyphens" β€” it prints the value where a field name belongs and reads ungrammatically β€” so this module supplies its own.

6 Β· πŸͺž The difference from the scalar twin
# THIS resource: one job ID, and a strict name rule.
module "uda_weighted_avg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-function-javascript-uda.git?ref=v1.0.0"

  name                    = "uda-weighted-avg"
  stream_analytics_job_id = module.job.id

  input  = [{ type = "float" }]
  output = { type = "float" }
  script = "function main() { this.init = function () {}; this.accumulate = function (v, t) {}; this.computeResult = function () { return 0; } }"
}

# The SCALAR function: a job NAME and a resource group, and a loose name rule.
# Both are available from THIS module's outputs.
module "udf_to_celsius" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-function-javascript-udf.git?ref=v1.0.0"

  name                      = "udf_to_celsius" # underscores are FINE there
  stream_analytics_job_name = module.uda_weighted_avg.stream_analytics_job_name
  resource_group_name       = module.uda_weighted_avg.resource_group_name

  input  = [{ type = "float" }]
  output = { type = "float" }
  script = "function toCelsius(f) { return (f - 32) * 5 / 9; }"
}

πŸ”΄ the_name_rule_is_stricter_here_than_on_the_scalar_function and the_scalar_twin_names_the_job_by_name_instead are both true. The stream_analytics_job_name and resource_group_name outputs exist precisely so the scalar function can be wired from here rather than from two separate places.

πŸ’‘ Adopt this module's stricter naming rule for both kinds and you will have one convention rather than two.

7 Β· Several aggregates with `for_each`
locals {
  udas = {
    "uda-average" = {
      input  = [{ type = "float" }]
      output = "float"
      script = file("$${path.module}/scripts/average.js")
    }
    "uda-p95" = {
      input  = [{ type = "float" }]
      output = "float"
      script = file("$${path.module}/scripts/p95.js")
    }
  }
}

module "udas" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-function-javascript-uda.git?ref=v1.0.0"
  for_each = local.udas

  name                    = each.key
  stream_analytics_job_id = module.job.id

  input  = each.value.input
  output = { type = each.value.output }
  script = each.value.script
}

πŸ’‘ Key the map by the aggregate name β€” it is force-new and the identifier the query calls, so a stable key keeps adding one from re-creating the rest.

⚠️ Keeping the JavaScript in real .js files means an editor and a linter can see it, which is worth something given that nothing else ever will.

8 Β· Checking every aggregate looks like one
locals {
  malformed = [
    for k, m in module.udas : k if !m.declares_all_three_aggregate_members
  ]

  degenerate = [
    for k, m in module.udas : k if m.accumulates_no_per_row_value
  ]
}

check "aggregates_look_like_aggregates" {
  assert {
    condition     = length(local.malformed) == 0
    error_message = "These Stream Analytics aggregates do not appear to declare init, accumulate and computeResult: ${join(", ", local.malformed)}. Nothing in Terraform or Azure checks this before the job runs."
  }
}

output "aggregate_review" {
  value = {
    signatures = { for k, m in module.udas : k => { args = m.input_types, returns = m.output_type } }
    malformed  = local.malformed
    degenerate = local.degenerate
    hashes     = { for k, m in module.udas : k => m.script_sha256 }
  }
}

πŸ’‘ A check block rather than a validation, deliberately: the declares_* outputs are text searches, and a valid aggregate can be written in ways they will not match. A check reports without blocking β€” and a validation {} failure would block terraform destroy as well as apply.

9 Β· Explicit timeouts
module "uda_average" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-function-javascript-uda.git?ref=v1.0.0"

  name                    = "uda-average"
  stream_analytics_job_id = module.job.id

  input  = [{ type = "float" }]
  output = { type = "float" }
  script = file("$${path.module}/scripts/average.js")

  timeouts = {
    create = "30m"
    read   = "5m"
    update = "30m"
    delete = "30m"
  }
}

ℹ️ The provider defaults are 30m / 5m / 30m / 30m. None of these operations compiles or runs the script, so none is long in practice β€” the defaults are headroom, not an expectation.

10 Β· Renaming, and what it breaks
# `name` is force-new AND the identifier the job's transformation query calls.
# Renaming is a clean replacement to Terraform and a broken reference to the
# query -- which lives on the job resource and is not managed here.
#
#   name = "uda-weighted-avg-v2"   # <-- the query still says "uda-weighted-avg"
#
# Create the new one alongside, update the query, then remove the old block.

⚠️ Terraform cannot see the query, so it cannot warn you. force_new_fields here has two members where the scalar twin's has three, because that resource splits the job reference across two arguments.

11 Β· A bare job name is rejected
module "uda_average" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-function-javascript-uda.git?ref=v1.0.0"

  name                    = "uda-average"
  stream_analytics_job_id = "asa-telemetry" # <-- the shape the SCALAR twin wants

  input  = [{ type = "float" }]
  output = { type = "float" }
  script = "function main() { this.init = function () {}; this.accumulate = function () {}; this.computeResult = function () { return 0; } }"
}
Error: Invalid value for variable

  stream_analytics_job_id does not look like an Azure Resource ID at all -- it
  must begin "/subscriptions/". A bare job name belongs on the SCALAR function
  resource, which takes stream_analytics_job_name and resource_group_name
  instead; this one takes the job's `id` output.

πŸ”’ The pattern is anchored at both ends, so this module's own function ID β€” which extends the same path with /functions/NAME β€” is rejected too, with its own message.

12 Β· Reviewing the script without emitting it
output "aggregate_scripts" {
  value = {
    for k, m in module.udas : k => {
      size        = m.script_length
      fingerprint = m.script_sha256
      init        = m.declares_init
      accumulate  = m.declares_accumulate
      compute     = m.declares_compute_result
    }
  }
}

πŸ”’ the_script_is_not_marked_sensitive is true. The body is deliberately never emitted: re-emitting it would copy anything embedded in it into every consuming configuration's state as well as this one's.

⚠️ The fingerprint moves when the formatting does β€” script_formatting_changes_produce_a_diff is true β€” so compare hashes across environments, not across reformats.

13 Β· πŸ—οΈ End-to-end composition

Resource group + Stream Analytics job + an aggregate and a scalar function on it, both wired from this module's outputs.

provider "azurerm" {
  features {}
}

module "rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-streaming-prod"
  location = "eastus2"
}

module "job" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-job.git?ref=v1.0.0"

  name                = "asa-telemetry"
  resource_group_name = module.rg.name
  location            = module.rg.location

  transformation_query = <<-SQL
    SELECT
      deviceId,
      udf-to-celsius(temperatureF) AS temperatureC,
      uda-weighted-avg(temperatureF, 2) AS weighted
    INTO [output]
    FROM [input] TIMESTAMP BY eventTime
    GROUP BY deviceId, TumblingWindow(minute, 5)
  SQL

  tags = {
    environment = "prod"
    workload    = "telemetry"
  }
}

module "uda_weighted_avg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-function-javascript-uda.git?ref=v1.0.0"

  name                    = "uda-weighted-avg"
  stream_analytics_job_id = module.job.id

  input = [
    { type = "float" },
    { type = "bigint", configuration_parameter = true },
  ]
  output = { type = "float" }

  script = <<-JS
    function main() {
      this.init = function () {
        this.total = 0.0;
        this.weight = 0;
      }
      this.accumulate = function (value, weight) {
        this.total += value * weight;
        this.weight += weight;
      }
      this.computeResult = function () {
        return this.weight === 0 ? 0.0 : this.total / this.weight;
      }
    }
  JS
}

# The scalar function takes a NAME and a RESOURCE GROUP -- both parsed above.
module "udf_to_celsius" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-stream-analytics-function-javascript-udf.git?ref=v1.0.0"

  name                      = "udf-to-celsius"
  stream_analytics_job_name = module.uda_weighted_avg.stream_analytics_job_name
  resource_group_name       = module.uda_weighted_avg.resource_group_name

  input  = [{ type = "float" }]
  output = { type = "float" }

  script = <<-JS
    function toCelsius(fahrenheit) {
      return (fahrenheit - 32) * 5 / 9;
    }
  JS
}

check "the_aggregate_looks_like_an_aggregate" {
  assert {
    condition     = module.uda_weighted_avg.declares_all_three_aggregate_members
    error_message = "uda-weighted-avg does not appear to declare init, accumulate and computeResult. Nothing in Terraform or Azure checks this before the job runs."
  }
}

output "job_function_signatures" {
  value = {
    aggregate = {
      name        = module.uda_weighted_avg.name
      arguments   = module.uda_weighted_avg.input_types
      returns     = module.uda_weighted_avg.output_type
      well_formed = module.uda_weighted_avg.declares_all_three_aggregate_members
      hash        = module.uda_weighted_avg.script_sha256
    }
    scalar = {
      name      = module.udf_to_celsius.name
      arguments = module.udf_to_celsius.input_types
      returns   = module.udf_to_celsius.output_type
      hash      = module.udf_to_celsius.script_sha256
    }
  }
}

πŸ”΄ The transformation_query on the job is what actually calls these two, and neither module manages it. If the names in the query and the names here drift apart, the job fails at run time and every plan stays clean.

πŸ’‘ Note the whole composition needs the job's name and resource group in exactly one place β€” here, parsed from the job ID this module already required. The scalar function's own module can reconstruct the ID in the other direction if you prefer to root the composition there instead.

ℹ️ Output names shown for sibling modules are the ones those modules emit at v1.0.0; align them with the versions you consume.


πŸ“₯ Inputs

Input Type Required Default
name string βœ… β€”
stream_analytics_job_id string βœ… β€”
input list(object({ type, configuration_parameter })) βœ… β€” (at least one)
output object({ type }) βœ… β€”
script string βœ… β€”
timeouts object({ create, read, update, delete }) β€” null
Full schemas
variable "name" {
  type = string
  # ^[a-zA-Z0-9-]{3,63}$ -- mirrors the provider exactly. The SCALAR function
  # twin accepts anything non-empty, so a name may be legal there and not here.
}

variable "stream_analytics_job_id" {
  type = string
  # A full JOB Resource ID, anchored at both ends. The scalar twin takes a bare
  # name plus a resource group instead; this resource has NO resource_group_name
  # argument and derives the value.
}

variable "input" {
  type = list(object({
    type                    = string
    configuration_parameter = optional(bool, false)
  }))
  # ORDER IS THE CONTRACT -- a list, not a set. At least one required.
  # Types: any, array, bigint, datetime, float, nvarchar(max), record.
}

variable "output" {
  type = object({
    type = string
  })
  # An object, because the provider requires exactly one and caps it at one.
  # One value per WINDOW, not per row.
}

variable "script" {
  type = string
  # Must expose init, accumulate and computeResult -- REPORTED, not enforced.
  # Stored, never compiled. NOT marked sensitive by the provider.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
  # Provider defaults: create 30m, read 5m, update 30m, delete 30m.
}

🧾 Outputs

Output Description Notes
id Resource ID of the aggregate Emitted first
name Name the query must call force-new
stream_analytics_job_id The job it belongs to
stream_analytics_job_name Job name, parsed from that ID what the scalar twin takes
resource_group_name Resource group, parsed from that ID what the scalar twin takes
input_types Parameter types, in order the signature
input_count How many parameters known at plan time
configuration_parameter_positions Positions of the per-window constants positions, not a count
accumulates_no_per_row_value True when nothing varies per row almost always a mistake
output_type The return type per window
returns_a_structure True for record or array
declares_init init appears in the script a text search
declares_accumulate accumulate appears a text search
declares_compute_result computeResult appears a text search
declares_all_three_aggregate_members All three appear reported, not enforced
script_length Characters in the script body never emitted
script_sha256 Fingerprint of the script changes on reformat
the_name_rule_is_stricter_here_than_on_the_scalar_function Constant true
the_scalar_twin_names_the_job_by_name_instead Constant true
an_aggregate_expects_an_object_not_a_function_body Constant true
nothing_compiles_or_runs_the_script Constant true
the_declared_types_are_a_contract_with_the_query_not_the_script Constant true
input_order_is_the_argument_contract Constant true
script_formatting_changes_produce_a_diff Constant true
the_script_is_not_marked_sensitive Constant true
force_new_fields ["name", "stream_analytics_job_id"] two, not three
fields_azure_returns_on_read Where drift is detectable the script genuinely is
create_refuses_an_existing_aggregate Constant true
the_import_guard_can_be_disabled_by_a_provider_feature Constant true the toggle is the caller's
delete_tolerates_an_aggregate_that_is_already_gone Constant true
this_resource_supports_no_azure_resource_tags Constant true tag the job instead

No secret is emitted, and the script is deliberately not echoed back.


🧠 Architecture Notes

An aggregate is an object, and nothing checks that yours is one. Stream Analytics calls init once at the start of each window, accumulate once per row, and computeResult once at the end. A script that is a plain function name(args) { ... } β€” exactly the right shape for the scalar twin β€” applies cleanly here, reads back cleanly, and aggregates nothing. There is no compiler, no schema check and no service-side validation before the job runs. This module therefore searches for the three member names and reports what it finds, in four outputs whose descriptions say plainly that a text search is what they are. It does not refuse: a valid aggregate can be written in ways the search will not match, and a validation {} failure would block terraform destroy as well as apply.

Two resources, one idea, two shapes. This resource takes stream_analytics_job_id; the scalar function takes stream_analytics_job_name plus resource_group_name. This one enforces ^[a-zA-Z0-9-]{3,63}$ on the name; the scalar function accepts anything non-empty β€” so my_uda and ab are legal there and refused here, verified by comparing both validators rather than inferred from one. Both compose the same Function Resource ID underneath. This module emits stream_analytics_job_name and resource_group_name, parsed from the job ID it already required, so a configuration managing both kinds can wire them from one place.

Different parsing from the twin, not a copy of it. This module parses the job ID it was given β€” nine elements β€” while the scalar module parses the function ID it produces β€” eleven. The two expressions look similar and index different things; both index sets were confirmed against real IDs before either was written.

The signature is positional, so input is a list. Stream Analytics binds a query's arguments by position. Reordering the list is a silent rebinding, which is why the module keeps it a list rather than a set and emits input_types in order rather than sorted. On an aggregate the per-row versus per-window distinction carries extra weight: accumulates_no_per_row_value reports the degenerate case where every parameter is a constant, which computes the same answer for every window.

Names, not bodies. The provider does not mark script sensitive, so anything embedded in it is in plan output and in state in clear. Re-emitting it would put it in every consuming configuration's state too, so this module emits script_length, script_sha256 and the three member flags instead. Note that the hash moves when the formatting does, because the provider suppresses no differences on this field.

Where each check actually fires. The name pattern, the job-ID anchor, the type enums and the input/output shape rules are module validation {} blocks: they refuse a bad configuration at terraform plan, offline. The provider's own name pattern, its non-empty check on the script and its minimum-items rule fire there too. Whether the job exists is decided at apply; whether the script aggregates anything is decided when the job runs.

The features {} dependence. The provider will not initialize without a caller-side provider "azurerm" { features {} } block. That block also carries the toggle that can disable this resource's create-time existence check.


🧱 Design Principles

Concern Default in this module Opt-out
The three aggregate members reported, never enforced β€” four outputs, described as text searches assert them in a check block
Input order preserved β€” a list, never a set none; order is the contract
Empty input rejected, with a message naming the reason none
output modelled as an object, since the provider requires exactly one β€”
Type values the provider's seven, mirrored none
Name the provider's pattern mirrored, plus an underscore-specific message none
Script in outputs never emitted; length, hash and member flags instead β€”
Job reference confusion a bare name is rejected, naming the scalar twin β€”
Secrets none accepted deliberately, none emitted β€”
Tags not supported by the resource tag the job

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module with ?ref=v1.0.0 β€” never a branch. This module is authored and verified plan-only; a human applies from CI.


πŸ§ͺ Testing

What validate and fmt cover, offline and without credentials

  • All 11 validation {} blocks β€” two on the name, three on the job ID, two on input, one on output, two on script and one on timeouts β€” each proven to fire from a deliberately bad .tfvars file, and each proven not to fire from two fully-populated valid ones.
  • Every fixture was first checked to parse, because a malformed fixture reports no validation failure and reads exactly like a check that does not fire.
  • The naming asymmetry is proven in both directions: a name with underscores fires here and is proven to pass on the scalar function's own fixtures.
  • Every derived output expression, lifted into a console harness and driven in three states β€” a one-argument aggregate with a full three-member script, a two-argument one with a configuration parameter returning a record, and a degenerate case with only init declared and every parameter a constant.

What only terraform plan or apply exercises

  • Whether the job exists.
  • Whether an aggregate of that name is already there, and whether the caller's features {} block has disabled the check that would catch it.

What nothing in Terraform ever exercises

  • Whether the script compiles, declares the three members in a form the service recognises, matches its declared signature, or returns the declared type. The failure appears when the job runs.

πŸ’¬ Example Output

id                                    = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-streaming-prod/providers/Microsoft.StreamAnalytics/streamingJobs/asa-telemetry/functions/uda-weighted-avg"
name                                  = "uda-weighted-avg"
stream_analytics_job_id               = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-streaming-prod/providers/Microsoft.StreamAnalytics/streamingJobs/asa-telemetry"
stream_analytics_job_name             = "asa-telemetry"
resource_group_name                   = "rg-streaming-prod"
input_types                           = ["float", "bigint"]
input_count                           = 2
configuration_parameter_positions     = [1]
accumulates_no_per_row_value          = false
output_type                           = "float"
returns_a_structure                   = false
declares_init                         = true
declares_accumulate                   = true
declares_compute_result               = true
declares_all_three_aggregate_members  = true
script_length                         = 178
script_sha256                         = "a020f6e7dacf0d6790bb57c5239a131c25139acafb3186a2c0478f2f1d937eff"
force_new_fields                      = ["name", "stream_analytics_job_id"]

πŸ” Troubleshooting

Symptom Cause Fix
The aggregate applied but returns nothing sensible The script is a plain function body rather than an object with three members Check declares_all_three_aggregate_members; rewrite as init / accumulate / computeResult
Invalid value for variable ... contains an underscore This resource's name rule is stricter than the scalar function's Use hyphens; adopt the stricter rule for both kinds
Invalid value for variable ... does not look like an Azure Resource ID A bare job name was passed This resource takes the job's id; the scalar twin takes the name
The same aggregate computes the same value for every window Every parameter is a configuration parameter Check accumulates_no_per_row_value; at least one input must vary per row
Reordering the input list changed behaviour Arguments bind positionally Restore the order; input_types shows it
The query cannot find the aggregate The name in the query does not match name They must agree; the query lives on the job and is not managed here
A repository-wide reformat rewrote every aggregate The script has no difference suppressor Expected; reformat deliberately
input must declare at least one parameter An empty list was passed The provider requires one; an aggregate with no input has nothing to aggregate
Two aggregates collide on create One of that name already exists Import it, or pick another name; the guard can be disabled by a features {} toggle
terraform destroy succeeded on an aggregate already deleted in the portal Delete tolerates a not-found response Expected; a clean destroy is not proof this configuration performed it

πŸ”— Related Docs


πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."