Skip to content

Commit 4d1fab5

Browse files
committed
Add TFLint and live STACKIT flavor validation
Closes #3
1 parent 4d8d927 commit 4d1fab5

7 files changed

Lines changed: 309 additions & 0 deletions

File tree

.github/workflows/tflint.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: tflint
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
jobs:
10+
tflint:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
16+
- name: Setup Terraform
17+
uses: hashicorp/setup-terraform@v3
18+
with:
19+
terraform_version: 1.9.8
20+
21+
- name: Setup TFLint
22+
uses: terraform-linters/setup-tflint@v4
23+
with:
24+
tflint_version: latest
25+
26+
- name: Show version
27+
run: tflint --version
28+
29+
- name: Init TFLint
30+
run: tflint --init
31+
32+
- name: Run TFLint
33+
run: tflint --recursive --format compact
34+
35+
- name: Validate STACKIT flavors (live)
36+
run: python3 scripts/validate_stackit_flavors.py

.tflint.hcl

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
plugin "terraform" {
2+
enabled = true
3+
preset = "recommended"
4+
}
5+
6+
config {
7+
module = true
8+
}
9+
10+
rule "terraform_required_providers" {
11+
enabled = true
12+
}
13+
14+
rule "terraform_required_version" {
15+
enabled = true
16+
}
17+
18+
rule "terraform_unused_declarations" {
19+
enabled = true
20+
}

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,36 @@ terraform apply
5757
- [Getting Started](docs/getting-started.md)
5858
- [Deployment Guide](docs/deployment-guide.md)
5959

60+
## 🔍 Linting (TFLint)
61+
62+
The repository includes automated Terraform linting with `tflint` in GitHub Actions.
63+
64+
- Workflow: `.github/workflows/tflint.yml`
65+
- Config: `.tflint.hcl`
66+
67+
Run locally:
68+
69+
```bash
70+
tflint --init
71+
tflint --recursive
72+
```
73+
74+
Additionally, Terraform variable validations enforce flavor naming patterns for:
75+
76+
- `modules/connectivity-regional` (`firewall_flavor`)
77+
- `modules/devops` (`git_flavor`)
78+
- `modules/landing-zone` (`kubernetes_clusters[*].node_pools[*].machine_type`)
79+
80+
Use `stackit server machine-type list` and the STACKIT Git API docs to verify currently available flavors.
81+
82+
For live validation against current STACKIT SKUs, CI also runs:
83+
84+
```bash
85+
python3 scripts/validate_stackit_flavors.py
86+
```
87+
88+
By default it uses `https://pim.api.stackit.cloud/v1/skus` and fails if a configured flavor is not currently available.
89+
6090
## 🤝 Contributing
6191

6292
Contributions are welcome! Please feel free to submit a Pull Request.

modules/connectivity-regional/variables.tf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ variable "firewall_flavor" {
7575
type = string
7676
description = "Firewall VM Flavor"
7777
default = "c1.2"
78+
79+
validation {
80+
condition = can(regex("^[a-z][0-9]+\\.[0-9]+$", var.firewall_flavor))
81+
error_message = "firewall_flavor must match STACKIT machine type format (e.g. c1.2). Validate available flavors with: stackit server machine-type list"
82+
}
7883
}
7984

8085
variable "vnet_range" {

modules/devops/variables.tf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ variable "git_flavor" {
6161
type = string
6262
description = "The flavor of the Git instance."
6363
default = null # "git-100", git-10
64+
65+
validation {
66+
condition = var.git_flavor == null || can(regex("^git-[0-9]+$", var.git_flavor))
67+
error_message = "git_flavor must match STACKIT Git flavor format (e.g. git-10 or git-100). Validate available flavors in the STACKIT Git API documentation."
68+
}
6469
}
6570

6671
variable "allowed_network_ranges" {

modules/landing-zone/variables.tf

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ variable "kubernetes_clusters" {
105105
}))
106106
description = "Map of Kubernetes clusters to create. The key is used as a suffix for the cluster name."
107107
default = {}
108+
109+
validation {
110+
condition = alltrue(flatten([
111+
for cluster in values(var.kubernetes_clusters) : [
112+
for node_pool in cluster.node_pools : can(regex("^[a-z][0-9]+\\.[0-9]+$", node_pool.machine_type))
113+
]
114+
]))
115+
error_message = "Each node_pools[*].machine_type must match STACKIT machine type format (e.g. c1.2). Validate available flavors with: stackit server machine-type list"
116+
}
108117
}
109118

