hashicorp / terraform-provider-assert

Offers functions to validate and assert values within Terraform configurations, simplifying variable validation and custom conditions.
https://registry.terraform.io/providers/hashicorp/assert/latest/docs
Mozilla Public License 2.0
39 stars 9 forks source link

provider::assert::null() not behaving as expected #71

Open dustindortch opened 1 month ago

dustindortch commented 1 month ago

I am doing some compound validation and I will use alltrue(), anytrue(), or sum() in my validation for AND, OR, and XOR operations, respectively.

The following fails validation:

variable "cidr_block" {
  default     = null
  description = "CIDR block for the VPC."
  type        = string
  # ...

  validation {
    condition = sum([
      provider::assert::null(var.cidr_block) ? 0 : 1,
      provider::assert::null(var.ipv4_ipam_pool_id) ? 0 : 1
    ]) == 1 # xor
    error_message = "Exactly one of cidr_block or ipv4_ipam_pool_id must be provided."
  }
}

Results:

terraform plan -out=tfplan

│ Error: Invalid value for variable
│ 
│   on main.tf line 17, in module "vpc":
│   17:   cidr_block = "10.0.42.0/24"
│     ├────────────────
│     │ var.cidr_block is "10.0.42.0/24"
│     │ var.ipv4_ipam_pool_id is a string
│ 
│ Exactly one of cidr_block or ipv4_ipam_pool_id must be provided.

The following validation works as expected:

variable "cidr_block" {
  default     = null
  description = "CIDR block for the VPC."
  type        = string
  # ...

  validation {
    condition = sum([
      var.cidr_block != null ? 1 : 0,
      var.ipv4_ipam_pool_id != null ? 1 : 0
    ]) == 1 # xor
    error_message = "Exactly one of cidr_block or ipv4_ipam_pool_id must be provided."
  }
}

Are these doing some sort of blocking failures? This seems bad if the context would be either inside a check block, which should be non-blocking, or inside of terraform test with an expected failure. It really limits the application.

dustindortch commented 4 weeks ago

Some clarification after investigating further. It only seems like an issue when doing cross-object references in variable validation.

The following fails like before:

variable "cidr_block" {
  default     = null
  description = "CIDR block for the VPC."
  type        = string

  validation {
    condition = anytrue([
      provider::assert::null(var.cidr_block),
      provider::assert::cidr(var.cidr_block)
    ])
    error_message = "CIDR block must be a valid CIDR range."
  }

  validation {
    condition = anytrue([
      alltrue([
        provider::assert::not_null(var.cidr_block),
        provider::assert::null(var.ipv4_ipam_pool_id)
      ]),
      alltrue([
        provider::assert::null(var.cidr_block),
        provider::assert::not_null(var.ipv4_ipam_pool_id)
      ])
    ]) # XOR
    error_message = "Exactly one of cidr_block or ipv4_ipam_pool_id must be provided."
  }
}

However, this small change fixes it:

variable "cidr_block" {
  default     = null
  description = "CIDR block for the VPC."
  type        = string

  validation {
    condition = anytrue([
      provider::assert::null(var.cidr_block),
      provider::assert::cidr(var.cidr_block)
    ])
    error_message = "CIDR block must be a valid CIDR range."
  }

  validation {
    condition = anytrue([
      alltrue([
        provider::assert::not_null(var.cidr_block),
        var.ipv4_ipam_pool_id == null #.................. this is the change
      ]),
      alltrue([
        provider::assert::null(var.cidr_block),
        provider::assert::not_null(var.ipv4_ipam_pool_id)
      ])
    ]) # XOR
    error_message = "Exactly one of cidr_block or ipv4_ipam_pool_id must be provided."
  }
}

So, my thought was that the cross-object reference is receiving the "zero value" from the nulled variable. To validate this, I ran the following which failed, as I was thinking it would, verifying this:

variable "cidr_block" {
  default     = null
  description = "CIDR block for the VPC."
  type        = string

  validation {
    condition = anytrue([
      provider::assert::null(var.cidr_block),
      provider::assert::cidr(var.cidr_block)
    ])
    error_message = "CIDR block must be a valid CIDR range."
  }

  validation {
    condition = alltrue([
      anytrue([
        alltrue([
          provider::assert::not_null(var.cidr_block),
          var.ipv4_ipam_pool_id == null
        ]),
        alltrue([
          provider::assert::null(var.cidr_block),
          provider::assert::not_null(var.ipv4_ipam_pool_id)
        ])
      ]),
      !alltrue([
        provider::assert::not_null(var.cidr_block),
        provider::assert::not_null(var.ipv4_ipam_pool_id)
      ])
    ]) # XOR
    error_message = "Exactly one of cidr_block or ipv4_ipam_pool_id must be provided."
  }
}

So, it appears to be more of a problem with cross-object reference behavior than the provider.

Although, while investigating the code... I am curious why null and not_null aren't more similar, just getting right of the "!" in the like resp.Error = function.ConcatFuncErrors(resp.Result.Set(ctx, !argument.IsNull())) for null.