Skip to content

Commit ca58bbb

Browse files
hubatishcopybara-github
authored andcommitted
Support reading & applying manifests as yaml rather than straight jinja
A number of tricky python pieces in this: - Convert to the recursive defaultdict to allow for creating new dictionaries when accessed with eg dict1['foo']['bar']['zip'] = 4. - Convert back to regular dicts so yaml.dump works. - Swapping from yield to list with ParseApplyOutput as otherwise the generator delayed/avoided execution of the important _RunKubectlCommand statement. PiperOrigin-RevId: 842884621
1 parent 497c38a commit ca58bbb

File tree

5 files changed

+248
-29
lines changed

5 files changed

+248
-29
lines changed

perfkitbenchmarker/container_service.py

Lines changed: 141 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
from typing import Any, Callable, Dict, Iterable, Iterator, Optional, Sequence
3939

4040
from absl import flags
41-
import jinja2
4241
from perfkitbenchmarker import context
4342
from perfkitbenchmarker import data
4443
from perfkitbenchmarker import errors
@@ -1001,6 +1000,33 @@ def ApplyManifest(
10011000
Returns:
10021001
Names of the resources, e.g. [deployment.apps/mydeploy, pod/foo]
10031002
"""
1003+
filename = data.ResourcePath(manifest_file)
1004+
if not filename.endswith('.j2'):
1005+
if kwargs:
1006+
raise AssertionError(
1007+
'kwargs should be empty for non-jinja templates. '
1008+
f'Found when reading {manifest_file}'
1009+
)
1010+
return KubernetesClusterCommands._ApplyRenderedManifest(filename)
1011+
1012+
manifest = vm_util.ReadAndRenderJinja2Template(
1013+
manifest_file, trim_spaces=False, **kwargs
1014+
)
1015+
return KubernetesClusterCommands._WriteAndApplyManifest(
1016+
manifest, should_log_file
1017+
)
1018+
1019+
@staticmethod
1020+
def _ApplyRenderedManifest(manifest_file: str) -> Iterator[str]:
1021+
"""Applies a rendered Kubernetes manifest & returns resources created.
1022+
1023+
Args:
1024+
manifest_file: The full path of the YAML file.
1025+
1026+
Returns:
1027+
Names of the resources, e.g. [deployment.apps/mydeploy, pod/foo]
1028+
"""
1029+
out, _, _ = RunKubectlCommand(['apply', '-f', manifest_file])
10041030

10051031
def _ParseApplyOutput(stdout: str) -> Iterator[str]:
10061032
"""Parses the output of kubectl apply to get the name of the resource."""
@@ -1010,27 +1036,131 @@ def _ParseApplyOutput(stdout: str) -> Iterator[str]:
10101036
if match:
10111037
yield match.group(1)
10121038

1013-
filename = data.ResourcePath(manifest_file)
1014-
if not filename.endswith('.j2'):
1015-
assert not kwargs
1016-
out, _, _ = RunKubectlCommand(['apply', '-f', filename])
1017-
return _ParseApplyOutput(out)
1039+
# Inner function needed to run kubectl command even if output is unused.
1040+
return _ParseApplyOutput(out)
10181041

1019-
environment = jinja2.Environment(undefined=jinja2.StrictUndefined)
1020-
with open(filename) as template_file, vm_util.NamedTemporaryFile(
1042+
@staticmethod
1043+
def _WriteAndApplyManifest(
1044+
manifest: str, should_log_file: bool = True
1045+
) -> Iterator[str]:
1046+
"""Writes the string to file and applies it."""
1047+
with vm_util.NamedTemporaryFile(
10211048
mode='w', suffix='.yaml'
10221049
) as rendered_template:
1023-
manifest = environment.from_string(template_file.read()).render(kwargs)
10241050
rendered_template.write(manifest)
10251051
rendered_template.close()
10261052
if should_log_file:
10271053
logging.info(
10281054
'Rendered manifest file %s with contents:\n%s',
10291055
rendered_template.name,
10301056
manifest,
1057+
stacklevel=2,
1058+
)
1059+
resource_names = KubernetesClusterCommands._ApplyRenderedManifest(
1060+
rendered_template.name
1061+
)
1062+
return resource_names
1063+
1064+
@staticmethod
1065+
def _RecursiveDict() -> dict[str, Any]:
1066+
"""Creates a new dictionary with auto nesting keys.
1067+
1068+
See https://stackoverflow.com/a/10218517/2528472.
1069+
1070+
Returns:
1071+
A new dictionary that automatically sets the value for any nested missing
1072+
keys.
1073+
"""
1074+
return collections.defaultdict(KubernetesClusterCommands._RecursiveDict)
1075+
1076+
@staticmethod
1077+
def ConvertManifestToYamlDicts(
1078+
manifest_file: str, **kwargs
1079+
) -> list[dict[str, Any]]:
1080+
"""Reads a Kubernetes manifest from a file and converts it to YAML.
1081+
1082+
Args:
1083+
manifest_file: The name of the YAML file or YAML template.
1084+
**kwargs: Arguments to the jinja template.
1085+
1086+
Returns:
1087+
The various YAML documents as a list of dictionaries.
1088+
"""
1089+
manifest = vm_util.ReadAndRenderJinja2Template(
1090+
manifest_file, trim_spaces=False, **kwargs
1091+
)
1092+
yaml_docs = yaml.safe_load_all(manifest)
1093+
return_yamls = []
1094+
for yaml_doc in yaml_docs:
1095+
return_yamls.append(
1096+
KubernetesClusterCommands._ConvertToDictType(
1097+
yaml_doc, KubernetesClusterCommands._RecursiveDict
1098+
)
1099+
)
1100+
return return_yamls
1101+
1102+
@staticmethod
1103+
def _ConvertToDictType(
1104+
yaml_doc: Any, dict_lambda: Any
1105+
) -> dict[str, Any] | Any:
1106+
"""Converts a YAML document to the given dictionary type.
1107+
1108+
In particular a recursive defaultdict can be more easily accessed with e.g.
1109+
my_dict['a']['b'] = value rather than my_dict.setdefault('a', {})['b'] =
1110+
value.
1111+
1112+
Args:
1113+
yaml_doc: The YAML document to convert.
1114+
dict_lambda: A constructor for the dictionary type to convert to.
1115+
1116+
Returns:
1117+
The remade dictionary.
1118+
"""
1119+
if not isinstance(yaml_doc, dict) and not isinstance(yaml_doc, list):
1120+
return yaml_doc
1121+
1122+
yaml_list = []
1123+
if isinstance(yaml_doc, list):
1124+
for item in yaml_doc:
1125+
if not bool(item) and item != 0:
1126+
continue
1127+
yaml_list.append(
1128+
KubernetesClusterCommands._ConvertToDictType(item, dict_lambda)
10311129
)
1032-
out, _, _ = RunKubectlCommand(['apply', '-f', rendered_template.name])
1033-
return _ParseApplyOutput(out)
1130+
return yaml_list
1131+
yaml_dict = dict_lambda()
1132+
for key, value in yaml_doc.items():
1133+
if not bool(value) and value != 0:
1134+
continue
1135+
yaml_dict[key] = KubernetesClusterCommands._ConvertToDictType(
1136+
value, dict_lambda
1137+
)
1138+
return yaml_dict
1139+
1140+
@staticmethod
1141+
def ApplyYaml(
1142+
yaml_dicts: list[dict[str, Any]], should_log_file: bool = True
1143+
) -> Iterator[str]:
1144+
"""Writes yaml to a file and applies it.
1145+
1146+
Args:
1147+
yaml_dicts: A list of YAML documents.
1148+
should_log_file: Whether to log the rendered manifest to stdout or not.
1149+
1150+
Returns:
1151+
Names of the resources, e.g. [deployment.apps/mydeploy, pod/foo]
1152+
"""
1153+
normal_dicts = []
1154+
# Convert back to a normal dict because yaml.dump otherwise adds random
1155+
# "dictitems:" keys & other python artifacts.
1156+
for yaml_dict in yaml_dicts:
1157+
normal_dicts.append(
1158+
KubernetesClusterCommands._ConvertToDictType(yaml_dict, dict)
1159+
)
1160+
manifest = yaml.dump_all(normal_dicts)
1161+
return KubernetesClusterCommands._WriteAndApplyManifest(
1162+
manifest, should_log_file
1163+
)
10341164

10351165
@staticmethod
10361166
def WaitForResource(

perfkitbenchmarker/linux_benchmarks/kubernetes_scale_benchmark.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ def ScaleUpPods(
180180
max_wait_time = _GetScaleTimeout()
181181
resource_timeout = max_wait_time + 60 * 5 # 5 minutes after waiting to avoid
182182
# pod delete events from polluting data collection.
183-
resource_names = cluster.ApplyManifest(
183+
scaleup_yaml = cluster.ConvertManifestToYamlDicts(
184184
MANIFEST_TEMPLATE,
185185
Name='kubernetes-scaleup',
186186
Replicas=num_new_pods,
@@ -197,6 +197,7 @@ def ScaleUpPods(
197197
),
198198
cloud='Azure' if FLAGS.cloud == 'Azure' else None,
199199
)
200+
resource_names = cluster.ApplyYaml(scaleup_yaml)
200201

201202
assert resource_names
202203
rollout_name = next(resource_names)

perfkitbenchmarker/vm_util.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,7 @@
150150
None,
151151
'File path to the SSH private key. If None, use the newly generated one.',
152152
)
153-
_SSH_KEY_TYPE = flags.DEFINE_string(
154-
'ssh_key_type', 'rsa',
155-
'SSH key type.')
153+
_SSH_KEY_TYPE = flags.DEFINE_string('ssh_key_type', 'rsa', 'SSH key type.')
156154

157155

158156
class RetryError(Exception):
@@ -950,19 +948,13 @@ def RenderTemplate(
950948
Returns:
951949
The name of the temporary file containing the rendered template.
952950
"""
953-
with open(template_path) as fp:
954-
template_contents = fp.read()
955-
environment = jinja2.Environment(
956-
undefined=jinja2.StrictUndefined,
957-
trim_blocks=trim_spaces,
958-
lstrip_blocks=trim_spaces,
951+
rendered_template = ReadAndRenderJinja2Template(
952+
template_path, trim_spaces, **context
959953
)
960-
template = environment.from_string(template_contents)
961954
prefix = 'pkb-' + os.path.basename(template_path)
962955
with NamedTemporaryFile(
963956
prefix=prefix, dir=GetTempDir(), delete=False, mode='w'
964957
) as tf:
965-
rendered_template = template.render(**context)
966958
if should_log_file:
967959
logging.info(
968960
'Rendered template from %s to %s with full text:\n%s',
@@ -976,6 +968,24 @@ def RenderTemplate(
976968
return tf.name
977969

978970

971+
@staticmethod
972+
def ReadAndRenderJinja2Template(
973+
file_path: str, trim_spaces: bool = False, **kwargs
974+
) -> str:
975+
"""Reads & renders a .j2 file, returning the whole thing as a string."""
976+
filename = data.ResourcePath(file_path)
977+
with open(filename) as template_file:
978+
contents = template_file.read()
979+
if file_path.endswith('.j2'):
980+
environment = jinja2.Environment(
981+
undefined=jinja2.StrictUndefined,
982+
trim_blocks=trim_spaces,
983+
lstrip_blocks=trim_spaces,
984+
)
985+
contents = environment.from_string(contents).render(kwargs)
986+
return contents
987+
988+
979989
def CreateRemoteFile(
980990
vm, file_contents, file_path, write_mode: Literal['w', 'wb'] = 'w'
981991
):

tests/container_service_test.py

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import time
23
from typing import Callable, Iterable, Protocol, Tuple
34
import unittest
@@ -136,7 +137,7 @@ def setUp(self):
136137
)
137138
)
138139

139-
@parameterized.parameters(('created'), ('configured'))
140+
@parameterized.parameters('created', 'configured')
140141
def test_apply_manifest_gets_deployment_name(self, suffix):
141142
self.MockIssueCommand(
142143
{'apply -f': [(f'deployment.apps/test-deployment {suffix}', '', 0)]}
@@ -153,6 +154,68 @@ def test_apply_manifest_gets_deployment_name(self, suffix):
153154
)
154155
self.assertEqual(next(deploy_ids), 'deployment.apps/test-deployment')
155156

157+
def test_apply_manifest_logs_jinja(self):
158+
self.MockIssueCommand(
159+
{'apply -f': [('deployment.apps/test-deployment hello', '', 0)]}
160+
)
161+
self.enter_context(
162+
mock.patch.object(
163+
container_service.data,
164+
'ResourcePath',
165+
return_value=os.path.join(
166+
os.path.dirname(__file__), 'data', 'kube_apply.yaml.j2'
167+
),
168+
)
169+
)
170+
with self.assertLogs(level='INFO') as logs:
171+
self.kubernetes_cluster.ApplyManifest(
172+
'tests/data/kube_apply.yaml.j2',
173+
should_log_file=True,
174+
name='hello-world',
175+
command=['echo', 'hello', 'world'],
176+
)
177+
# Asserting on logging isn't very important, but is easier than reading the
178+
# written file.
179+
full_logs = ';'.join(logs.output)
180+
self.assertIn('name: hello-world', full_logs)
181+
self.assertIn('echo', full_logs)
182+
183+
def test_apply_manifest_yaml_logs(self):
184+
self.MockIssueCommand(
185+
{'apply -f': [('deployment.apps/test-deployment hello', '', 0)]}
186+
)
187+
self.enter_context(
188+
mock.patch.object(
189+
container_service.data,
190+
'ResourcePath',
191+
return_value=os.path.join(
192+
os.path.dirname(__file__), 'data', 'kube_apply.yaml.j2'
193+
),
194+
)
195+
)
196+
with self.assertLogs(level='INFO') as logs:
197+
yamls = self.kubernetes_cluster.ConvertManifestToYamlDicts(
198+
'tests/data/kube_apply.yaml.j2',
199+
name='hello-world',
200+
command=[],
201+
)
202+
self.assertEqual(yamls[0]['kind'], 'Namespace')
203+
yamls[1]['spec']['selector']['app'] = 'hi-world'
204+
yamls[1]['spec']['template']['spec']['containers'].append(
205+
{'name': 'second-container'}
206+
)
207+
self.kubernetes_cluster.ApplyYaml(
208+
yamls,
209+
should_log_file=True,
210+
)
211+
full_logs = ';'.join(logs.output)
212+
self.assertIn('app: hi-world', full_logs)
213+
self.assertIn('name: hello-world', full_logs)
214+
self.assertIn('name: second-container', full_logs)
215+
# Check for no python artifacts.
216+
self.assertNotIn('dict', full_logs)
217+
self.assertNotIn('null', full_logs)
218+
156219
@mock.patch.object(
157220
vm_util,
158221
'IssueCommand',
@@ -450,9 +513,7 @@ def test_KubernetesEventParsing(self):
450513
},
451514
'kind': 'Event',
452515
'lastTimestamp': None,
453-
'message': (
454-
'Successfully assigned default/deploy-pod to gke-node'
455-
),
516+
'message': 'Successfully assigned default/deploy-pod to gke-node',
456517
'metadata': {
457518
'creationTimestamp': '2025-10-03T18:05:56Z',
458519
},
@@ -462,8 +523,7 @@ def test_KubernetesEventParsing(self):
462523
})
463524
self.assertIsNotNone(event)
464525
self.assertEqual(
465-
event.message,
466-
'Successfully assigned default/deploy-pod to gke-node'
526+
event.message, 'Successfully assigned default/deploy-pod to gke-node'
467527
)
468528
self.assertEqual(event.reason, 'Scheduled')
469529
self.assertEqual(event.type, 'Normal')

tests/data/kube_apply.yaml.j2

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
apiVersion: v1
2+
kind: Namespace
3+
metadata:
4+
name: {{name}}
5+
---
6+
apiVersion: v1
7+
kind: Deployment
8+
spec:
9+
replicas: 1
10+
template:
11+
spec:
12+
containers:
13+
- name: {{name}}
14+
image: registry.k8s.io/e2e-test-images/agnhost:2.52
15+
command:
16+
{%- for c in command %}
17+
{{ c }}
18+
{%- endfor %}

0 commit comments

Comments
 (0)