Skip to content

Commit 2195808

Browse files
authored
feat: run workload and charm as unprivileged user (#282)
Signed-off-by: Dario Faccin <dario.faccin@canonical.com>
1 parent a49f8ca commit 2195808

File tree

6 files changed

+1862
-881
lines changed

6 files changed

+1862
-881
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ __pycache__
33
.idea
44
.tox
55
.coverage
6+
coverage.xml
67
*.charm
78
venv/
89
.terraform*

metadata.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ provides:
1616
requires:
1717
dashboard-links:
1818
interface: kubeflow_dashboard_links
19+
charm-user: non-root

poetry.lock

Lines changed: 1772 additions & 878 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ pytest = "^8.3.4"
8686
optional = true
8787

8888
[tool.poetry.group.integration.dependencies]
89-
charmed-kubeflow-chisme = ">=0.4.11"
89+
charmed-kubeflow-chisme = ">=0.4.14"
90+
jinja2 = "^3.1.4"
9091
juju = "<4.0"
9192
lightkube = "^0.15.6"
9293
pytest = "^8.3.4"

src/templates/deployment.yaml.j2

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,25 @@ kind: Deployment
33
metadata:
44
labels:
55
control-plane: {{ namespace }}-{{ app_name }}
6+
# label added to distinguish charm pod from workload pod
7+
app.kubernetes.io/name: {{ app_name }}-manager
68
name: {{ app_name }}
79
namespace: {{ namespace }}
810
spec:
911
replicas: 1
1012
selector:
1113
matchLabels:
1214
control-plane: {{ namespace }}-{{ app_name }}
15+
# label added to distinguish charm pod from workload pod
16+
app.kubernetes.io/name: {{ app_name }}-manager
1317
template:
1418
metadata:
1519
annotations:
1620
sidecar.istio.io/inject: "false"
1721
labels:
1822
control-plane: {{ namespace }}-{{ app_name }}
23+
# label added to distinguish charm pod from workload pod
24+
app.kubernetes.io/name: {{ app_name }}-manager
1925
spec:
2026
containers:
2127
- command:
@@ -57,6 +63,9 @@ spec:
5763
periodSeconds: 15
5864
timeoutSeconds: 3
5965
securityContext:
66+
# this might require update if not running training-operator rock image
67+
runAsUser: 584792
68+
runAsGroup: 584792
6069
allowPrivilegeEscalation: false
6170
volumeMounts:
6271
- mountPath: /tmp/k8s-webhook-server/serving-certs

tests/integration/test_charm.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,44 @@
1414
from charmed_kubeflow_chisme.testing import (
1515
assert_alert_rules,
1616
assert_metrics_endpoint,
17+
assert_security_context,
1718
deploy_and_assert_grafana_agent,
19+
generate_container_securitycontext_map,
1820
get_alert_rules,
21+
get_pod_names,
1922
)
23+
from jinja2 import Template
24+
from lightkube import Client
2025
from lightkube.resources.apiextensions_v1 import CustomResourceDefinition
2126
from lightkube.resources.rbac_authorization_v1 import ClusterRole
2227
from pytest_operator.plugin import OpsTest
2328

2429
logger = logging.getLogger(__name__)
2530

26-
APP_NAME = "training-operator"
31+
METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
32+
DEPLOYMENT_FILE = Path("./src/templates/deployment.yaml.j2").read_text()
33+
APP_NAME = METADATA["name"]
2734
CHARM_LOCATION = None
2835
APP_PREVIOUS_CHANNEL = "1.7/stable"
2936
METRICS_PATH = "/metrics"
3037
METRICS_PORT = 8080
38+
WEBHOOK_TARGET_PORT = "9443"
39+
DEPLOYMENT_YAML = yaml.safe_load(
40+
Template(DEPLOYMENT_FILE).render(
41+
**{
42+
"app_name": APP_NAME,
43+
"metrics_port": METRICS_PORT,
44+
"webhook_target_port": WEBHOOK_TARGET_PORT,
45+
}
46+
)
47+
)
48+
49+
50+
@pytest.fixture(scope="session")
51+
def lightkube_client() -> Client:
52+
"""Returns lightkube Kubernetes client"""
53+
client = Client(field_manager=f"{APP_NAME}")
54+
return client
3155

3256

3357
@pytest.mark.abort_on_fail
@@ -190,7 +214,7 @@ async def test_alert_rules(ops_test: OpsTest):
190214
await assert_alert_rules(app, alert_rules)
191215

192216

193-
async def test_metrics_enpoint(ops_test: OpsTest):
217+
async def test_metrics_endpoint(ops_test: OpsTest):
194218
"""Test metrics_endpoints are defined in relation data bag and their accessibility.
195219
196220
This function gets all the metrics_endpoints from the relation data bag, checks if
@@ -204,6 +228,57 @@ async def test_metrics_enpoint(ops_test: OpsTest):
204228
await assert_metrics_endpoint(app, metrics_port=METRICS_PORT, metrics_path=METRICS_PATH)
205229

206230

231+
def build_pod_container_map(model_name: str, deployment_template: dict) -> dict[str, dict]:
232+
"""Build full map of pods:containers belonging to this charm.
233+
234+
This function builds a custom mapping of security context for pods and containers,
235+
necessary because some pods are not directly spawned by juju but are defined in
236+
`src/templates/deployment.yaml.j2`.
237+
"""
238+
charm_pods: list = get_pod_names(model_name, APP_NAME)
239+
deployment_pods: list = get_pod_names(model_name, f"{APP_NAME}-manager")
240+
deployment_container_name = deployment_template["spec"]["template"]["spec"]["containers"][0][
241+
"name"
242+
]
243+
deployment_container_security_context = deployment_template["spec"]["template"]["spec"][
244+
"containers"
245+
][0]["securityContext"]
246+
pod_container_map = {}
247+
248+
for charm_pod in charm_pods:
249+
pod_container_map[charm_pod] = generate_container_securitycontext_map(METADATA)
250+
for pod in deployment_pods:
251+
pod_container_map[pod] = {deployment_container_name: deployment_container_security_context}
252+
return pod_container_map
253+
254+
255+
async def test_container_security_context(
256+
ops_test: OpsTest,
257+
lightkube_client: Client,
258+
):
259+
"""Test container security context is correctly set.
260+
261+
Verify that container spec defines the security context with correct
262+
user ID and group ID.
263+
"""
264+
failed_checks = []
265+
pod_container_map = build_pod_container_map(ops_test.model_name, DEPLOYMENT_YAML)
266+
for pod, pod_containers in pod_container_map.items():
267+
for container in pod_containers.keys():
268+
try:
269+
logger.info("Checking security context for container %s (pod: %s)", container, pod)
270+
assert_security_context(
271+
lightkube_client,
272+
pod,
273+
container,
274+
pod_containers,
275+
ops_test.model_name,
276+
)
277+
except AssertionError as err:
278+
failed_checks.append(f"{pod}/{container}: {err}")
279+
assert failed_checks == []
280+
281+
207282
@pytest.mark.abort_on_fail
208283
async def test_remove_with_resources_present(ops_test: OpsTest):
209284
"""Test remove with all resources deployed.

0 commit comments

Comments
 (0)