Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
979002a
fix(terraform): allow juju provider 1.x and 2.x
marceloneppel Jul 14, 2026
8e6f3e1
test(terraform): add terraform test lane (static + deploy)
marceloneppel Jul 14, 2026
af791e7
Merge branch '16/edge' into feature/fix-juju-terraform-provider-versi…
marceloneppel Jul 14, 2026
a61c488
ci(terraform): install terraform via snap instead of setup-terraform …
marceloneppel Jul 14, 2026
844b6f4
docs(terraform): regenerate README for widened juju constraint
marceloneppel Jul 14, 2026
738ca24
test(terraform): make the negative composition case assert its failur…
marceloneppel Jul 14, 2026
15e2908
test(terraform): fail the deploy test fast when the app errors
marceloneppel Jul 14, 2026
ec68406
test(terraform): allow overriding the terraform binary via TF_BINARY
marceloneppel Jul 14, 2026
6f3b286
test(terraform): bound and stream terraform subprocess calls in the d…
marceloneppel Jul 14, 2026
3425b32
test(terraform): give terraform apply the full deploy timeout budget
marceloneppel Jul 14, 2026
4389c0c
test(terraform): deploy for the runner's architecture, not the module…
marceloneppel Jul 15, 2026
10bf04d
test(terraform): match mysql-plans lint env + broaden deploy coverage
marceloneppel Jul 15, 2026
1fdf65a
fix(terraform): capture output stdout in deploy integration test
marceloneppel Jul 15, 2026
f47244c
docs(terraform): correct Usage example variables
marceloneppel Jul 15, 2026
26e1af3
test(terraform): cover the module under the v1 juju provider line
marceloneppel Jul 16, 2026
c6a527f
refactor(terraform): declare the v1 provider leg as a task-local spre…
marceloneppel Jul 16, 2026
0c1a47c
fix(terraform): correct codespell flag in spread task comment
marceloneppel Jul 16, 2026
381249a
refactor(terraform): inline composition-test fixtures, drop conftest
marceloneppel Jul 16, 2026
ae62bc1
docs(terraform): trim spread task variant comment
marceloneppel Jul 16, 2026
358fd1f
chore(terraform): drop unused poetry allowlist from lint-terraform
marceloneppel Jul 16, 2026
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
42 changes: 42 additions & 0 deletions .github/workflows/terraform.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.
name: Terraform

on:
workflow_dispatch:
pull_request:
paths:
- "terraform/**"
- "tests/terraform/**"
- "tests/integration/terraform/**"
- "tox.ini"
- ".github/workflows/terraform.yaml"

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
static:
name: Static checks + constraint compositions
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false

- name: Install Terraform
run: sudo snap install terraform --classic

- name: Install tox & poetry
run: pipx install tox poetry

- name: Terraform lint (fmt/init/validate)
run: tox run -e lint-terraform

