Skip to content

Commit 115c2fb

Browse files
authored
feat: run workload and charm as unprivileged user (#522)
Signed-off-by: Dario Faccin <[email protected]>
1 parent d6d67bd commit 115c2fb

File tree

10 files changed

+1875
-783
lines changed

10 files changed

+1875
-783
lines changed

charms/jupyter-controller/metadata.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ issues: https://github.com/canonical/notebook-operators/issues
88
containers:
99
jupyter-controller:
1010
resource: oci-image
11+
uid: 584792
12+
gid: 584792
1113
resources:
1214
oci-image:
1315
type: oci-image
@@ -23,3 +25,4 @@ requires:
2325
logging:
2426
interface: loki_push_api
2527
optional: true
28+
charm-user: non-root

charms/jupyter-controller/poetry.lock

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

charms/jupyter-controller/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ package-mode = false
4646
optional = true
4747

4848
[tool.poetry.group.charm.dependencies]
49-
charmed-kubeflow-chisme = ">=0.4.11"
49+
charmed-kubeflow-chisme = ">=0.4.14"
5050
cosl = "^0.0.50"
5151
lightkube = "^0.15.6"
5252
ops = "^2.17.1"
@@ -86,7 +86,7 @@ optional = true
8686

8787
[tool.poetry.group.integration.dependencies]
8888
juju = "<4.0"
89-
charmed-kubeflow-chisme = ">=0.4.11"
89+
charmed-kubeflow-chisme = ">=0.4.14"
9090
httpx = "^0.27.2"
9191
lightkube = "^0.15.6"
9292
pytest = "^8.3.4"

charms/jupyter-controller/tests/integration/test_charm.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@
1414
assert_alert_rules,
1515
assert_logging,
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
)
2023
from charms_dependencies import ISTIO_GATEWAY, ISTIO_PILOT, JUPYTER_UI
2124
from httpx import HTTPStatusError
@@ -29,9 +32,17 @@
2932

3033
METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
3134
APP_NAME = METADATA["name"]
35+
CONTAINERS_SECURITY_CONTEXT_MAP = generate_container_securitycontext_map(METADATA)
3236
ISTIO_GATEWAY_APP_NAME = "istio-ingressgateway"
3337

3438

39+
@pytest.fixture(scope="session")
40+
def lightkube_client() -> Client:
41+
"""Returns lightkube Kubernetes client"""
42+
client = Client(field_manager=f"{APP_NAME}")
43+
return client
44+
45+
3546
@pytest.mark.abort_on_fail
3647
async def test_build_and_deploy(ops_test: OpsTest, request):
3748
"""Test build and deploy."""
@@ -142,9 +153,8 @@ def assert_replicas(client, resource_class, resource_name, namespace):
142153
assert replicas == 1, f"Waited too long for {resource_class_kind}/{resource_name}!"
143154

144155

145-
async def test_create_notebook(ops_test: OpsTest):
156+
async def test_create_notebook(ops_test: OpsTest, lightkube_client: Client):
146157
"""Test notebook creation."""
147-
lightkube_client = Client()
148158
this_ns = lightkube_client.get(res=Namespace, name=ops_test.model.name)
149159
lightkube_client.patch(res=Namespace, name=this_ns.metadata.name, obj=this_ns)
150160

@@ -172,8 +182,30 @@ async def test_create_notebook(ops_test: OpsTest):
172182
assert_replicas(lightkube_client, notebook_resource, "sample-notebook", ops_test.model.name)
173183

174184

185+
@pytest.mark.parametrize("container_name", list(CONTAINERS_SECURITY_CONTEXT_MAP.keys()))
186+
@pytest.mark.abort_on_fail
187+
async def test_container_security_context(
188+
ops_test: OpsTest,
189+
lightkube_client: Client,
190+
container_name: str,
191+
):
192+
"""Test container security context is correctly set.
193+
194+
Verify that container spec defines the security context with correct
195+
user ID and group ID.
196+
"""
197+
pod_name = get_pod_names(ops_test.model.name, APP_NAME)[0]
198+
assert_security_context(
199+
lightkube_client,
200+
pod_name,
201+
container_name,
202+
CONTAINERS_SECURITY_CONTEXT_MAP,
203+
ops_test.model.name,
204+
)
205+
206+
175207
@pytest.mark.abort_on_fail
176-
async def test_remove_with_resources_present(ops_test: OpsTest):
208+
async def test_remove_with_resources_present(ops_test: OpsTest, lightkube_client: Client):
177209
"""Test remove with all resources deployed.
178210
179211
Verify that all deployed resources that need to be removed are removed.
@@ -184,7 +216,6 @@ async def test_remove_with_resources_present(ops_test: OpsTest):
184216
assert APP_NAME not in ops_test.model.applications
185217

186218
# verify that all resources that were deployed are removed
187-
lightkube_client = Client()
188219

189220
# verify all CRDs in namespace are removed
190221
crd_list = lightkube_client.list(

charms/jupyter-ui/metadata.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ issues: https://github.com/canonical/notebook-operators/issues
88
containers:
99
jupyter-ui:
1010
resource: oci-image
11+
uid: 584792
12+
gid: 584792
13+
mounts:
14+
- storage: config
15+
location: /etc/config
16+
- storage: logos
17+
location: /src/apps/default/static/assets/logos/
1118
resources:
1219
oci-image:
1320
type: oci-image
@@ -77,3 +84,11 @@ provides:
7784
description: |
7885
Access a cross-model application from catalogue via the service mesh.
7986
This relation provides additional data required by the service mesh to enforce cross-model authorization policies.
87+
charm-user: non-root
88+
storage:
89+
config:
90+
type: filesystem
91+
minimum-size: 1M
92+
logos:
93+
type: filesystem
94+
minimum-size: 1M

charms/jupyter-ui/poetry.lock

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

charms/jupyter-ui/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ optional = true
4747

4848
[tool.poetry.group.charm.dependencies]
4949
charmed-service-mesh-helpers = ">=0.2.0"
50-
charmed-kubeflow-chisme = ">=0.4.11"
50+
charmed-kubeflow-chisme = ">=0.4.14"
5151
serialized-data-interface = "<0.4"
5252
cosl = "^0.0.50"
5353
lightkube = "^0.15.6"

charms/jupyter-ui/src/charm.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
TOLERATIONS_OPTIONS_CONFIG_DEFAULT = f"{TOLERATIONS_OPTIONS_CONFIG}-default"
7171
DEFAULT_PODDEFAULTS_CONFIG = "default-poddefaults"
7272
JWA_CONFIG_FILE = "src/templates/spawner_ui_config.yaml.j2"
73+
JWA_CONFIG_FILE_DST = "spawner_ui_config.yaml"
7374

7475
IMAGE_CONFIGS = [
7576
JUPYTER_IMAGES_CONFIG,
@@ -120,7 +121,11 @@ def __init__(self, *args):
120121
" - entrypoint:app"
121122
)
122123
self._container_name = "jupyter-ui"
124+
self._container_meta = self.meta.containers[self._container_name]
123125
self._container = self.unit.get_container(self._name)
126+
self._config_storage_name = "config"
127+
self._logos_storage_name = "logos"
128+
self._container_meta = self.meta.containers[self._container_name]
124129

125130
# setup context to be used for updating K8S resources
126131
self._context = {
@@ -302,10 +307,11 @@ def _upload_logos_files_to_container(self):
302307
splits it into files as expected by the workload,
303308
and pushes the files to the container.
304309
"""
310+
logos_storage_path = Path(self._container_meta.mounts[self._logos_storage_name].location)
305311
for file_name, file_content in yaml.safe_load(
306312
Path("src/logos-configmap.yaml").read_text()
307313
)["data"].items():
308-
logo_file = "/src/apps/default/static/assets/logos/" + file_name
314+
logo_file = logos_storage_path / file_name
309315
self.container.push(
310316
logo_file,
311317
file_content,
@@ -474,8 +480,9 @@ def _render_jwa_spawner_inputs(
474480

475481
def _upload_jwa_file_to_container(self, file_content):
476482
"""Pushes the JWA spawner config file to the workload container."""
483+
config_storage_path = Path(self._container_meta.mounts[self._config_storage_name].location)
477484
self.container.push(
478-
"/etc/config/spawner_ui_config.yaml",
485+
config_storage_path / JWA_CONFIG_FILE_DST,
479486
file_content,
480487
make_dirs=True,
481488
)
@@ -519,6 +526,11 @@ def _on_pebble_ready(self, _):
519526
if not self._is_container_ready():
520527
return
521528

529+
try:
530+
self._check_storage()
531+
except CheckFailed as err:
532+
self.model.unit.status = err.status
533+
return
522534
# upload files to container
523535
self._upload_logos_files_to_container()
524536

@@ -590,10 +602,23 @@ def _check_istio_relations(self):
590602
BlockedStatus,
591603
)
592604

605+
def _check_storage(self):
606+
"""Check if storage is available."""
607+
config_storage_path = Path(self._container_meta.mounts[self._config_storage_name].location)
608+
logos_storage_path = Path(self._container_meta.mounts[self._logos_storage_name].location)
609+
610+
if not self.container.exists(config_storage_path):
611+
self.logger.info('Storage "config" not yet available')
612+
raise CheckFailed('Waiting for "config" storage', WaitingStatus)
613+
if not self.container.exists(logos_storage_path):
614+
self.logger.info('Storage "logos" not yet available')
615+
raise CheckFailed('Waiting for "logos" storage', WaitingStatus)
616+
593617
def main(self, _) -> None:
594618
"""Perform all required actions of the Charm."""
595619
try:
596620
self._check_leader()
621+
self._check_storage()
597622
self._deploy_k8s_resources()
598623
if self._is_container_ready():
599624
self._update_layer()

charms/jupyter-ui/tests/integration/test_charm.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,20 @@
1616
from charmed_kubeflow_chisme.testing import (
1717
GRAFANA_AGENT_APP,
1818
assert_logging,
19+
assert_security_context,
1920
deploy_and_assert_grafana_agent,
21+
generate_container_securitycontext_map,
22+
get_pod_names,
2023
)
24+
from lightkube import Client
2125
from pytest_operator.plugin import OpsTest
2226

2327
logger = logging.getLogger(__name__)
2428

2529
METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
2630
CONFIG = yaml.safe_load(Path("./config.yaml").read_text())
27-
APP_NAME = "jupyter-ui"
31+
APP_NAME = METADATA["name"]
32+
CONTAINERS_SECURITY_CONTEXT_MAP = generate_container_securitycontext_map(METADATA)
2833
JUPYTER_IMAGES_CONFIG = "jupyter-images"
2934
VSCODE_IMAGES_CONFIG = "vscode-images"
3035
RSTUDIO_IMAGES_CONFIG = "rstudio-images"
@@ -66,6 +71,13 @@
6671
]
6772

6873

74+
@pytest.fixture(scope="session")
75+
def lightkube_client() -> Client:
76+
"""Return lightkube Kubernetes client."""
77+
client = Client(field_manager=f"{APP_NAME}")
78+
return client
79+
80+
6981
@pytest.mark.abort_on_fail
7082
async def test_build_and_deploy(ops_test: OpsTest, request):
7183
"""Build and deploy the charm.
@@ -189,6 +201,28 @@ async def test_logging(ops_test):
189201
await assert_logging(app)
190202

191203

204+
@pytest.mark.parametrize("container_name", list(CONTAINERS_SECURITY_CONTEXT_MAP.keys()))
205+
@pytest.mark.abort_on_fail
206+
async def test_container_security_context(
207+
ops_test: OpsTest,
208+
lightkube_client: Client,
209+
container_name: str,
210+
):
211+
"""Test container security context is correctly set.
212+
213+
Verify that container spec defines the security context with correct
214+
user ID and group ID.
215+
"""
216+
pod_name = get_pod_names(ops_test.model.name, APP_NAME)[0]
217+
assert_security_context(
218+
lightkube_client,
219+
pod_name,
220+
container_name,
221+
CONTAINERS_SECURITY_CONTEXT_MAP,
222+
ops_test.model.name,
223+
)
224+
225+
192226
RETRY_120_SECONDS = tenacity.Retrying(
193227
stop=tenacity.stop_after_delay(120),
194228
wait=tenacity.wait_fixed(2),

0 commit comments

Comments
 (0)