This is an automated email from the ASF dual-hosted git repository. mgubaidullin pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel-karavan.git
commit a773db372eab9f180110ad6129d58004a1bce571 Author: Marat Gubaidullin <[email protected]> AuthorDate: Fri Aug 28 09:30:45 2026 -0400 KubernetesService cleanup --- docs/install/karavan-helm/.helmignore | 23 ---- docs/install/karavan-helm/Chart.yaml | 5 - .../install/karavan-helm/templates/deployment.yaml | 105 --------------- docs/install/karavan-helm/templates/ingress.yaml | 28 ---- docs/install/karavan-helm/templates/nodePort.yaml | 15 --- .../karavan-helm/templates/role-binding.yaml | 25 ---- docs/install/karavan-helm/templates/role.yaml | 29 ---- docs/install/karavan-helm/templates/secret.yaml | 20 --- .../karavan-helm/templates/service-account.yaml | 4 - docs/install/karavan-helm/templates/service.yaml | 18 --- docs/install/karavan-helm/values.yaml | 52 ------- docs/install/karavan-kubernetes/role-binding.yaml | 13 -- docs/install/karavan-kubernetes/role.yaml | 63 ++++++--- .../camel/karavan/api/InfrastructureResource.java | 7 +- .../karavan/kubernetes/KubernetesService.java | 150 ++++++++++++++------- .../src/main/resources/application.properties | 4 + 16 files changed, 158 insertions(+), 403 deletions(-) diff --git a/docs/install/karavan-helm/.helmignore b/docs/install/karavan-helm/.helmignore deleted file mode 100644 index 0e8a0eb3..00000000 --- a/docs/install/karavan-helm/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*.orig -*~ -# Various IDEs -.project -.idea/ -*.tmproj -.vscode/ diff --git a/docs/install/karavan-helm/Chart.yaml b/docs/install/karavan-helm/Chart.yaml deleted file mode 100644 index 96d99b83..00000000 --- a/docs/install/karavan-helm/Chart.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: v2 -name: karavan -version: 0.1.0 -description: Chart Helm for deploy Karavan -appVersion: "4.18.1" \ No newline at end of file diff --git a/docs/install/karavan-helm/templates/deployment.yaml b/docs/install/karavan-helm/templates/deployment.yaml deleted file mode 100644 index c058629e..00000000 --- a/docs/install/karavan-helm/templates/deployment.yaml +++ /dev/null @@ -1,105 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ .Release.Name }} - labels: - app: {{ .Release.Name }} -spec: - replicas: {{ .Values.replicaCount }} - selector: - matchLabels: - app: {{ .Release.Name }} - template: - metadata: - labels: - app: {{ .Release.Name }} - spec: - containers: - - name: {{ .Release.Name }} - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - env: - - name: KARAVAN_CONTAINER_STATUS_INTERVAL - value: {{ .Values.env.KARAVAN_CONTAINER_STATUS_INTERVAL | quote }} - - name: KARAVAN_CONTAINER_STATISTICS_INTERVAL - value: {{ .Values.env.KARAVAN_CONTAINER_STATISTICS_INTERVAL | quote }} - - name: KARAVAN_CAMEL_STATUS_INTERVAL - value: {{ .Values.env.KARAVAN_CAMEL_STATUS_INTERVAL | quote }} - - name: KARAVAN_DEVMODE_IMAGE - value: {{ .Values.env.KARAVAN_DEVMODE_IMAGE | quote }} - - name: KARAVAN_GIT_REPOSITORY - valueFrom: - secretKeyRef: - key: karavan.git.repository - name: {{ .Values.secret.existingSecret | default .Release.Name }} - - name: KARAVAN_GIT_USERNAME - valueFrom: - secretKeyRef: - key: karavan.git.username - name: {{ .Values.secret.existingSecret | default .Release.Name }} - - name: KARAVAN_GIT_PASSWORD - valueFrom: - secretKeyRef: - key: karavan.git.password - name: {{ .Values.secret.existingSecret | default .Release.Name }} - - name: KARAVAN_GIT_BRANCH - valueFrom: - secretKeyRef: - key: karavan.git.branch - name: {{ .Values.secret.existingSecret | default .Release.Name }} - - name: KARAVAN_KEYCLOAK_URL - valueFrom: - secretKeyRef: - key: karavan.keycloak.url - name: {{ .Values.secret.existingSecret | default .Release.Name }} - - name: KARAVAN_KEYCLOAK_REALM - valueFrom: - secretKeyRef: - key: karavan.keycloak.realm - name: {{ .Values.secret.existingSecret | default .Release.Name }} - - name: KARAVAN_KEYCLOAK_FRONTEND_CLIENTID - valueFrom: - secretKeyRef: - key: karavan.keycloak.frontend.clientId - name: {{ .Values.secret.existingSecret | default .Release.Name }} - - name: KARAVAN_KEYCLOAK_BACKEND_CLIENTID - valueFrom: - secretKeyRef: - key: karavan.keycloak.backend.clientId - name: {{ .Values.secret.existingSecret | default .Release.Name }} - - name: KARAVAN_KEYCLOAK_BACKEND_SECRET - valueFrom: - secretKeyRef: - key: karavan.keycloak.backend.secret - name: {{ .Values.secret.existingSecret | default .Release.Name }} - - name: KARAVAN_CONTAINER_IMAGE_REGISTRY - valueFrom: - secretKeyRef: - key: karavan.container-image.registry - name: {{ .Values.secret.existingSecret | default .Release.Name }} - - name: KARAVAN_CONTAINER_IMAGE_GROUP - valueFrom: - secretKeyRef: - key: karavan.container-image.group - name: {{ .Values.secret.existingSecret | default .Release.Name }} - - name: KARAVAN_CONTAINER_IMAGE_REGISTRY_USERNAME - valueFrom: - secretKeyRef: - key: karavan.container-image.registry-username - name: {{ .Values.secret.existingSecret | default .Release.Name }} - - name: KARAVAN_CONTAINER_IMAGE_REGISTRY_PASSWORD - valueFrom: - secretKeyRef: - key: karavan.container-image.registry-password - name: {{ .Values.secret.existingSecret | default .Release.Name }} - - name: KUBERNETES_NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - ports: - - containerPort: {{ .Values.service.targetPort }} - name: karavan - resources: - requests: - memory: {{ .Values.resources.requests.memory | quote }} - serviceAccountName: {{ .Release.Name }} \ No newline at end of file diff --git a/docs/install/karavan-helm/templates/ingress.yaml b/docs/install/karavan-helm/templates/ingress.yaml deleted file mode 100644 index 36866b72..00000000 --- a/docs/install/karavan-helm/templates/ingress.yaml +++ /dev/null @@ -1,28 +0,0 @@ -{{- if .Values.ingress.enabled }} -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: {{ .Release.Name }} - annotations: - {{- with .Values.ingress.annotations }} - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - {{- if .Values.ingress.tls }} - tls: - - hosts: - - {{ .Values.ingress.host }} - secretName: {{ .Values.ingress.tlsSecretName }} - {{- end }} - rules: - - host: {{ .Values.ingress.host }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: {{ .Release.Name }} - port: - number: {{ .Values.service.port }} -{{- end }} \ No newline at end of file diff --git a/docs/install/karavan-helm/templates/nodePort.yaml b/docs/install/karavan-helm/templates/nodePort.yaml deleted file mode 100644 index e0655fcd..00000000 --- a/docs/install/karavan-helm/templates/nodePort.yaml +++ /dev/null @@ -1,15 +0,0 @@ -{{- if .Values.enableNodePort }} -apiVersion: v1 -kind: Service -metadata: - name: {{ .Release.Name }} -spec: - type: ClusterIP - ports: - - name: http - port: {{ .Values.service.port }} - targetPort: {{ .Values.service.targetPort }} - protocol: TCP - selector: - app: {{ .Release.Name }} -{{- end }} \ No newline at end of file diff --git a/docs/install/karavan-helm/templates/role-binding.yaml b/docs/install/karavan-helm/templates/role-binding.yaml deleted file mode 100644 index 89bca84c..00000000 --- a/docs/install/karavan-helm/templates/role-binding.yaml +++ /dev/null @@ -1,25 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: {{ .Release.Name }}-role-binding -roleRef: - kind: Role - apiGroup: rbac.authorization.k8s.io - name: {{ .Release.Name }} -subjects: -- kind: ServiceAccount - apiGroup: "" - name: {{ .Release.Name }} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: {{ .Release.Name }}-cluster-role-binding -roleRef: - kind: ClusterRole - apiGroup: rbac.authorization.k8s.io - name: view -subjects: -- kind: ServiceAccount - apiGroup: "" - name: {{ .Release.Name }} \ No newline at end of file diff --git a/docs/install/karavan-helm/templates/role.yaml b/docs/install/karavan-helm/templates/role.yaml deleted file mode 100644 index f72a73d7..00000000 --- a/docs/install/karavan-helm/templates/role.yaml +++ /dev/null @@ -1,29 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: {{ .Release.Name }} -rules: -- apiGroups: [""] - resources: ["secrets", "configmaps", "serviceaccounts"] - verbs: ["*"] -- apiGroups: [""] - resources: ["persistentvolumes", "persistentvolumeclaims"] - verbs: ["*"] -- apiGroups: [""] - resources: ["pods", "services", "replicationcontrollers"] - verbs: ["*"] -- apiGroups: ["route.openshift.io"] - resources: ["routes"] - verbs: ["*"] -- apiGroups: ["apps"] - resources: ["deployments"] - verbs: ["*"] -- apiGroups: ["networking.k8s.io"] - resources: ["ingresses"] - verbs: ["*"] -- apiGroups: ["image.openshift.io"] - resources: ["imagestreams/layers"] - verbs: ["update", "get"] -- apiGroups: [""] - resources: ["pods/exec"] - verbs: ["create", "get"] \ No newline at end of file diff --git a/docs/install/karavan-helm/templates/secret.yaml b/docs/install/karavan-helm/templates/secret.yaml deleted file mode 100644 index 67a02484..00000000 --- a/docs/install/karavan-helm/templates/secret.yaml +++ /dev/null @@ -1,20 +0,0 @@ -{{- if not .Values.secret.existingSecret }} -apiVersion: v1 -kind: Secret -metadata: - name: {{ .Release.Name }} -stringData: - karavan.keycloak.url: {{ .Values.secret.keycloak.url | quote }} - karavan.keycloak.realm: {{ .Values.secret.keycloak.realm | quote }} - karavan.keycloak.frontend.clientId: {{ .Values.secret.keycloak.frontendClientId | quote }} - karavan.keycloak.backend.clientId: {{ .Values.secret.keycloak.backendClientId | quote }} - karavan.keycloak.backend.secret: {{ .Values.secret.keycloak.backendSecret | quote }} - karavan.git.repository: {{ .Values.secret.git.repository | quote }} - karavan.git.username: {{ .Values.secret.git.username | quote }} - karavan.git.password: {{ .Values.secret.git.password | quote }} - karavan.git.branch: {{ .Values.secret.git.branch | quote }} - karavan.container-image.registry: {{ .Values.secret.containerImage.registry | quote }} - karavan.container-image.group: {{ .Values.secret.containerImage.group | quote }} - karavan.container-image.registry-username: {{ .Values.secret.containerImage.registryUsername | quote }} - karavan.container-image.registry-password: {{ .Values.secret.containerImage.registryPassword | quote }} -{{- end }} \ No newline at end of file diff --git a/docs/install/karavan-helm/templates/service-account.yaml b/docs/install/karavan-helm/templates/service-account.yaml deleted file mode 100644 index 7e1bad4c..00000000 --- a/docs/install/karavan-helm/templates/service-account.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: {{ .Release.Name }} \ No newline at end of file diff --git a/docs/install/karavan-helm/templates/service.yaml b/docs/install/karavan-helm/templates/service.yaml deleted file mode 100644 index 9643e52b..00000000 --- a/docs/install/karavan-helm/templates/service.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ .Release.Name }} -spec: - type: {{ .Values.service.type }} - ports: - - name: http - port: {{ .Values.service.port }} - targetPort: {{ .Values.service.targetPort }} - {{- if and (eq .Values.service.type "NodePort") .Values.service.enableNodePort }} - nodePort: {{ .Values.service.nodePort }} - {{- end }} - - name: https - port: {{ .Values.service.httpsPort }} - targetPort: {{ .Values.service.targetPort }} - selector: - app: {{ .Release.Name }} \ No newline at end of file diff --git a/docs/install/karavan-helm/values.yaml b/docs/install/karavan-helm/values.yaml deleted file mode 100644 index 96995368..00000000 --- a/docs/install/karavan-helm/values.yaml +++ /dev/null @@ -1,52 +0,0 @@ -replicaCount: 1 - -image: - repository: ghcr.io/apache/camel-karavan - tag: "4.18.1" - pullPolicy: Always - -env: - KARAVAN_CONTAINER_STATUS_INTERVAL: "disabled" - KARAVAN_CONTAINER_STATISTICS_INTERVAL: "disabled" - KARAVAN_CAMEL_STATUS_INTERVAL: "3s" - KARAVAN_DEVMODE_IMAGE: "ghcr.io/apache/camel-karavan-devmode:4.18.1" - -service: - type: ClusterIP - port: 80 - targetPort: 8080 - nodePort: 30777 - enableNodePort: false - httpsPort: 443 - -resources: - requests: - memory: "1024Mi" - -secret: - # Set the following if you want to use an existing Secret. In this case everything set below will be ignored. - # existingSecret: karavan - keycloak: - url: "https://localhost" - realm: "karavan" - frontendClientId: "karavan" - backendClientId: "karavan" - backendSecret: "secret" - git: - repository: "http://reporitory/karavan/karavan.git" - username: "karavan" - password: "karavan" - branch: "main" - containerImage: - registry: "registry:5000" - group: "karavan" - registryUsername: "" - registryPassword: "" - -ingress: - enabled: true - host: "hostname.local" - annotations: - kubernetes.io/ingress.class: nginx - tls: false - tlsSecretName: "" \ No newline at end of file diff --git a/docs/install/karavan-kubernetes/role-binding.yaml b/docs/install/karavan-kubernetes/role-binding.yaml index 46767c0a..0f4c1f2b 100644 --- a/docs/install/karavan-kubernetes/role-binding.yaml +++ b/docs/install/karavan-kubernetes/role-binding.yaml @@ -8,19 +8,6 @@ roleRef: apiGroup: "rbac.authorization.k8s.io" name: "karavan" subjects: -- kind: "ServiceAccount" - apiGroup: "" - name: "karavan" ---- -apiVersion: "rbac.authorization.k8s.io/v1" -kind: "RoleBinding" -metadata: - name: "karavan-cluster-role-binding" -roleRef: - kind: "ClusterRole" - apiGroup: "rbac.authorization.k8s.io" - name: "view" -subjects: - kind: "ServiceAccount" apiGroup: "" name: "karavan" \ No newline at end of file diff --git a/docs/install/karavan-kubernetes/role.yaml b/docs/install/karavan-kubernetes/role.yaml index 2807523f..c62cd8a8 100644 --- a/docs/install/karavan-kubernetes/role.yaml +++ b/docs/install/karavan-kubernetes/role.yaml @@ -4,27 +4,52 @@ kind: "Role" metadata: name: "karavan" rules: +# Karavan secret/configmap management + configuration of deployed integrations - apiGroups: [""] - resources: ["secrets", "configmaps", "serviceaccounts"] - verbs: ["*"] + resources: ["secrets", "configmaps"] + verbs: ["get", "list", "create", "update", "patch", "delete"] +# Read-only: resolving the ServiceAccount referenced by generated pod specs - apiGroups: [""] - resources: ["persistentvolumes", "persistentvolumeclaims"] - verbs: ["*"] + resources: ["serviceaccounts"] + verbs: ["get", "list"] +# Maven cache volume for devmode pods (karavan.devmode.createm2) - apiGroups: [""] - resources: ["pods", "services", "replicationcontrollers"] - verbs: ["*"] -- apiGroups: ["route.openshift.io"] - resources: ["routes"] - verbs: ["*"] + resources: ["persistentvolumeclaims"] + verbs: ["get", "create", "patch", "delete"] +# devmode and builder pods + pod status informer +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch", "create", "patch", "delete"] +# Streaming container logs to the UI +- apiGroups: [""] + resources: ["pods/log"] + verbs: ["get"] +# Services for devmode pods and deployed integrations + service status informer +- apiGroups: [""] + resources: ["services"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# Pod events shown in the UI +- apiGroups: ["", "events.k8s.io"] + resources: ["events"] + verbs: ["get", "list", "watch"] +# Deployed integrations, rollout restart + deployment status informer - apiGroups: ["apps"] resources: ["deployments"] - verbs: ["*"] -- apiGroups: ["networking.k8s.io"] - resources: ["ingresses"] - verbs: ["*"] -- apiGroups: ["image.openshift.io"] - resources: ["imagestreams/layers"] - verbs: ["update", "get"] -- apiGroups: [""] - resources: ["pods/exec"] - verbs: ["create", "get"] \ No newline at end of file + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + +# --- OpenShift only ------------------------------------------------------- +# Uncomment when running on OpenShift and integrations are exposed through +# Routes and/or images are pushed to the internal image registry. +# - apiGroups: ["route.openshift.io"] +# resources: ["routes"] +# verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# - apiGroups: ["image.openshift.io"] +# resources: ["imagestreams/layers"] +# verbs: ["get", "update"] + +# --- Optional ------------------------------------------------------------- +# Uncomment only if integrations are exposed through Ingress resources +# (JKube creates them only when -Djkube.createExternalUrls=true). +# - apiGroups: ["networking.k8s.io"] +# resources: ["ingresses"] +# verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] diff --git a/karavan-app/src/main/java/org/apache/camel/karavan/api/InfrastructureResource.java b/karavan-app/src/main/java/org/apache/camel/karavan/api/InfrastructureResource.java index 409e2221..c55cddf1 100644 --- a/karavan-app/src/main/java/org/apache/camel/karavan/api/InfrastructureResource.java +++ b/karavan-app/src/main/java/org/apache/camel/karavan/api/InfrastructureResource.java @@ -96,7 +96,12 @@ public class InfrastructureResource { if (resources == null) { return Response.status(Response.Status.NOT_FOUND).entity("Resource file " + KUBERNETES_YAML_FILENAME + " not found").build(); } - kubernetesService.startDeployment(resources.getCode(), Map.of(LABEL_TYPE, ContainerType.packaged.name())); + try { + kubernetesService.startDeployment(resources.getCode(), Map.of(LABEL_TYPE, ContainerType.packaged.name())); + } catch (IllegalArgumentException e) { + LOGGER.error("Rejected " + name + " of project " + projectId + ": " + e.getMessage()); + return Response.status(Response.Status.BAD_REQUEST).entity(e.getMessage()).build(); + } return Response.ok().build(); } diff --git a/karavan-app/src/main/java/org/apache/camel/karavan/kubernetes/KubernetesService.java b/karavan-app/src/main/java/org/apache/camel/karavan/kubernetes/KubernetesService.java index 7f3516ff..b11ecb2a 100644 --- a/karavan-app/src/main/java/org/apache/camel/karavan/kubernetes/KubernetesService.java +++ b/karavan-app/src/main/java/org/apache/camel/karavan/kubernetes/KubernetesService.java @@ -20,7 +20,6 @@ import io.fabric8.kubernetes.api.model.*; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientBuilder; -import io.fabric8.kubernetes.client.dsl.ExecWatch; import io.fabric8.kubernetes.client.dsl.LogWatch; import io.fabric8.kubernetes.client.utils.Serialization; import io.quarkus.runtime.LaunchMode; @@ -39,10 +38,11 @@ import org.apache.camel.karavan.service.ConfigService; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; -import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.apache.camel.karavan.KaravanConstants.*; import static org.apache.camel.karavan.service.CodeService.CAMEL_OBSERVABILITY_PORT; @@ -53,6 +53,10 @@ public class KubernetesService { private static final Logger LOGGER = Logger.getLogger(KubernetesService.class.getName()); + // Kinds accepted in the user editable kubernetes.yaml resource file of a project. + // Anything else (Pod, DaemonSet, ClusterRoleBinding, ...) is rejected before reaching the API server. + public static final String DEFAULT_ALLOWED_DEPLOYMENT_KINDS = "Deployment,Service,ConfigMap,Secret"; + public static final Map<String, Quantity> DEFAULT_CONTAINER_RESOURCES = Map.of( "requests.memory", new Quantity("256Mi"), "requests.cpu", new Quantity("500m"), @@ -97,6 +101,15 @@ public class KubernetesService { @ConfigProperty(name = "karavan.openshift") Optional<Boolean> isOpenShift; + // Extend only together with the matching Kubernetes RBAC permissions, e.g. "Route" on OpenShift + @ConfigProperty(name = "karavan.deployment.allowed-kinds", defaultValue = DEFAULT_ALLOWED_DEPLOYMENT_KINDS) + String allowedDeploymentKinds; + + private Set<String> getAllowedDeploymentKinds() { + return Arrays.stream(allowedDeploymentKinds.split(",")) + .map(String::trim).filter(s -> !s.isEmpty()).collect(Collectors.toSet()); + } + public void runBuildProject(String projectId, String podFragment, Map<String, String> envVars) { try (KubernetesClient client = kubernetesClient()) { String containerName = projectId + BUILDER_SUFFIX; @@ -247,15 +260,33 @@ public class KubernetesService { } public void startDeployment(String resources, Map<String, String> labels) { + KubernetesList list; + try { + list = Serialization.unmarshal(resources, KubernetesList.class); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid Kubernetes resources: " + e.getMessage()); + } + if (list == null || list.getItems() == null || list.getItems().isEmpty()) { + throw new IllegalArgumentException("No Kubernetes resources to apply"); + } + // The resource file is user editable, so it can not be trusted: only the resource kinds + // an integration is made of are accepted, and pod specs must not ask for host level access. + Set<String> allowedKinds = getAllowedDeploymentKinds(); + list.getItems().forEach(item -> validateDeploymentResource(item, allowedKinds)); try (KubernetesClient client = kubernetesClient()) { - KubernetesList list = Serialization.unmarshal(resources, KubernetesList.class); list.getItems().forEach(item -> { - if (labels != null ) { - item.getMetadata().getLabels().putAll(labels); - if (item instanceof Deployment deployment) { - deployment.getSpec().getTemplate().getMetadata().getLabels().putAll(labels); + if (labels != null) { + putLabels(item.getMetadata(), labels); + if (item instanceof Deployment deployment && deployment.getSpec() != null && deployment.getSpec().getTemplate() != null) { + var template = deployment.getSpec().getTemplate(); + if (template.getMetadata() == null) { + template.setMetadata(new ObjectMeta()); + } + putLabels(template.getMetadata(), labels); } } + // Pin the namespace: a resource must never be applied outside of Karavan's namespace + item.getMetadata().setNamespace(getNamespace()); client.resource(item).inNamespace(getNamespace()).serverSideApply(); }); } catch (Exception ex) { @@ -263,6 +294,72 @@ public class KubernetesService { } } + private static void putLabels(ObjectMeta meta, Map<String, String> labels) { + if (meta.getLabels() == null) { + meta.setLabels(new HashMap<>()); + } + meta.getLabels().putAll(labels); + } + + static void validateDeploymentResource(HasMetadata item, Set<String> allowedKinds) { + String kind = item.getKind(); + if (!allowedKinds.contains(kind)) { + throw new IllegalArgumentException("Resource kind is not allowed: " + kind + + ". Allowed kinds: " + String.join(", ", allowedKinds)); + } + if (item.getMetadata() == null || item.getMetadata().getName() == null) { + throw new IllegalArgumentException("Resource of kind " + kind + " has no metadata.name"); + } + if (item instanceof Deployment deployment && deployment.getSpec() != null && deployment.getSpec().getTemplate() != null) { + validatePodSpec(deployment.getSpec().getTemplate().getSpec()); + } + } + + private static void validatePodSpec(PodSpec spec) { + if (spec == null) { + return; + } + if (Boolean.TRUE.equals(spec.getHostNetwork())) { + throw new IllegalArgumentException("hostNetwork is not allowed"); + } + if (Boolean.TRUE.equals(spec.getHostPID())) { + throw new IllegalArgumentException("hostPID is not allowed"); + } + if (Boolean.TRUE.equals(spec.getHostIPC())) { + throw new IllegalArgumentException("hostIPC is not allowed"); + } + if (spec.getVolumes() != null) { + spec.getVolumes().stream().filter(v -> v.getHostPath() != null).findFirst().ifPresent(v -> { + throw new IllegalArgumentException("hostPath volume is not allowed: " + v.getName()); + }); + } + Stream.concat( + spec.getContainers() != null ? spec.getContainers().stream() : Stream.empty(), + spec.getInitContainers() != null ? spec.getInitContainers().stream() : Stream.empty() + ).forEach(KubernetesService::validateContainer); + } + + private static void validateContainer(Container container) { + SecurityContext securityContext = container.getSecurityContext(); + if (securityContext != null) { + if (Boolean.TRUE.equals(securityContext.getPrivileged())) { + throw new IllegalArgumentException("privileged container is not allowed: " + container.getName()); + } + if (Boolean.TRUE.equals(securityContext.getAllowPrivilegeEscalation())) { + throw new IllegalArgumentException("allowPrivilegeEscalation is not allowed: " + container.getName()); + } + if (securityContext.getCapabilities() != null && securityContext.getCapabilities().getAdd() != null + && !securityContext.getCapabilities().getAdd().isEmpty()) { + throw new IllegalArgumentException("adding Linux capabilities is not allowed: " + container.getName()); + } + } + if (container.getPorts() != null) { + container.getPorts().stream().filter(p -> p.getHostPort() != null).findFirst().ifPresent(p -> { + throw new IllegalArgumentException("hostPort is not allowed: " + container.getName()); + }); + } + } + public void deleteDeployment(String name) { try (KubernetesClient client = kubernetesClient()) { LOGGER.info("Delete deployment: " + name + " in the namespace: " + getNamespace()); @@ -731,43 +828,4 @@ public class KubernetesService { } return list; } - - public void execCommandInDeployment(String deploymentName, String command) { - try (KubernetesClient client = kubernetesClient()) { - Deployment deployment = client.apps().deployments().inNamespace(getNamespace()).withName(deploymentName).get(); - - if (deployment != null && deployment.getSpec().getSelector() != null) { - Map<String, String> matchLabels = deployment.getSpec().getSelector().getMatchLabels(); - List<Pod> pods = client.pods().inNamespace(getNamespace()).withLabels(matchLabels).list().getItems(); - - if (!pods.isEmpty()) { - Pod pod = pods.getFirst(); - String podName = pod.getMetadata().getName(); - String containerName = pod.getSpec().getContainers().getFirst().getName(); - - LOGGER.info("Executing command in pod " + podName + " (container: " + containerName + ")"); - - try (ExecWatch watch = client.pods().inNamespace(getNamespace()).withName(podName) - .inContainer(containerName) - .writingOutput(OutputStream.nullOutputStream()) - .writingError(OutputStream.nullOutputStream()) - .exec("sh", "-c", command)) { - - // Block the thread until the command finishes (or times out after 30s) - // This keeps the WebSocket open long enough for the command to run. - watch.exitCode().get(30, TimeUnit.SECONDS); - - } catch (Exception e) { - LOGGER.error("Exec failed or timed out: " + e.getMessage()); - } - } else { - LOGGER.warn("No pods found for deployment: " + deploymentName); - } - } else { - LOGGER.warn("Deployment not found or has no selector: " + deploymentName); - } - } catch (Exception e) { - LOGGER.error("Error executing command in container for deployment " + deploymentName + ": " + e.getMessage(), e); - } - } } diff --git a/karavan-app/src/main/resources/application.properties b/karavan-app/src/main/resources/application.properties index d841ed32..765d6a34 100644 --- a/karavan-app/src/main/resources/application.properties +++ b/karavan-app/src/main/resources/application.properties @@ -31,6 +31,10 @@ karavan.builder.service.account=karavan karavan.secret.name=karavan karavan.docker.network=karavan +# Resource kinds accepted in a project kubernetes.yaml file. Extend only together +# with the matching Kubernetes RBAC permissions, e.g. add "Route" on OpenShift. +deployment.allowed-kinds=Deployment,Service,ConfigMap,Secret,Ingress + # Keycloak configuration karavan.keycloak.url=http://localhost:8079 karavan.keycloak.realm=karavan
