mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 06:59:05 +00:00
fix(deploy): inspect HCP plan redirect and nested unknowns
Stop auto-following the json-output 307 so the guard can pin archivist.terraform.io, and fail closed when tags or settings change under nested after_unknown values.
This commit is contained in:
parent
93acfa492f
commit
79e93defed
3 changed files with 183 additions and 6 deletions
|
|
@ -32,10 +32,41 @@ PLAN_ID_RE = re.compile(r"^plan-[A-Za-z0-9]+$")
|
||||||
VERSION_LABEL_RE = re.compile(r"^[0-9a-f]{40}-[0-9]+-[0-9]+$")
|
VERSION_LABEL_RE = re.compile(r"^[0-9a-f]{40}-[0-9]+-[0-9]+$")
|
||||||
IGNORED_ACTIONS = {"no-op", "read"}
|
IGNORED_ACTIONS = {"no-op", "read"}
|
||||||
UNSAFE_ACTIONS = {"create", "delete"}
|
UNSAFE_ACTIONS = {"create", "delete"}
|
||||||
|
# Wholly unknown computed attributes may be ignored. Nested unknowns on any
|
||||||
|
# other attribute are treated as changes so the version-only guard fails closed.
|
||||||
|
COMPUTED_UNKNOWN_ATTRIBUTES = frozenset({"instances", "load_balancers"})
|
||||||
|
REDIRECT_STATUSES = {301, 302, 303, 307, 308}
|
||||||
|
|
||||||
UrlOpen = Callable[..., Any]
|
UrlOpen = Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
|
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
|
"""Return the redirect response instead of following it."""
|
||||||
|
|
||||||
|
def http_error_301(self, req, fp, code, msg, headers):
|
||||||
|
return self._capture(req, fp, code, headers)
|
||||||
|
|
||||||
|
http_error_302 = http_error_303 = http_error_307 = http_error_308 = http_error_301
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _capture(req, fp, code, headers):
|
||||||
|
response = urllib.response.addinfourl(fp, headers, req.full_url, code=code)
|
||||||
|
response.msg = "Redirect"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _urlopen_without_redirects(
|
||||||
|
*handlers: urllib.request.BaseHandler,
|
||||||
|
) -> UrlOpen:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPSHandler(context=context),
|
||||||
|
_NoRedirectHandler,
|
||||||
|
*handlers,
|
||||||
|
)
|
||||||
|
return opener.open
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
source = parser.add_mutually_exclusive_group(required=True)
|
source = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
|
@ -67,13 +98,14 @@ def download_plan_json(
|
||||||
token: str,
|
token: str,
|
||||||
*,
|
*,
|
||||||
urlopen: UrlOpen | None = None,
|
urlopen: UrlOpen | None = None,
|
||||||
|
handlers: tuple[urllib.request.BaseHandler, ...] = (),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not PLAN_ID_RE.fullmatch(plan_id):
|
if not PLAN_ID_RE.fullmatch(plan_id):
|
||||||
raise ValueError(f"plan id {plan_id!r} is not a valid HCP plan id")
|
raise ValueError(f"plan id {plan_id!r} is not a valid HCP plan id")
|
||||||
if not token:
|
if not token:
|
||||||
raise ValueError("TF_API_TOKEN is required to download plan JSON")
|
raise ValueError("TF_API_TOKEN is required to download plan JSON")
|
||||||
|
|
||||||
opener = urlopen or urllib.request.urlopen
|
opener = urlopen or _urlopen_without_redirects(*handlers)
|
||||||
api_url = f"https://{API_HOST}/api/v2/plans/{plan_id}/json-output"
|
api_url = f"https://{API_HOST}/api/v2/plans/{plan_id}/json-output"
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
api_url,
|
api_url,
|
||||||
|
|
@ -90,7 +122,7 @@ def download_plan_json(
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"plan JSON is not ready; refusing to poll the plans endpoint"
|
"plan JSON is not ready; refusing to poll the plans endpoint"
|
||||||
)
|
)
|
||||||
if first.status not in {301, 302, 303, 307, 308}:
|
if first.status not in REDIRECT_STATUSES:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"expected a redirect from {API_HOST}, got HTTP {first.status}"
|
f"expected a redirect from {API_HOST}, got HTTP {first.status}"
|
||||||
)
|
)
|
||||||
|
|
@ -106,7 +138,7 @@ def download_plan_json(
|
||||||
archive_request = urllib.request.Request(location, method="GET")
|
archive_request = urllib.request.Request(location, method="GET")
|
||||||
second = _open_pinned(opener, archive_request, allowed_host=ARCHIVE_HOST)
|
second = _open_pinned(opener, archive_request, allowed_host=ARCHIVE_HOST)
|
||||||
try:
|
try:
|
||||||
if second.status in {301, 302, 303, 307, 308}:
|
if second.status in REDIRECT_STATUSES:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"refusing a second redirect from {ARCHIVE_HOST}"
|
f"refusing a second redirect from {ARCHIVE_HOST}"
|
||||||
)
|
)
|
||||||
|
|
@ -141,6 +173,14 @@ def _open_pinned(urlopen: UrlOpen, request: urllib.request.Request, *, allowed_h
|
||||||
return urlopen(request, timeout=30)
|
return urlopen(request, timeout=30)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_nested_unknown(value: Any) -> bool:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return any(item is True or _is_nested_unknown(item) for item in value.values())
|
||||||
|
if isinstance(value, list):
|
||||||
|
return any(item is True or _is_nested_unknown(item) for item in value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def changed_attributes(change: dict[str, Any]) -> set[str]:
|
def changed_attributes(change: dict[str, Any]) -> set[str]:
|
||||||
before = change.get("before") or {}
|
before = change.get("before") or {}
|
||||||
after = change.get("after") or {}
|
after = change.get("after") or {}
|
||||||
|
|
@ -149,9 +189,13 @@ def changed_attributes(change: dict[str, Any]) -> set[str]:
|
||||||
changed: set[str] = set()
|
changed: set[str] = set()
|
||||||
for key in keys:
|
for key in keys:
|
||||||
unknown_value = unknown.get(key)
|
unknown_value = unknown.get(key)
|
||||||
if unknown_value is True or (
|
if unknown_value is True:
|
||||||
isinstance(unknown_value, (dict, list)) and unknown_value
|
if key in COMPUTED_UNKNOWN_ATTRIBUTES:
|
||||||
):
|
continue
|
||||||
|
changed.add(key)
|
||||||
|
continue
|
||||||
|
if _is_nested_unknown(unknown_value):
|
||||||
|
changed.add(key)
|
||||||
continue
|
continue
|
||||||
if before.get(key) != after.get(key):
|
if before.get(key) != after.get(key):
|
||||||
changed.add(key)
|
changed.add(key)
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import io
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from email.message import EmailMessage
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.request import Request
|
from urllib.request import Request
|
||||||
|
|
||||||
|
|
@ -157,11 +160,100 @@ def test_download_pinning() -> list[str]:
|
||||||
return failures
|
return failures
|
||||||
|
|
||||||
|
|
||||||
|
def _scripted_https_handler(fixture: bytes, archive_url: str):
|
||||||
|
calls: list[str] = []
|
||||||
|
api_prefix = "https://app.terraform.io/api/v2/plans/"
|
||||||
|
|
||||||
|
class ScriptedHTTPSHandler(urllib.request.BaseHandler):
|
||||||
|
handler_order = 100
|
||||||
|
|
||||||
|
def https_open(self, req: Request):
|
||||||
|
url = req.full_url
|
||||||
|
calls.append(url)
|
||||||
|
headers = EmailMessage()
|
||||||
|
if url.startswith(api_prefix):
|
||||||
|
headers["Location"] = archive_url
|
||||||
|
body = b""
|
||||||
|
status = 307
|
||||||
|
msg = "Temporary Redirect"
|
||||||
|
elif url == archive_url:
|
||||||
|
body = fixture
|
||||||
|
status = 200
|
||||||
|
msg = "OK"
|
||||||
|
else:
|
||||||
|
raise AssertionError(f"unexpected URL {url}")
|
||||||
|
response = urllib.response.addinfourl(
|
||||||
|
io.BytesIO(body),
|
||||||
|
headers,
|
||||||
|
url,
|
||||||
|
code=status,
|
||||||
|
)
|
||||||
|
response.msg = msg
|
||||||
|
return response
|
||||||
|
|
||||||
|
return ScriptedHTTPSHandler(), calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_standard_opener_redirect() -> list[str]:
|
||||||
|
"""urllib follows the HCP 307; the guard must still inspect that first hop."""
|
||||||
|
module = load_check_module()
|
||||||
|
fixture = (FIXTURES / "version-only.json").read_bytes()
|
||||||
|
archive_url = "https://archivist.terraform.io/v1/object/example"
|
||||||
|
api_url = f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output"
|
||||||
|
failures: list[str] = []
|
||||||
|
|
||||||
|
following_handler, following_calls = _scripted_https_handler(fixture, archive_url)
|
||||||
|
followed = urllib.request.build_opener(following_handler).open(api_url)
|
||||||
|
try:
|
||||||
|
if followed.status != 200:
|
||||||
|
failures.append(
|
||||||
|
f"standard opener first status was {followed.status}, not 200"
|
||||||
|
)
|
||||||
|
if following_calls != [api_url, archive_url]:
|
||||||
|
failures.append(f"standard opener URLs were {following_calls}")
|
||||||
|
finally:
|
||||||
|
followed.close()
|
||||||
|
|
||||||
|
guard_handler, guard_calls = _scripted_https_handler(fixture, archive_url)
|
||||||
|
try:
|
||||||
|
plan = module.download_plan_json(
|
||||||
|
PLAN_ID,
|
||||||
|
"test-token",
|
||||||
|
handlers=(guard_handler,),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
failures.append(f"no-redirect download failed: {exc}")
|
||||||
|
return failures
|
||||||
|
|
||||||
|
if plan["resource_changes"][1]["address"] != (
|
||||||
|
"module.environment.aws_elastic_beanstalk_environment.this"
|
||||||
|
):
|
||||||
|
failures.append("no-redirect download did not return the version-only fixture")
|
||||||
|
if guard_calls != [api_url, archive_url]:
|
||||||
|
failures.append(f"no-redirect download URLs were {guard_calls}")
|
||||||
|
|
||||||
|
following_urlopen_handler, _ = _scripted_https_handler(fixture, archive_url)
|
||||||
|
following_urlopen = urllib.request.build_opener(following_urlopen_handler).open
|
||||||
|
try:
|
||||||
|
module.download_plan_json(
|
||||||
|
PLAN_ID,
|
||||||
|
"test-token",
|
||||||
|
urlopen=following_urlopen,
|
||||||
|
)
|
||||||
|
failures.append("redirect-following urlopen was accepted as the first hop")
|
||||||
|
except ValueError as exc:
|
||||||
|
if "expected a redirect" not in str(exc):
|
||||||
|
failures.append(f"following urlopen error was {exc}")
|
||||||
|
|
||||||
|
return failures
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
cases = [
|
cases = [
|
||||||
("version-only", run_case("version-only.json"), 0),
|
("version-only", run_case("version-only.json"), 0),
|
||||||
("wrong-label", run_case("wrong-label.json"), 1),
|
("wrong-label", run_case("wrong-label.json"), 1),
|
||||||
("eb-setting-change", run_case("eb-setting-change.json"), 1),
|
("eb-setting-change", run_case("eb-setting-change.json"), 1),
|
||||||
|
("nested-unknown-tags", run_case("nested-unknown-tags.json"), 1),
|
||||||
("iam-update", run_case("iam-update.json"), 1),
|
("iam-update", run_case("iam-update.json"), 1),
|
||||||
("dns-update", run_case("dns-update.json"), 1),
|
("dns-update", run_case("dns-update.json"), 1),
|
||||||
("create", run_case("create.json"), 1),
|
("create", run_case("create.json"), 1),
|
||||||
|
|
@ -176,6 +268,8 @@ def main() -> int:
|
||||||
if result.returncode != expected
|
if result.returncode != expected
|
||||||
]
|
]
|
||||||
download_failures = test_download_pinning()
|
download_failures = test_download_pinning()
|
||||||
|
redirect_failures = test_download_standard_opener_redirect()
|
||||||
|
download_failures.extend(redirect_failures)
|
||||||
if failures or download_failures:
|
if failures or download_failures:
|
||||||
if failures:
|
if failures:
|
||||||
print(
|
print(
|
||||||
|
|
|
||||||
39
scripts/testdata/terraform-release-plans/nested-unknown-tags.json
vendored
Normal file
39
scripts/testdata/terraform-release-plans/nested-unknown-tags.json
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"resource_changes": [
|
||||||
|
{
|
||||||
|
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||||
|
"mode": "managed",
|
||||||
|
"type": "aws_elastic_beanstalk_environment",
|
||||||
|
"change": {
|
||||||
|
"actions": ["update"],
|
||||||
|
"before": {
|
||||||
|
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||||
|
"setting": [
|
||||||
|
{
|
||||||
|
"namespace": "aws:elasticbeanstalk:environment",
|
||||||
|
"name": "EnvironmentType",
|
||||||
|
"value": "LoadBalanced"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": { "env": "dev", "project": "shoc" }
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||||
|
"setting": [
|
||||||
|
{
|
||||||
|
"namespace": "aws:elasticbeanstalk:environment",
|
||||||
|
"name": "EnvironmentType",
|
||||||
|
"value": "LoadBalanced"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": { "env": "prod", "project": "shoc" }
|
||||||
|
},
|
||||||
|
"after_unknown": {
|
||||||
|
"instances": true,
|
||||||
|
"load_balancers": true,
|
||||||
|
"tags": { "env": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue