diff --git a/docs/migration.md b/docs/migration.md index 89b26656..6e6e1b9a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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/"` private-module references → `"/default/"`. diff --git a/services/pyproject-listener.toml b/services/pyproject-listener.toml index 389404b5..036d7c0e 100644 --- a/services/pyproject-listener.toml +++ b/services/pyproject-listener.toml @@ -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", diff --git a/services/terrapod/api/routers/tfe_v2.py b/services/terrapod/api/routers/tfe_v2.py index c81e58af..a21bcb3d 100644 --- a/services/terrapod/api/routers/tfe_v2.py +++ b/services/terrapod/api/routers/tfe_v2.py @@ -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 @@ -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)) diff --git a/services/tests/api/test_workspaces.py b/services/tests/api/test_workspaces.py index 2670d04d..998407c9 100644 --- a/services/tests/api/test_workspaces.py +++ b/services/tests/api/test_workspaces.py @@ -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