Interpolation of variable in module index

Hello Guys,

Hitting a strange issue that I wasn’t expecting, working on a Terraform module for API Gateway, and hitting this behaviour :

      "/test-apigw-endpoint" = {
        delete = {
          x-amazon-apigateway-integration = {
            httpMethod           = "POST"
            payloadFormatVersion = "1.0"
            type                 = "AWS_PROXY"
            # TODO: add validation to URI names to avoid getting Terraform circular dependency errors on incorrect name
            uri = module.alias["test-project-TestLambdaName-dev"].lambda_alias_invoke_arn
            #uri = local.test_module_var
          }
        },

works without issues, than trying to pass a variable in the module index causes an issue :

      "/test-apigw-endpoint" = {
        delete = {
          x-amazon-apigateway-integration = {
            httpMethod           = "POST"
            payloadFormatVersion = "1.0"
            type                 = "AWS_PROXY"
            # TODO: add validation to URI names to avoid getting Terraform circular dependency errors on incorrect name
            uri = module.alias["test-project-TestLambdaName-${var.env}"].lambda_alias_invoke_arn
            #uri = local.test_module_var
          }
        },

The values of hardcoded string and variable are identical.
The error I get from this is a circular depedency :

│ Error: Cycle: module.alias.aws_lambda_permission.version_triggers, module.api_gateway.output.execution_arn (expand), module.alias.var.allowed_triggers (expand), module.alias.aws_lambda_permission.qualified_alias_triggers, module.alias (close), local.test_module_var (expand), module.api_gateway.var.openapi_config (expand), module.api_gateway.aws_api_gateway_rest_api.this

I thought this was a separate issue as I am using nested modules and I thought I made a mistake with dependencies but the same works with hardcoded index name and it doesn’t with a variable.

Is variable interpolation not supported in module indexes, or am I missing something else ?

Thanks for you help !

Regards,
Boris

Hi @burizz,

I think unfortunately here you’ve hit a subtle edge case in how Terraform deals with dynamic references to module instances.

To allow for flexible data flow into and out of modules, Terraform treats each individual input variable and each individual output value of a module as a separate node in the dependency graph. This means that it’s possible in principle for one of a module’s input variables to depend on one of its own output values, as long as the two aren’t also connected inside the module and thus creating a dependency cycle.

However, Terraform must build the dependency graph before evaluating any expressions, because a major reason for the dependency graph is to allow Terraform to evaluate all of the expressions in the correct order so that everything an expression uses is already finalized and ready before evaluating that expression.

Terraform does this by performing static analysis of the expressions, extracting from all expressions the static prefixes of any references.

In you example here, before you added the interpolation Terraform’s static analysis was able to find a full output value reference:

module.alias["test-project-TestLambdaName-dev"].lambda_alias_invoke_arn

That means that Terraform can see exactly which output value you are referring to and so can generate a precise dependency edge referring directly to that output value.

Unfortunately when you added ${var.env} in there that was no longer possible. Now the static prefix is just the following:

module.alias

Terraform can no longer determine statically exactly which output value you are referring to because it won’t know the final value of var.env until expression evaluation time, and so it must therefore conservatively depend on the entire result of module.alias – all of its output values across all of its instances – so that it can perform a dynamic lookup of a key from the map value representing module.alias.

Since I can only see one part of your call to this module I’m not sure exactly what’s causing the cycle here, but if you need to use a dynamic lookup like this I think you’ll need to structure things a bit differently so that your entire module calls can behave as a single dependency node in the graph, rather than each individual output value and input variable being considered differently.

If you’re not sure how to proceed from here, I’d be happy to try to give some more specific pointers if you can share the entire source code of your module "alias" and module "api_gateway" blocks, and I think also the local value test_module_var which itself seems to be included in this dependency cycle.

Thanks a lot for the great explanation @apparentlymart. This was indeed the issue and it got me in the right direction, initially I was leaning more towards interpolation problem it turned out to be more of a depedency problem because it was defaulting to create dependency of the whole module.alias instead of the specific lambda instance of it.

Essentially the problem was I had an api-gateway module that depends on an alias module and the alias module had a dependency on the api gateway module.

Mainly because Aliases need “AllowExecutionFromAPIGateway” permissions, for which I need to take the APIGW ID from the API gateway module :

module "alias" {
  for_each = var.lambda_list

...truncated...

  function_name    = each.key
  function_version = module.lambda_function[each.key].lambda_function_version
  allowed_triggers = {
    AllowExecutionFromAPIGateway = {
      service = "apigateway"
      source_arn = "${module.api_gateway.execution_arn}/*/*"
    },
    AllowExecutionFromEventBridge = {
      principal  = "events.amazonaws.com"
      source_arn = aws_cloudwatch_event_rule.this[each.key].arn
    }
  }
}

And api gateway resources need Lambda alias ARN references which we take from the alias module :

    "/test-apigw-endpoint" = {
        delete = {
          x-amazon-apigateway-integration = {
            httpMethod           = "POST"
            payloadFormatVersion = "1.0"
            type                 = "AWS_PROXY"
            uri                  = module.alias["${var.project}-TestLambdaName-${var.env}-poc"].lambda_alias_invoke_arn
          }
        },

At the moment I think I will work around this with a datasource, essentially first create API Gateway, Lambdas, Aliases, etc, than update “AllowExecutionFromAPIGateway” permissions by taking APIGW ID using datasource instead of other module’s output :

data "aws_api_gateway_rest_api" "my_rest_api" {
  name = "${var.project}-backend-${var.env}"
}

... truncated ...

  allowed_triggers = {
    AllowExecutionFromAPIGateway = {
      service = "apigateway"
      source_arn = "arn:aws:execute-api:${var.aws_region}:${data.aws_caller_identity.current.account_id}:${data.aws_api_gateway_rest_api.my_rest_api.id}/*/*"
    },

I can probably do this better, later on, by splitting the lambda permission resources into a separate module. At least I understand how this works a little bit better now. Thanks again.