Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion k8s/helm/templates/core/controller-role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ rules:
verbs: ["get", "list", "create", "delete"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create", "delete"]
# get/list are required by the deployments k8s backend: it reads a managed
# Secret to enforce the ownership-label guard before create (409 conflict) and
# before delete. Without get, delete_deployment fails with 403 and the
# Deployment is stuck in DELETING. Mirrors the configmaps verb set.
verbs: ["get", "list", "create", "delete"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
{{- if .Values.rbac.volcanoEnabled }}
# Volcano
- apiGroups: ["batch.volcano.sh"]
Expand Down
13 changes: 11 additions & 2 deletions plugins/nemo-deployments/openapi/openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from nemo_deployments_plugin.backends.k8s.config import K8sExecutorConfig
from nemo_deployments_plugin.backends.labels import deployment_identity_labels
from nemo_deployments_plugin.entities import Deployment, DeploymentConfig
from nemo_deployments_plugin.secrets import SecretResolutionError, resolve_deployment_config_secrets
from nemo_deployments_plugin.secrets import SecretResolutionError, resolve_deployment_secret_env
from nemo_platform_plugin.client.adapter import client_from_platform
from nemo_platform_plugin.entities.client import AsyncEntitiesClient
from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityNotFoundError
Expand Down Expand Up @@ -103,7 +103,10 @@ async def create_deployment(
) -> BackendStatusUpdate:
try:
config = await self._load_deployment_config(workspace, config_name)
config = await resolve_deployment_config_secrets(self._sdk, config)
# k8s keeps secret_ref env vars intact and mounts their resolved
# values through a single per-deployment Secret via envFrom, so the
# plaintext never lands in the pod manifest.
secret_env = await resolve_deployment_secret_env(self._sdk, config)
except NemoEntityNotFoundError:
return BackendStatusUpdate(
status="FAILED",
Expand All @@ -126,6 +129,7 @@ async def create_deployment(
backend_config=backend_config,
config=config,
executor_image_pull_secrets=self._executor_config.image_pull_secrets,
secret_env=secret_env,
)

return await job_ops.create_job(
Expand All @@ -138,6 +142,7 @@ async def create_deployment(
backend_config=backend_config,
config=config,
executor_image_pull_secrets=self._executor_config.image_pull_secrets,
secret_env=secret_env,
)

async def read_status(self, *, workspace: str, name: str) -> BackendStatusUpdate:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
from nemo_deployments_plugin.auth_proxy import build_auth_proxy_container
from nemo_deployments_plugin.backends.k8s.client import k8s_client_module
from nemo_deployments_plugin.backends.k8s.status import resource_labels_match
from nemo_deployments_plugin.backends.labels import k8s_deployment_configmap_name, k8s_volume_resource_name
from nemo_deployments_plugin.backends.labels import (
k8s_deployment_configmap_name,
k8s_deployment_secret_name,
k8s_volume_resource_name,
)
from nemo_deployments_plugin.entities import (
Affinity,
ConfigFile,
Expand Down Expand Up @@ -55,10 +59,25 @@ def merged_volume_mounts(config: DeploymentConfig, container: Container) -> list


def build_env_vars(container: Container) -> list[Any]:
"""Build plaintext ``V1EnvVar`` entries for a container.

Env vars carrying a ``secret_ref`` are intentionally skipped here — their
values live in the per-deployment managed ``Secret`` and are injected via
``envFrom`` (see :func:`build_secret_env_from`), so the plaintext never
appears in the pod manifest.
"""
k8s = k8s_client_module()
return [k8s.client.V1EnvVar(name=item.name, value=item.value) for item in container.env if item.value is not None]


def build_secret_env_from(secret_name: str | None) -> list[Any]:
"""Build the ``envFrom`` projection for a deployment's managed Secret."""
if secret_name is None:
return []
k8s = k8s_client_module()
return [k8s.client.V1EnvFromSource(secret_ref=k8s.client.V1SecretEnvSource(name=secret_name))]


def build_resource_requirements(container: Container) -> Any | None:
limits = container.resources.limits or None
requests = container.resources.requests or None
Expand Down Expand Up @@ -98,12 +117,18 @@ def build_volume_mounts(mounts: list[VolumeMount]) -> list[Any]:
]


def build_container_spec(container: Container, *, volume_mounts: list[VolumeMount] | None = None) -> Any:
def build_container_spec(
container: Container,
*,
volume_mounts: list[VolumeMount] | None = None,
secret_name: str | None = None,
) -> Any:
k8s = k8s_client_module()
kwargs: dict[str, Any] = {
"name": container.name,
"image": container.image,
"env": build_env_vars(container) or None,
"env_from": build_secret_env_from(secret_name) or None,
"resources": build_resource_requirements(container),
}
if container.command:
Expand All @@ -123,6 +148,8 @@ class CompiledWorkload:
configmap_body: Any | None
configmap_name: str | None
service_containers: tuple[Container, ...]
secret_body: Any | None = None
secret_name: str | None = None


def _reraise_api_unless(exc: ApiException, *allowed_statuses: int) -> None:
Expand Down Expand Up @@ -256,19 +283,21 @@ def build_container(
*,
config: DeploymentConfig,
include_probes: bool,
secret_name: str | None = None,
) -> Any:
"""Build a V1Container from a plugin Container."""
k8s = k8s_client_module()
mounts = merged_volume_mounts(config, container)
if config.config_files:
mounts = [*mounts, *_config_file_mounts(config.config_files)]
base = build_container_spec(container, volume_mounts=mounts or None)
base = build_container_spec(container, volume_mounts=mounts or None, secret_name=secret_name)
kwargs: dict[str, Any] = {
"name": base.name,
"image": base.image,
"command": base.command,
"args": base.args,
"env": base.env,
"env_from": base.env_from,
"resources": base.resources,
"volume_mounts": build_volume_mounts(mounts) if mounts else base.volume_mounts,
"ports": _build_container_ports(container.ports) or None,
Expand Down Expand Up @@ -335,6 +364,34 @@ def build_configmap_body(
)


def build_secret_body(
*,
workspace: str,
deployment_name: str,
labels: dict[str, str],
secret_env: dict[str, str],
) -> Any | None:
"""Build the per-deployment managed ``V1Secret`` for resolved secret env vars.

Returns ``None`` when the deployment references no secrets. The Secret holds
every resolved secret env var (keyed by env var name) and is mounted into
containers via ``envFrom`` so the plaintext never lands in the pod manifest.
"""
if not secret_env:
return None
k8s = k8s_client_module()
return k8s.client.V1Secret(
api_version="v1",
kind="Secret",
type="Opaque",
metadata=k8s.client.V1ObjectMeta(
name=k8s_deployment_secret_name(workspace, deployment_name),
labels=labels,
),
string_data=dict(secret_env),
)


def _build_config_file_volume(configmap_name: str, config_files: list[ConfigFile]) -> Any:
k8s = k8s_client_module()
items = [
Expand Down Expand Up @@ -369,8 +426,14 @@ def compile_workload(
k8s_config: K8sDeploymentConfig | None,
pod_restart_policy: RestartPolicy,
executor_image_pull_secrets: list[ImagePullSecret] | None = None,
secret_env: dict[str, str] | None = None,
) -> CompiledWorkload:
"""Compile pod spec kwargs and optional ConfigMap for a Job or Deployment."""
"""Compile pod spec kwargs and optional ConfigMap/Secret for a Job or Deployment.

``secret_env`` maps env var names to resolved plaintext secret values. When
non-empty, a single per-deployment ``Secret`` is compiled and mounted into
every container via ``envFrom``.
"""
validate_workload_config(config)
pvc_mounts = _collect_pvc_mounts(config)
volumes = build_pod_volumes(workspace=workspace, mounts=pvc_mounts)
Expand All @@ -384,23 +447,34 @@ def compile_workload(
if configmap_name is not None:
volumes = [*volumes, _build_config_file_volume(configmap_name, config.config_files)]

secret_body = build_secret_body(
workspace=workspace,
deployment_name=deployment_name,
labels=labels,
secret_env=secret_env or {},
)
secret_name = secret_body.metadata.name if secret_body is not None else None

ordered_init = list(_ordered_init_containers(config))
# Auth-proxy sidecar (native sidecar with restartPolicy=Always) is appended so
# it starts before the main workload and keeps running. No-op when the config
# does not request it or platform auth is disabled.
auth_proxy = build_auth_proxy_container(config)
if auth_proxy is not None:
ordered_init.append(auth_proxy)
init_containers = [
build_container(
container,
config=config,
include_probes=container.restart_policy == NATIVE_SIDECAR_RESTART_POLICY,
secret_name=secret_name,
)
for container in ordered_init
]
# Auth-proxy sidecar (native sidecar with restartPolicy=Always) is appended so
# it starts before the main workload and keeps running. No-op when the config
# does not request it or platform auth is disabled. It is a platform-managed
# container and deliberately does NOT receive the workload's secret envFrom.
auth_proxy = build_auth_proxy_container(config)
if auth_proxy is not None:
init_containers.append(build_container(auth_proxy, config=config, include_probes=True))
main_containers = [
build_container(container, config=config, include_probes=True) for container in config.containers
build_container(container, config=config, include_probes=True, secret_name=secret_name)
for container in config.containers
]

pod_spec_kwargs: dict[str, Any] = {
Expand Down Expand Up @@ -434,6 +508,8 @@ def compile_workload(
configmap_body=configmap_body,
configmap_name=configmap_name,
service_containers=tuple(config.containers),
secret_body=secret_body,
secret_name=secret_name,
)


Expand Down Expand Up @@ -499,3 +575,67 @@ def delete_configmap(
core_v1.delete_namespaced_config_map(name=name, namespace=namespace, _request_timeout=timeout)
except ApiException as exc:
_reraise_api_unless(exc, 404)


def create_secret(
core_v1: Any,
*,
namespace: str,
body: Any,
expected_labels: dict[str, str],
timeout: float | None,
) -> None:
try:
core_v1.create_namespaced_secret(namespace=namespace, body=body, _request_timeout=timeout)
except ApiException as exc:
_reraise_api_unless(exc, 409)
existing = core_v1.read_namespaced_secret(
name=body.metadata.name,
namespace=namespace,
_request_timeout=timeout,
)
if not resource_labels_match(existing, expected_labels):
raise


def delete_secret_best_effort(
core_v1: Any,
*,
namespace: str,
name: str | None,
expected_labels: dict[str, str],
timeout: float | None,
) -> None:
if name is None:
return
try:
delete_secret(
core_v1,
namespace=namespace,
name=name,
expected_labels=expected_labels,
timeout=timeout,
)
except ApiException:
logger.debug("Best-effort Secret cleanup failed for %s", name, exc_info=True)
Comment thread
benmccown marked this conversation as resolved.
Dismissed


def delete_secret(
core_v1: Any,
*,
namespace: str,
name: str,
expected_labels: dict[str, str],
timeout: float | None,
) -> None:
try:
secret = core_v1.read_namespaced_secret(name=name, namespace=namespace, _request_timeout=timeout)
except ApiException as exc:
_reraise_api_unless(exc, 404)
return
if not resource_labels_match(secret, expected_labels):
return
try:
core_v1.delete_namespaced_secret(name=name, namespace=namespace, _request_timeout=timeout)
except ApiException as exc:
_reraise_api_unless(exc, 404)
Loading
Loading