110119
variable "custom_roles" {
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
#!/usr/bin/env python3
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
import re
8+
import sys
9+
import time
10+
from pathlib import Path
11+
from typing import Iterable
12+
from urllib.error import URLError, HTTPError
13+
from urllib.request import Request, urlopen
14+
15+
16+
REPO_ROOT = Path(__file__).resolve().parents[1]
17+
DEFAULT_API_URL = "https://pim.api.stackit.cloud/v1/skus"
18+
API_TIMEOUT_SECONDS = 45
19+
API_RETRIES = 3
20+
21+
22+
def fetch_json(url: str) -> dict:
23+
last_error: Exception | None = None
24+
headers = {"User-Agent": "stackit-landing-zone-flavor-validator/1.0"}
25+
26+
for attempt in range(1, API_RETRIES + 1):
27+
try:
28+
request = Request(url, headers=headers)
29+
with urlopen(request, timeout=API_TIMEOUT_SECONDS) as response:
30+
if response.status != 200:
31+
raise RuntimeError(f"Unexpected HTTP status {response.status} from {url}")
32+
payload = response.read().decode("utf-8")
33+
return json.loads(payload)
34+
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, RuntimeError) as error:
35+
last_error = error
36+
if attempt < API_RETRIES:
37+
time.sleep(attempt)
38+
39+
raise RuntimeError(f"Failed to fetch STACKIT SKUs from {url}: {last_error}")
40+
41+
42+
def extract_live_flavors(payload: dict) -> tuple[set[str], set[str]]:
43+
server_flavors: set[str] = set()
44+
git_flavors: set[str] = set()
45+
46+
if "services" in payload:
47+
items = payload.get("services", [])
48+
for item in items:
49+
if not isinstance(item, dict):
50+
continue
51+
52+
product = str(item.get("product") or "")
53+
deprecated = str(item.get("deprecated") or "")
54+
if deprecated.lower() == "yes":
55+
continue
56+
57+
attributes = item.get("attributes")
58+
if not isinstance(attributes, dict):
59+
attributes = {}
60+
61+
if product == "Server":
62+
flavor = attributes.get("flavor")
63+
if isinstance(flavor, str) and flavor.strip():
64+
server_flavors.add(flavor.strip())
65+
66+
if product == "Git":
67+
name = str(item.get("name") or "")
68+
match = re.match(r"^Git-(\d+)-", name)
69+
if match:
70+
git_flavors.add(f"git-{match.group(1)}")
71+
72+
elif "data" in payload:
73+
items = payload.get("data", [])
74+
for item in items:
75+
if not isinstance(item, dict):
76+
continue
77+
78+
product = str(item.get("productName") or "")
79+
deprecated = str(item.get("deprecated") or "")
80+
if deprecated.lower() == "yes":
81+
continue
82+
83+
attributes = item.get("productSpecificAttributes")
84+
if not isinstance(attributes, dict):
85+
attributes = {}
86+
87+
if product == "Server":
88+
flavor = attributes.get("flavor")
89+
if isinstance(flavor, str) and flavor.strip():
90+
server_flavors.add(flavor.strip())
91+
92+
if product == "Git":
93+
name = str(item.get("name") or "")
94+
match = re.match(r"^Git-(\d+)-", name)
95+
if match:
96+
git_flavors.add(f"git-{match.group(1)}")
97+
else:
98+
raise RuntimeError("Unsupported SKU API response format: expected 'services' or 'data'.")
99+
100+
if not server_flavors:
101+
raise RuntimeError("No live server flavors found in SKU API response.")
102+
if not git_flavors:
103+
raise RuntimeError("No live git flavors found in SKU API response.")
104+
105+
return server_flavors, git_flavors
106+
107+
108+
def iter_tf_files(root: Path) -> Iterable[Path]:
109+
for path in root.rglob("*"):
110+
if not path.is_file():
111+
continue
112+
if path.suffix not in {".tf", ".tfvars"}:
113+
continue
114+
if ".terraform" in path.parts:
115+
continue
116+
yield path
117+
118+
119+
def collect_used_flavors(root: Path) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
120+
used_server: list[tuple[str, str]] = []
121+
used_git: list[tuple[str, str]] = []
122+
123+
assignment_server_re = re.compile(r"\b(?:machine_type|firewall_flavor)\s*=\s*\"([^\"]+)\"")
124+
assignment_git_re = re.compile(r"\bgit_flavor\s*=\s*\"([^\"]+)\"")
125+
block_start_re = re.compile(r'^\s*variable\s+"(firewall_flavor|git_flavor)"\s*{\s*$')
126+
default_re = re.compile(r'\bdefault\s*=\s*"([^\"]+)"')
127+
128+
for path in iter_tf_files(root):
129+
rel_path = path.relative_to(root)
130+
in_var_block: str | None = None
131+
132+
with path.open("r", encoding="utf-8") as handle:
133+
for line_number, line in enumerate(handle, start=1):
134+
code = line.split("#", 1)[0].split("//", 1)[0]
135+
136+
start_match = block_start_re.match(code)
137+
if start_match:
138+
in_var_block = start_match.group(1)
139+
140+
for match in assignment_server_re.finditer(code):
141+
used_server.append((match.group(1), f"{rel_path}:{line_number}"))
142+
143+
for match in assignment_git_re.finditer(code):
144+
used_git.append((match.group(1), f"{rel_path}:{line_number}"))
145+
146+
if in_var_block:
147+
default_match = default_re.search(code)
148+
if default_match:
149+
value = default_match.group(1)
150+
if in_var_block == "firewall_flavor":
151+
used_server.append((value, f"{rel_path}:{line_number}"))
152+
elif in_var_block == "git_flavor":
153+
used_git.append((value, f"{rel_path}:{line_number}"))
154+
155+
if in_var_block and "}" in code:
156+
in_var_block = None
157+
158+
return used_server, used_git
159+
160+
161+
def validate(used: list[tuple[str, str]], allowed: set[str], kind: str) -> list[str]:
162+
errors: list[str] = []
163+
for value, location in used:
164+
if value not in allowed:
165+
errors.append(
166+
f"{kind} flavor '{value}' at {location} is not available in live STACKIT SKU API"
167+
)
168+
return errors
169+
170+
171+
def main() -> int:
172+
api_url = os.environ.get("STACKIT_PIM_SKUS_URL", DEFAULT_API_URL)
173+
174+
try:
175+
payload = fetch_json(api_url)
176+
allowed_server, allowed_git = extract_live_flavors(payload)
177+
used_server, used_git = collect_used_flavors(REPO_ROOT)
178+
179+
errors = []
180+
errors.extend(validate(used_server, allowed_server, "server"))
181+
errors.extend(validate(used_git, allowed_git, "git"))
182+
183+
if errors:
184+
print("Live flavor validation failed:")
185+
for error in errors:
186+
print(f"- {error}")
187+
188+
print("\nAllowed server flavor count:", len(allowed_server))
189+
print("Allowed git flavor count:", len(allowed_git))
190+
return 1
191+
192+
print("Live flavor validation succeeded.")
193+
print("Validated server flavors:", len(used_server))
194+
print("Validated git flavors:", len(used_git))
195+
print("Allowed server flavors:", len(allowed_server))
196+
print("Allowed git flavors:", len(allowed_git))
197+
return 0
198+
except Exception as error:
199+
print(f"Live flavor validation error: {error}", file=sys.stderr)
200+
return 2
201+
202+
203+
if __name__ == "__main__":
204+
raise SystemExit(main())

0 commit comments

Comments
 (0)