- name: Constraint-composition tests
run: tox run -e terraform
4 changes: 4 additions & 0 deletions terraform/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
22 changes: 12 additions & 10 deletions terraform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
| Name | Version |
|------|---------|
| <a name="requirement_terraform"></a> [terraform](#requirement\_terraform) | >= 1.6.6 |
| <a name="requirement_juju"></a> [juju](#requirement\_juju) | ~> 1.0.0 |
| <a name="requirement_juju"></a> [juju](#requirement\_juju) | >= 1.0, < 3.0 |

## Providers

| Name | Version |
|------|---------|
| <a name="provider_juju"></a> [juju](#provider\_juju) | ~> 1.0.0 |
| <a name="provider_juju"></a> [juju](#provider\_juju) | >= 1.0, < 3.0 |

## Modules

Expand Down Expand Up @@ -48,31 +48,33 @@ No modules.

## Usage

Users should ensure that Juju model has been created to deploy into:
Create the Juju model to deploy into, then capture its UUID — the module's
`juju_model` input is the model **UUID**, not its name:
```
juju add-model welcome
MODEL_UUID=$(juju show-model welcome --format json | jq -r '.welcome."model-uuid"')
```

To deploy Charmed PostgreSQL into the model `welcome`, run:
To deploy Charmed PostgreSQL into the model, run:
```
terraform apply -var='juju_model_name=welcome' -auto-approve
terraform apply -var="juju_model=$MODEL_UUID" -auto-approve
```

By default, this Terraform module will deploy PostgreSQL with `1` unit only.
To configure the module to deploy `3` units, run:
```
terraform apply -var='juju_model_name=welcome' -var='units=3' -auto-approve
terraform apply -var="juju_model=$MODEL_UUID" -var='units=3' -auto-approve
```

The juju storage directives config example:
The storage directives example:
```
terraform apply -var='juju_model_name=welcome' -auto-approve \
-var='storage={data="10G", archive="2G,lxd", logs="3G", temp="tmpfs,2G"}'
terraform apply -var="juju_model=$MODEL_UUID" -auto-approve \
-var='storage_directives={data="10G", archive="2G", logs="3G", temp="2G"}'
```

The juju constraints example:
```
terraform apply -var='juju_model_name=welcome' -auto-approve \
terraform apply -var="juju_model=$MODEL_UUID" -auto-approve \
-var='constraints=arch=amd64 cores=4 mem=4096M virt-type=virtual-machine'
```

2 changes: 1 addition & 1 deletion terraform/versions.tf
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ terraform {
required_providers {
juju = {
source = "juju/juju"
version = "~> 1.0"
version = ">= 1.0, < 3.0"
}
}
}
2 changes: 2 additions & 0 deletions tests/integration/terraform/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.
146 changes: 146 additions & 0 deletions tests/integration/terraform/test_terraform_deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.

"""Real-deploy integration test for the postgres terraform module.

Applies the module against the pre-created ``testing`` model (provided by the
spread/concierge substrate via the ``juju`` fixture) and waits for the deployed
``postgresql-k8s`` application to reach active/idle. Drives several module
variables (storage directives, config, the runner's arch) and asserts a module
output, so the deploy test exercises more than the bare-minimum wiring.
Deploys the published charm via the module's default channel.

By default the module is applied directly, so ``terraform init`` resolves the
juju provider per the module's own ``required_providers`` (currently v2.x). To
prove the module also works under the v1 provider line, set
``TF_PROVIDER_CONSTRAINT`` (e.g. ``~> 1.0``): the test then deploys from a
throwaway consumer root that sources the module and adds that juju provider
constraint as a sibling, intersecting it with the module's own — the
downstream-composition scenario the widened constraint exists to support.
"""

import os
import shutil
import subprocess
from pathlib import Path

import jubilant
import pytest

REPO_ROOT = Path(__file__).resolve().parents[3]
TERRAFORM_MODULE = REPO_ROOT / "terraform"
APP = "postgresql-k8s"
TIMEOUT = 20 * 60
# `terraform apply` blocks until the charm's units are created, so give it the deploy budget.
TF_TIMEOUT = 15 * 60
TF_BINARY = os.getenv("TF_BINARY") or "terraform"
# When set (e.g. "~> 1.0"), deploy from a consumer root that pins the juju provider to that
# constraint instead of applying the module directly; unset applies the module as-is (v2.x).
PROVIDER_CONSTRAINT = os.getenv("TF_PROVIDER_CONSTRAINT")
# Storage directives for the postgresql-k8s charm: archive, data, logs, temp.
STORAGE_DIRECTIVES = '{"data"="2G","archive"="1G","logs"="1G","temp"="512M"}'
# A string-typed postgresql-k8s config option (profile) — drives the `config` variable.
CONFIG = '{"profile"="testing"}'


def _arch() -> str:
# Juju's arch name (amd64/arm64) for the runner, so the module deploys for this host's
# architecture instead of its hardcoded `arch=amd64` default (unschedulable on arm64).
return subprocess.run(
["dpkg", "--print-architecture"], capture_output=True, text=True, check=True
).stdout.strip()


def _run_terraform(
cwd: Path, timeout: int, *args: str, capture: bool = False
) -> subprocess.CompletedProcess:
# Bound each call so a stall fails instead of hanging to the spread kill-timeout. Stream
# output (no capture) for the slow init/apply so progress and any stall are visible live;
# capture only when the caller asserts on stdout (terraform output), else `.stdout` is None.
return subprocess.run(
[TF_BINARY, *args],
cwd=str(cwd),
check=True,
timeout=timeout,
capture_output=capture,
text=capture,
)


def _build_consumer_root(root: Path, module: Path, constraint: str) -> None:
# A downstream root module that sources the repo module and pins the juju provider via a
# sibling required_providers constraint; it intersects the module's own, so `terraform init`
# resolves the major the consumer asked for. Forwards the same vars the direct apply passes.
root.mkdir(parents=True, exist_ok=True)
(root / "versions.tf").write_text(
"terraform {\n"
' required_version = ">= 1.6.6"\n'
" required_providers {\n"
" juju = {\n"
' source = "juju/juju"\n'
f' version = "{constraint}"\n'
" }\n"
" }\n"
"}\n"
)
(root / "main.tf").write_text(
'variable "juju_model" { type = string }\n'
'variable "constraints" { type = string }\n'
'variable "storage_directives" { type = map(string) }\n'
'variable "config" { type = map(string) }\n'
'module "postgres" {\n'
f' source = "{module}/"\n'
" juju_model = var.juju_model\n"
" constraints = var.constraints\n"
" storage_directives = var.storage_directives\n"
" config = var.config\n"
"}\n"
'output "application_name" {\n'
" value = module.postgres.application_name\n"
"}\n"
)


def test_terraform_apply_deploys_postgresql(juju: jubilant.Juju, tmp_path: Path) -> None:
"""The module must apply postgresql-k8s with storage/config, reach active/idle, and expose outputs."""
if shutil.which(TF_BINARY) is None:
pytest.skip(f"{TF_BINARY} not found on PATH")

model_uuid = juju.show_model().model_uuid

# Apply the module directly (default, v2.x) or from a consumer root pinning the provider
# major via TF_PROVIDER_CONSTRAINT — same `-var` strings drive both paths.
deploy_dir = tmp_path / "consumer" if PROVIDER_CONSTRAINT else TERRAFORM_MODULE
if PROVIDER_CONSTRAINT:
_build_consumer_root(deploy_dir, TERRAFORM_MODULE, PROVIDER_CONSTRAINT)

_run_terraform(deploy_dir, TF_TIMEOUT, "init", "-input=false")
_run_terraform(
deploy_dir,
TF_TIMEOUT,
"apply",
"-auto-approve",
"-input=false",
"-var",
f"juju_model={model_uuid}",
"-var",
f"constraints=arch={_arch()}",
"-var",
f"storage_directives={STORAGE_DIRECTIVES}",
"-var",
f"config={CONFIG}",
)

juju.wait(
lambda status: jubilant.all_active(status, APP) and jubilant.all_agents_idle(status, APP),
error=lambda status: jubilant.any_error(status, APP),
timeout=TIMEOUT,
)

# The module exposes an `application_name` output; assert it reflects the deployed app.
# capture=True so `.stdout` holds the value instead of streaming to the log.
output = _run_terraform(
deploy_dir, TF_TIMEOUT, "output", "-raw", "application_name", capture=True
)
assert output.stdout.strip() == APP, f"application_name output: {output.stdout!r}"
14 changes: 14 additions & 0 deletions tests/spread/test_terraform_deploy.py/task.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
summary: terraform/test_terraform_deploy.py
environment:
TEST_MODULE: terraform/test_terraform_deploy.py
# tfprov_v1: task-local variant (only this task gains it; others stay juju36). CONCIERGE_JUJU_CHANNEL
# is repeated because this variant doesn't inherit the global juju36-scoped value.
CONCIERGE_JUJU_CHANNEL/tfprov_v1: 3.6/stable
TF_PROVIDER_CONSTRAINT/tfprov_v1: "~> 1.0"
execute: |
# The terraform binary is not part of the concierge substrate; install it for this task only.
sudo snap install terraform --classic

tox run -e integration -- "tests/integration/$TEST_MODULE" --model testing --alluredir="$SPREAD_TASK/allure-results"
artifacts:
- allure-results
2 changes: 2 additions & 0 deletions tests/terraform/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.
96 changes: 96 additions & 0 deletions tests/terraform/test_compositions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.

"""Static constraint-composition tests for the postgres terraform module.

Each case builds a throwaway root module that sources the repo's ``terraform/``
module and declares a sibling ``juju`` provider constraint, then runs
``terraform init`` (provider resolution against the live registry) and asserts
on the resolved provider major version (or expected failure).

This guards the module's ``required_providers`` constraint against the failure
mode where a downstream module pairs postgres with a juju provider constraint
the module's own constraint cannot satisfy.
"""

import os
import re
import shutil
import subprocess
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[2]
TERRAFORM_MODULE = REPO_ROOT / "terraform"
TF_BINARY = os.getenv("TF_BINARY") or "terraform"

pytestmark = pytest.mark.skipif(
shutil.which(TF_BINARY) is None, reason=f"{TF_BINARY} not found on PATH"
)

_INSTALLED_RE = re.compile(r"Installed juju/juju v(\d+)\.(\d+)\.(\d+)")

# (root juju provider constraint, expect init success, resolved provider major)
CASES = [
pytest.param("~> 1.0", True, "1", id="v1_consumer"),
pytest.param(">= 1.1.1", True, "2", id="identity_sibling"),
pytest.param(">= 2.0", True, "2", id="v2_consumer"),
pytest.param("~> 2.0", True, "2", id="v2_pinned"),
pytest.param(">= 3.0", False, None, id="beyond_cap"),
]


def _run_terraform(cwd: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[TF_BINARY, *args],
cwd=str(cwd),
capture_output=True,
text=True,
)


def _write_root(root: Path, module: Path, constraint: str) -> None:
root.mkdir(parents=True, exist_ok=True)
(root / "versions.tf").write_text(
f"terraform {{\n"
f' required_version = ">= 1.6.6"\n'
f" required_providers {{\n"
f" juju = {{\n"
f' source = "juju/juju"\n'
f' version = "{constraint}"\n'
f" }}\n"
f" }}\n"
f"}}\n"
)
(root / "main.tf").write_text(
f'module "postgres" {{\n'
f' source = "{module}/"\n'
f' juju_model = "unused-during-init"\n'
f"}}\n"
)


@pytest.mark.parametrize("constraint,expect_ok,expect_major", CASES)
def test_composition_resolves_provider(
tmp_path: Path, constraint: str, expect_ok: bool, expect_major: str | None
) -> None:
"""Assert the module's constraint composes with a sibling juju constraint."""
root = tmp_path / "root"
_write_root(root, TERRAFORM_MODULE, constraint)

init = _run_terraform(root, "init", "-backend=false", "-input=false")

if expect_ok:
assert init.returncode == 0, f"init failed:\n{init.stderr}{init.stdout}"
match = _INSTALLED_RE.search(init.stdout)
assert match is not None, f"no resolved provider in output:\n{init.stdout}"
assert match.group(1) == expect_major, (
f"resolved {match.group(0)}, expected major {expect_major}"
)
else:
assert init.returncode != 0, f"expected init to fail but it succeeded:\n{init.stdout}"
# Assert the failure reason is an unsatisfiable constraint, not an unrelated error.
assert "no available releases match" in (init.stderr + init.stdout), (
f"init failed for an unexpected reason:\n{init.stderr}{init.stdout}"
)
Loading
Loading