Skip to content

Commit b495951

Browse files
authored
Add CI check to scan Traffic Policy code and fix borked snippets (#1992)
https://linear.app/ngrok/issue/DOC-427/expand-ci-checks-to-scan-traffic-policy-code-snippets
1 parent d19a1e6 commit b495951

13 files changed

Lines changed: 219 additions & 44 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: test-policies
2+
on:
3+
workflow_dispatch: {}
4+
pull_request:
5+
types: [opened, synchronize, reopened]
6+
push:
7+
branches:
8+
- main
9+
jobs:
10+
test-policies:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
with:
15+
ref: ${{ github.ref }}
16+
- name: Install Python dependencies
17+
run: pip install PyYAML
18+
- name: Validate policies
19+
run: ./scripts/test-policies.sh

scripts/test-policies.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/bin/bash
2+
# Validates traffic policy YAML/JSON in .mdx/.md code blocks. No temp files.
3+
# Reports failures as source path (block N). Usage: ./test-policies.sh
4+
set -e
5+
cd "$(dirname "$0")/.."
6+
exec python3 scripts/validate_policy_snippets.py
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Validate traffic policy YAML/JSON in code blocks under traffic-policy docs.
4+
Reads source files only; no temp files. Reports failures as source path (block N).
5+
"""
6+
import json
7+
import re
8+
import sys
9+
from pathlib import Path
10+
11+
try:
12+
import yaml
13+
except ImportError:
14+
print("PyYAML required: pip install PyYAML", file=sys.stderr)
15+
sys.exit(2)
16+
17+
ROOT = Path(__file__).resolve().parent.parent
18+
SEARCH_DIRS = [
19+
ROOT / "traffic-policy",
20+
ROOT / "snippets" / "traffic-policy",
21+
ROOT / "universal-gateway",
22+
]
23+
POLICY_KEYS = ("on_http_request", "on_http_response", "on_tcp_connect")
24+
25+
26+
def find_source_files():
27+
out = []
28+
for d in SEARCH_DIRS:
29+
if not d.is_dir():
30+
continue
31+
for p in d.rglob("*"):
32+
if p.suffix in (".mdx", ".md"):
33+
out.append(p)
34+
return sorted(out)
35+
36+
37+
def extract_blocks(path):
38+
"""Yield (is_json, content) for each policy-like code block. Line-by-line to avoid regex cross-block bugs."""
39+
lines = path.read_text().splitlines()
40+
i = 0
41+
while i < len(lines):
42+
line = lines[i]
43+
if line.strip().startswith("```"):
44+
rest = line.strip()[3:].strip().lower()
45+
if rest.startswith("yaml"):
46+
kind = "yaml"
47+
elif rest.startswith("json"):
48+
kind = "json"
49+
else:
50+
i += 1
51+
continue
52+
if "skip-validation" in rest or "skip_validation" in rest:
53+
i += 1
54+
while i < len(lines) and lines[i].strip() != "```":
55+
i += 1
56+
if i < len(lines):
57+
i += 1
58+
continue
59+
i += 1
60+
block = []
61+
while i < len(lines) and lines[i].strip() != "```":
62+
block.append(lines[i])
63+
i += 1
64+
if i < len(lines):
65+
i += 1 # consume closing ```
66+
content = "\n".join(block)
67+
if any(k in content for k in POLICY_KEYS):
68+
yield ("json" if kind == "json" else "yaml", content)
69+
continue
70+
i += 1
71+
72+
73+
def validate_block(content, is_json):
74+
# is_json is the string "json" or "yaml" from extractor
75+
# Infer format from content so we never parse YAML as JSON
76+
raw = content.strip()
77+
if raw.startswith("{"):
78+
is_json = True
79+
elif raw.startswith("on_") or "\non_" in "\n" + raw:
80+
is_json = False
81+
else:
82+
is_json = is_json == "json"
83+
if is_json:
84+
try:
85+
d = json.loads(content)
86+
except json.JSONDecodeError as e:
87+
return str(e)
88+
else:
89+
try:
90+
d = yaml.safe_load(content)
91+
except yaml.YAMLError as e:
92+
return str(e).split("\n")[0]
93+
if d is None:
94+
return "empty document"
95+
if not isinstance(d, dict):
96+
return "root must be an object"
97+
if not any(k in d for k in POLICY_KEYS):
98+
# Allow agent config format: endpoints: [ { traffic_policy: { on_http_request: ... } } ]
99+
if "endpoints" in d and isinstance(d["endpoints"], list):
100+
for ep in d["endpoints"]:
101+
if isinstance(ep, dict) and "traffic_policy" in ep:
102+
tp = ep["traffic_policy"]
103+
if isinstance(tp, dict) and any(k in tp for k in POLICY_KEYS):
104+
return None
105+
# Allow API request body: { "traffic_policy": "{ \"on_http_request\": ... }", "bindings": ..., "type": "cloud" }
106+
if isinstance(d.get("traffic_policy"), str) and d.get("type") == "cloud":
107+
try:
108+
inner = json.loads(d["traffic_policy"])
109+
if isinstance(inner, dict) and any(k in inner for k in POLICY_KEYS):
110+
return None
111+
except (json.JSONDecodeError, TypeError):
112+
pass
113+
return "missing policy key (need one of: " + ", ".join(POLICY_KEYS) + ")"
114+
return None
115+
116+
117+
def main():
118+
sources = find_source_files()
119+
if not sources:
120+
print("No source files found.")
121+
return 0
122+
123+
total = 0
124+
passed = 0
125+
failed = 0
126+
for path in sources:
127+
rel = path.relative_to(ROOT)
128+
for block_idx, (is_json, content) in enumerate(extract_blocks(path), 1):
129+
total += 1
130+
err = validate_block(content, is_json)
131+
if err is None:
132+
passed += 1
133+
print(f"✅ {rel} (block {block_idx})")
134+
else:
135+
failed += 1
136+
print(f"❌ {rel} (block {block_idx}): {err}")
137+
138+
print("")
139+
print("==========================================")
140+
print("SUMMARY")
141+
print("==========================================")
142+
print(f"Total: {total} | Passed: {passed} | Failed: {failed}")
143+
if failed:
144+
print("❌ Some blocks invalid.")
145+
return 1
146+
print("🎉 All valid.")
147+
return 0
148+
149+
150+
if __name__ == "__main__":
151+
sys.exit(main())

snippets/traffic-policy/gallery/route-requests/BasedOnUrl.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
on_http_request:
66
- name: "Route requests based on URL"
77
actions:
8-
- type: "forward-internal"
9-
config:
10-
url: "https://${req.host.split(".example.com")[0]}.internal"
8+
- type: "forward-internal"
9+
config:
10+
url: "https://${req.host.split(\".example.com\")[0]}.internal"
1111
```
1212
```json policy.json
1313
{

traffic-policy/actions/add-headers.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ configuration will add headers to the response from the upstream service when th
137137
```yaml policy.yml highlight={3}
138138
on_http_response:
139139
- expressions:
140-
- "req.method == "GET" && req.url.path.startsWith("/status/200")"
140+
- "req.method == \"GET\" && req.url.path.startsWith(\"/status/200\")"
141141
actions:
142142
- type: "add-headers"
143143
config:

traffic-policy/actions/owasp-crs-request.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,8 @@ on_http_request:
234234
"matched_rules_first_data": "${actions.ngrok.owasp_crs_request.matched_rules[0].data}"
235235
}
236236
}
237-
]
238237
}
238+
]
239239
}
240240
]
241241
}

traffic-policy/actions/redirect.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ on_http_request:
118118
actions:
119119
- type: redirect
120120
config:
121-
from: /api/v1/
121+
from: /api/v1/
122122
to: /api/v2/
123123
```
124124

traffic-policy/concepts/expressions.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ You must wrap expressions containing `!` in quotes for them to be parsed correct
8989
```yaml
9090
on_tcp_connect:
9191
- expressions:
92-
- !(conn.client_ip in ['2001:4860:7:f0e::f4', '98.35.32.113'])
92+
- "!(conn.client_ip in ['2001:4860:7:f0e::f4', '98.35.32.113'])"
9393
actions:
9494
- type: deny
9595
```

traffic-policy/examples/block-unwanted-requests.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ on_http_request:
129129
- type: custom-response
130130
config:
131131
status_code: 200
132-
body: User-agent: ChatGPT-User\r\nDisallow: /
132+
body: "User-agent: ChatGPT-User\r\nDisallow: /"
133133
headers:
134134
content-type: text/plain
135135
```
@@ -259,7 +259,7 @@ This rule sends a custom response with status code `401` and body `Unauthorized`
259259
```yaml policy.yml
260260
on_http_request:
261261
- expressions:
262-
- !('authorization' in req.headers)
262+
- "!('authorization' in req.headers)"
263263
actions:
264264
- type: custom-response
265265
config:
@@ -308,7 +308,7 @@ on_http_request:
308308
- type: custom-response
309309
config:
310310
status_code: 401
311-
body: Unauthorized request due to country of origin.
311+
body: "Unauthorized request due to country of origin."
312312
```
313313
314314
```json policy.json
@@ -355,7 +355,7 @@ on_http_request:
355355
- type: custom-response
356356
config:
357357
status_code: 400
358-
body: Error: You can't upload content larger than 1MB.
358+
body: "Error: You can't upload content larger than 1MB."
359359
```
360360
361361
```json policy.json
@@ -392,7 +392,7 @@ In this example, the Algolia web crawler is exempted from any rate limiting conf
392392
```yaml policy.yml
393393
on_http_request:
394394
- expressions:
395-
- !('com.algolia.crawler' in conn.client_ip.categories)
395+
- "!('com.algolia.crawler' in conn.client_ip.categories)"
396396
actions:
397397
- type: rate-limit
398398
config:

traffic-policy/getting-started/agent-endpoints/config-file.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ endpoints:
3333
- type: custom-response
3434
config:
3535
status_code: 200
36-
body: Hello, World!
36+
body: "Hello, World!"
3737
```
3838
3939
This policy will respond to each HTTP request with “Hello, World!"

0 commit comments

Comments
 (0)