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
2 changes: 1 addition & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ terrapod-migrate rewrite --state-file migration-state.json --source-dir ~/code/a
against each repo (locally cloned, on the operator's own machine). The
tool walks the directory tree and mechanically rewrites:

- `terraform { cloud { hostname = "app.terraform.io", organization = "acme" ... } }` blocks → Terrapod hostname + `"default"` organization. Both `workspaces { name = "..." }` and `workspaces { tags = [...] }` forms are supported — only `hostname` and `organization` change; the workspace selection inside stays as-is (Terrapod's `tfe_v2` endpoint accepts the same `tags = [...]` syntax and translates internally).
- `terraform { cloud { hostname = "app.terraform.io", organization = "acme" ... } }` blocks → Terrapod hostname + `"default"` organization. Both `workspaces { name = "..." }` and `workspaces { tags = [...] }` forms are supported — only `hostname` and `organization` change; the workspace selection inside stays as-is (Terrapod's `tfe_v2` endpoint accepts the same `tags = [...]` syntax and translates internally). Tags are matched against workspace **labels**: a bare tag (`"core"`) matches any workspace with that label key, and a `key:value` tag (`"repo:tf-aws-core"`) matches that exact label. Use the colon form to select by key+value — OpenTofu rejects the Terraform 1.10+ map form (`tags = { repo = "..." }`), so `tags = ["repo:tf-aws-core"]` is the portable equivalent.
- `terraform { backend "remote" { hostname = "app.terraform.io", organization = "acme" ... } }` blocks → same destination as `cloud {}`.
- `source = "app.terraform.io/acme/<module>"` private-module references → `"<terrapod-host>/default/<module>"`.

Expand Down
2 changes: 1 addition & 1 deletion services/pyproject-listener.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "terrapod-listener"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"cryptography>=46.0.5,<48.0.0",
"cryptography>=48.0.1,<49.0.0",
"httpx>=0.28",
"kubernetes>=35.0",
"prometheus-client>=0.24",
Expand Down
27 changes: 20 additions & 7 deletions services/terrapod/api/routers/tfe_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -736,12 +736,16 @@ def _parse_tag_filters(request: Request) -> list[tuple[str, str | None]]:
The terraform/tofu CLI's `cloud { workspaces { tags = ... } }` block emits two
query-parameter shapes depending on whether `tags` is a list or a map:

- list form `tags = ["core", "env=prod"]`
-> `?search[tags]=core,env=prod`
(each comma-separated token is either a bare key or `key=value`)
- list form `tags = ["core", "env:prod"]`
-> `?search[tags]=core,env:prod`
(each comma-separated token is a bare key, `key:value`, or
`key=value`; OpenTofu emits the colon form for set-of-string
tags since `=` isn't a legal tag character)

- map form `tags = { env = "prod" }`
-> `?filter[tagged][0][key]=env&filter[tagged][0][value]=prod`
(Terraform 1.10+ only; OpenTofu rejects map tags, so the colon
list form above is the portable way to select by key+value)

Terrapod doesn't have a separate "tags" concept on workspaces; instead each
tag is matched against `Workspace.labels` (which is also the source of
Expand All @@ -753,16 +757,25 @@ def _parse_tag_filters(request: Request) -> list[tuple[str, str | None]]:
"""
filters: list[tuple[str, str | None]] = []

# List form: search[tags]=a,b,c=d
# List form: search[tags]=a,b,c=d,e:f
# A token is a bare `key`, or `key=value` / `key:value`. tofu's cloud
# block emits the COLON form for set-of-string tags
# (`tags = ["repo:tf-aws-core"]`) because `=` is not a legal tofu/TFC
# tag character and the map form isn't supported in OpenTofu; the `=`
# form comes from go-tfe / direct API callers. Split on whichever
# separator appears first so both map to an exact key=value label.
raw_tags = request.query_params.get("search[tags]", "")
if raw_tags:
for token in raw_tags.split(","):
token = token.strip()
if not token:
continue
if "=" in token:
k, v = token.split("=", 1)
filters.append((k.strip(), v.strip()))
sep_positions = [token.find(c) for c in (":", "=") if c in token]
if sep_positions:
i = min(sep_positions)
k, v = token[:i].strip(), token[i + 1 :].strip()
if k:
filters.append((k, v))
else:
filters.append((token, None))

Expand Down
28 changes: 28 additions & 0 deletions services/tests/api/test_workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,34 @@ def test_list_form_key_value(self):
out = _parse_tag_filters(self._req("search%5Btags%5D=env=prod,team=platform"))
assert out == [("env", "prod"), ("team", "platform")]

def test_list_form_colon_key_value(self):
from terrapod.api.routers.tfe_v2 import _parse_tag_filters

# OpenTofu's cloud block emits `key:value` for set-of-string tags
# (`tags = ["repo:tf-aws-core"]`) — map tags aren't supported there.
out = _parse_tag_filters(self._req("search%5Btags%5D=repo:tf-aws-core"))
assert out == [("repo", "tf-aws-core")]

def test_list_form_colon_value_with_hyphens(self):
from terrapod.api.routers.tfe_v2 import _parse_tag_filters

# Only the FIRST separator splits — values keep their hyphens/etc.
out = _parse_tag_filters(self._req("search%5Btags%5D=env:us-east-1,team:platform"))
assert out == [("env", "us-east-1"), ("team", "platform")]

def test_list_form_mixed_colon_equals_and_bare(self):
from terrapod.api.routers.tfe_v2 import _parse_tag_filters

# Colon, equals, and bare tokens coexist; earliest separator wins.
out = _parse_tag_filters(self._req("search%5Btags%5D=core,repo:tf-aws-core,env=prod"))
assert out == [("core", None), ("repo", "tf-aws-core"), ("env", "prod")]

def test_list_form_colon_empty_key_skipped(self):
from terrapod.api.routers.tfe_v2 import _parse_tag_filters

out = _parse_tag_filters(self._req("search%5Btags%5D=:orphanvalue,core"))
assert out == [("core", None)]

def test_list_form_mixed(self):
from terrapod.api.routers.tfe_v2 import _parse_tag_filters

Expand Down
Loading