Coverage for scripts / upgrade_validation / sentinel.py: 100.00%

69 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1"""A job template written through the API before the upgrade and read back after it. 

2 

3``gco upgrade`` promises that the control plane's shared state (the DynamoDB 

4tables among it) survives: only the workload tier is destroyed and recreated. 

5The sentinel tests that promise the way an operator would notice it breaking. 

6It is created through the public API while the base release serves it, and 

7after the upgrade the recreated regional stack must return it unchanged, with 

8its original creation time, so the item was neither lost nor rewritten. It is 

9deleted afterwards; the table itself goes with the global stack at teardown. 

10""" 

11 

12from __future__ import annotations 

13 

14import hashlib 

15import time 

16from collections.abc import Callable 

17from typing import Any 

18 

19import requests 

20 

21from cli.aws_client import APIRequestError 

22from scripts.live_release_validation.models import RunContext 

23 

24TEMPLATES_PATH = "/api/v1/templates" 

25#: Statuses that mean the API is not serving yet (or throttled), not an answer. 

26RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504}) 

27RETRY_INTERVAL_SECONDS = 20.0 

28 

29Sleeper = Callable[[float], None] 

30Clock = Callable[[], float] 

31 

32 

33def sentinel_name(run_id: str) -> str: 

34 """A template name unique to the run that fits the API's 63-character limit.""" 

35 return "gco-upgrade-sentinel-" + hashlib.sha256(run_id.encode("utf-8")).hexdigest()[:16] 

36 

37 

38def sentinel_body(run_id: str) -> dict[str, Any]: 

39 """The template the run writes; never submitted as a Job.""" 

40 return { 

41 "name": sentinel_name(run_id), 

42 "description": f"Upgrade-validation sentinel for run {run_id}", 

43 "manifest": { 

44 "apiVersion": "batch/v1", 

45 "kind": "Job", 

46 "metadata": {"name": "{{name}}", "namespace": "gco-jobs"}, 

47 "spec": { 

48 "template": { 

49 "spec": { 

50 "restartPolicy": "Never", 

51 "containers": [ 

52 {"name": "sentinel", "image": "{{image}}", "command": ["true"]} 

53 ], 

54 } 

55 } 

56 }, 

57 }, 

58 "parameters": {"image": "public.ecr.aws/docker/library/busybox:1.38.0"}, 

59 } 

60 

61 

62def _call( 

63 ctx: RunContext, 

64 method: str, 

65 path: str, 

66 *, 

67 deadline: float, 

68 body: dict[str, Any] | None = None, 

69 sleep: Sleeper = time.sleep, 

70 clock: Clock = time.monotonic, 

71) -> dict[str, Any]: 

72 """One API call, retried while the API is not answering yet, until ``deadline``.""" 

73 attempts = 0 

74 while True: 

75 attempts += 1 

76 try: 

77 response: dict[str, Any] = ctx.aws_client.call_api( 

78 method=method, 

79 path=path, 

80 body=body, 

81 max_attempts=1 if method == "GET" else None, 

82 ) 

83 return response 

84 except APIRequestError as exc: 

85 if exc.status_code not in RETRYABLE_STATUS_CODES: 

86 raise 

87 error: Exception = exc 

88 except requests.RequestException as exc: 

89 error = exc 

90 if clock() >= deadline: 

91 raise RuntimeError( 

92 f"{method} {path} did not succeed in {attempts} attempt(s): " 

93 f"{type(error).__name__}: {error}" 

94 ) from error 

95 sleep(RETRY_INTERVAL_SECONDS) 

96 

97 

98def _require_written_template(template: Any, body: dict[str, Any]) -> dict[str, Any]: 

99 if not isinstance(template, dict) or not template.get("created_at"): 

100 raise RuntimeError(f"The API returned no template record for {body['name']}") 

101 drift = sorted( 

102 key 

103 for key in ("name", "description", "manifest", "parameters") 

104 if template.get(key) != body[key] 

105 ) 

106 if drift: 

107 raise RuntimeError(f"Template {body['name']} differs from what was written: {drift}") 

108 return template 

109 

110 

111def create_sentinel( 

112 ctx: RunContext, 

113 *, 

114 timeout_seconds: float, 

115 sleep: Sleeper = time.sleep, 

116 clock: Clock = time.monotonic, 

117) -> dict[str, Any]: 

118 """Write the run's sentinel template, or confirm an earlier attempt wrote it.""" 

119 body = sentinel_body(ctx.settings.run_id) 

120 deadline = clock() + timeout_seconds 

121 try: 

122 response = _call( 

123 ctx, "POST", TEMPLATES_PATH, body=body, deadline=deadline, sleep=sleep, clock=clock 

124 ) 

125 template = response.get("template") 

126 except APIRequestError as exc: 

127 if exc.status_code != 409: 

128 raise 

129 # A POST whose response was lost may still have written the item. 

130 template = read_sentinel(ctx, body["name"], deadline=deadline, sleep=sleep, clock=clock) 

131 return _require_written_template(template, body) 

132 

133 

134def read_sentinel( 

135 ctx: RunContext, 

136 name: str, 

137 *, 

138 deadline: float, 

139 sleep: Sleeper = time.sleep, 

140 clock: Clock = time.monotonic, 

141) -> Any: 

142 """The sentinel template as the API returns it now.""" 

143 response = _call( 

144 ctx, "GET", f"{TEMPLATES_PATH}/{name}", deadline=deadline, sleep=sleep, clock=clock 

145 ) 

146 return response.get("template") 

147 

148 

149def verify_sentinel( 

150 ctx: RunContext, 

151 written: dict[str, Any], 

152 *, 

153 timeout_seconds: float, 

154 sleep: Sleeper = time.sleep, 

155 clock: Clock = time.monotonic, 

156) -> dict[str, Any]: 

157 """Require the sentinel unchanged, including the creation time it was written with.""" 

158 body = sentinel_body(ctx.settings.run_id) 

159 template = _require_written_template( 

160 read_sentinel( 

161 ctx, body["name"], deadline=clock() + timeout_seconds, sleep=sleep, clock=clock 

162 ), 

163 body, 

164 ) 

165 if template.get("created_at") != written.get("created_at"): 

166 raise RuntimeError( 

167 f"Template {body['name']} was recreated: created {template.get('created_at')}, " 

168 f"written {written.get('created_at')}" 

169 ) 

170 return template 

171 

172 

173def delete_sentinel( 

174 ctx: RunContext, 

175 *, 

176 timeout_seconds: float, 

177 sleep: Sleeper = time.sleep, 

178 clock: Clock = time.monotonic, 

179) -> dict[str, Any]: 

180 """Delete the sentinel; a 404 means it is already gone.""" 

181 name = sentinel_name(ctx.settings.run_id) 

182 try: 

183 _call( 

184 ctx, 

185 "DELETE", 

186 f"{TEMPLATES_PATH}/{name}", 

187 deadline=clock() + timeout_seconds, 

188 sleep=sleep, 

189 clock=clock, 

190 ) 

191 except APIRequestError as exc: 

192 if exc.status_code != 404: 

193 raise 

194 return {"name": name, "deleted": False, "already_absent": True} 

195 return {"name": name, "deleted": True}