From faae1292cdc9977bdbd882d51bd7062ff10aa529 Mon Sep 17 00:00:00 2001 From: Serhiy Date: Tue, 27 Feb 2024 17:23:08 +0100 Subject: [PATCH 1/6] Provide new field "seccompProfile" in PodSecurityContext model. Update json parser to support new field during serialization/deserialization --- client/src/main/scala/skuber/Security.scala | 68 +++++++++++-------- .../src/main/scala/skuber/json/package.scala | 41 ++++++++++- 2 files changed, 79 insertions(+), 30 deletions(-) diff --git a/client/src/main/scala/skuber/Security.scala b/client/src/main/scala/skuber/Security.scala index 072dfb25..10c339f7 100644 --- a/client/src/main/scala/skuber/Security.scala +++ b/client/src/main/scala/skuber/Security.scala @@ -1,40 +1,54 @@ package skuber /** - * @author David O'Riordan - */ + * @author David O'Riordan + */ import Security._ -case class SecurityContext(allowPrivilegeEscalation: Option[Boolean] = None, - capabilities: Option[Capabilities] = None, - privileged: Option[Boolean] = None, - readOnlyRootFilesystem: Option[Boolean] = None, - runAsGroup: Option[Int] = None, - runAsNonRoot: Option[Boolean] = None, - runAsUser: Option[Int] = None, - seLinuxOptions: Option[SELinuxOptions] = None) - -case class PodSecurityContext(fsGroup: Option[Int] = None, - runAsGroup: Option[Int] = None, - runAsNonRoot: Option[Boolean] = None, - runAsUser: Option[Int] = None, - seLinuxOptions: Option[SELinuxOptions] = None, - supplementalGroups: List[Int] = Nil, - sysctls: List[Sysctl] = Nil) +case class SecurityContext( + allowPrivilegeEscalation: Option[Boolean] = None, + capabilities: Option[Capabilities] = None, + privileged: Option[Boolean] = None, + readOnlyRootFilesystem: Option[Boolean] = None, + runAsGroup: Option[Int] = None, + runAsNonRoot: Option[Boolean] = None, + runAsUser: Option[Int] = None, + seLinuxOptions: Option[SELinuxOptions] = None +) + +case class PodSecurityContext( + fsGroup: Option[Int] = None, + runAsGroup: Option[Int] = None, + runAsNonRoot: Option[Boolean] = None, + runAsUser: Option[Int] = None, + seLinuxOptions: Option[SELinuxOptions] = None, + supplementalGroups: List[Int] = Nil, + sysctls: List[Sysctl] = Nil, + seccompProfile: Option[SeccompProfile] = None +) object Security { type Capability = String + type SeccompProfileType = String - case class Capabilities(add: List[Capability] = Nil, - drop: List[Capability] = Nil) + case class Capabilities(add: List[Capability] = Nil, drop: List[Capability] = Nil) - case class SELinuxOptions(user: String = "", - role: String = "", - _type: String = "", - level: String = "") + case class SELinuxOptions(user: String = "", role: String = "", _type: String = "", level: String = "") - case class Sysctl(name: String, - value: String) + case class Sysctl(name: String, value: String) -} \ No newline at end of file + sealed trait SeccompProfile { + val _type: SeccompProfileType + } + case class UnconfinedProfile() extends SeccompProfile { + override val _type: SeccompProfileType = "Unconfined" + } + case class RuntimeDefaultProfile() extends SeccompProfile { + override val _type: SeccompProfileType = "RuntimeDefault" + } + case class LocalhostProfile(localhostProfile: String) extends SeccompProfile { + override val _type: SeccompProfileType = "Localhost" + } + +} diff --git a/client/src/main/scala/skuber/json/package.scala b/client/src/main/scala/skuber/json/package.scala index 6bdd0c10..47d819ed 100644 --- a/client/src/main/scala/skuber/json/package.scala +++ b/client/src/main/scala/skuber/json/package.scala @@ -105,6 +105,40 @@ package object format { } } + implicit val seccompProfileFmt: Format[Security.SeccompProfile] = new Format[Security.SeccompProfile] { + + override def reads(json: JsValue): JsResult[Security.SeccompProfile] = json match { + case JsObject(fields) => + fields.get("type") match { + case Some(JsString("Unconfined")) => + JsSuccess(Security.UnconfinedProfile()) + case Some(JsString("RuntimeDefault")) => + JsSuccess(Security.RuntimeDefaultProfile()) + case Some(JsString("Localhost")) => + val profileConfigPath: String = fields("localhostProfile").as[String] + JsSuccess(Security.LocalhostProfile(profileConfigPath)) + case operator => JsError(s"Unknown Seccomp profile '$operator'") + } + + case _ => JsError(s"Unknown Seccomp") + } + + override def writes(seccomp: Security.SeccompProfile): JsValue = seccomp match { + + case p @ Security.UnconfinedProfile() => + val fields: List[(String, JsValue)] = List("type" -> JsString(p._type)) + JsObject(fields) + case p @ Security.RuntimeDefaultProfile() => + val fields: List[(String, JsValue)] = List("type" -> JsString(p._type)) + JsObject(fields) + case p @ Security.LocalhostProfile(localhostProfile) => + val fields: List[(String, JsValue)] = List( + "type" -> JsString(p._type), + "localhostProfile" -> JsString(localhostProfile)) + JsObject(fields) + } + } + private def otwSelectorToLabelSelector(otws: OnTheWireSelector): LabelSelector = { val equalityBasedReqsOpt: Option[List[IsEqualRequirement]] = otws.matchLabels.map { labelKVMap => labelKVMap.map(kv => IsEqualRequirement(kv._1, kv._2)).toList @@ -233,8 +267,9 @@ package object format { (JsPath \ "runAsUser").formatNullable[Int] and (JsPath \ "seLinuxOptions").formatNullable[Security.SELinuxOptions] and (JsPath \ "supplementalGroups").formatMaybeEmptyList[Int] and - (JsPath \ "sysctls").formatMaybeEmptyList[Security.Sysctl]) (PodSecurityContext.apply, - p => (p.fsGroup, p.runAsGroup, p.runAsNonRoot, p.runAsUser, p.seLinuxOptions, p.supplementalGroups, p.sysctls)) + (JsPath \ "sysctls").formatMaybeEmptyList[Security.Sysctl] and + (JsPath \ "seccompProfile").formatNullable[Security.SeccompProfile]) (PodSecurityContext.apply, + p => (p.fsGroup, p.runAsGroup, p.runAsNonRoot, p.runAsUser, p.seLinuxOptions, p.supplementalGroups, p.sysctls, p.seccompProfile)) implicit val tolerationEffectFmt: Format[Pod.TolerationEffect] = new Format[Pod.TolerationEffect] { @@ -984,7 +1019,7 @@ package object format { import skuber.api.client._ - // this handler reads a generic Status response from the server + // this handler reads a generic Status response from the server implicit val statusReads: Reads[Status] = Json.reads[Status] def watchEventWrapperReads[T <: ObjectResource](implicit objreads: Reads[T]): Reads[WatchEventWrapper[T]] = ((JsPath \ "type").formatEnum(EventType).flatMap { eventType => From 8bc94fe2b61537682fd4d131bdf4076d5003b79c Mon Sep 17 00:00:00 2001 From: Serhiy Date: Tue, 27 Feb 2024 17:24:01 +0100 Subject: [PATCH 2/6] update tests, add new test cases for seccompProfile --- .../scala/skuber/json/PodFormatSpec.scala | 91 ++++++++++++------- 1 file changed, 59 insertions(+), 32 deletions(-) diff --git a/client/src/test/scala/skuber/json/PodFormatSpec.scala b/client/src/test/scala/skuber/json/PodFormatSpec.scala index 1a55c5f6..477c2eb9 100644 --- a/client/src/test/scala/skuber/json/PodFormatSpec.scala +++ b/client/src/test/scala/skuber/json/PodFormatSpec.scala @@ -17,21 +17,21 @@ import scala.io.Source */ class PodFormatSpec extends Specification { "This is a unit specification for the skuber Pod related json formatter.\n ".txt - + import Pod._ - + // Pod reader and writer "A Pod can be symmetrically written to json and the same value read back in\n" >> { "this can be done for a simple Pod with just a name" >> { val myPod = Pod.named("myPod") val readPod = Json.fromJson[Pod](Json.toJson(myPod)).get - myPod mustEqual readPod + myPod mustEqual readPod } "this can be done for a simple Pod with just a name and namespace set" >> { val myPod = Namespace("myNamespace").pod("myPod") val readPod = Json.fromJson[Pod](Json.toJson(myPod)).get - myPod mustEqual readPod - } + myPod mustEqual readPod + } "this can be done for a Pod with a simple, single container spec" >> { val myPod = Namespace("myNamespace"). pod("myPod",Spec(Container("myContainer", "myImage")::Nil)) @@ -49,14 +49,14 @@ import Pod._ periodSeconds = Some(10), failureThreshold = Some(30)) val cntrs=List(Container("myContainer", "myImage"), - Container(name="myContainer2", - image = "myImage2", + Container(name="myContainer2", + image = "myImage2", command=List("bash","ls"), workingDir=Some("/home/skuber"), ports=List(Container.Port(3234), Container.Port(3256,name="svc", hostIP="10.101.35.56")), env=List(EnvVar("HOME", "/home/skuber")), - resources=Some(Resource.Requirements(limits=Map("cpu" -> "0.1"))), - volumeMounts=List(Volume.Mount("mnt1","/mt1"), + resources=Some(Resource.Requirements(limits=Map("cpu" -> "0.1"))), + volumeMounts=List(Volume.Mount("mnt1","/mt1"), Volume.Mount("mnt2","/mt2", readOnly = true)), readinessProbe=Some(readyProbe), startupProbe=Some(startupProbe), @@ -71,18 +71,18 @@ import Pod._ dnsPolicy=DNSPolicy.ClusterFirst, nodeSelector=Map("diskType" -> "ssd", "machineSize" -> "large"), imagePullSecrets=List(LocalObjectReference("abc"),LocalObjectReference("def")), - securityContext=Some(PodSecurityContext(supplementalGroups=List(1, 2, 3)))) + securityContext=Some(PodSecurityContext(supplementalGroups=List(1, 2, 3), seccompProfile = Some(Security.RuntimeDefaultProfile())))) val myPod = Namespace("myNamespace").pod("myPod",pdSpec) - + val writtenPod = Json.toJson(myPod) val strs=Json.stringify(writtenPod) val readPodJsResult = Json.fromJson[Pod](writtenPod) - + val ret: Result = readPodJsResult match { - case JsError(e) => Failure(e.toString) - case JsSuccess(readPod,_) => + case JsError(e) => Failure(e.toString) + case JsSuccess(readPod,_) => readPod mustEqual myPod - } + } ret } "a quite complex pod can be read from json" >> { @@ -108,6 +108,11 @@ import Pod._ } }, "spec": { + "securityContext": { + "seccompProfile": { + "type": "RuntimeDefault" + } + }, "volumes": [ { "name": "dns-token", @@ -312,7 +317,7 @@ import Pod._ myPod.kind mustEqual "Pod" myPod.name mustEqual "kube-dns-v3-i5fzg" myPod.metadata.labels("k8s-app") mustEqual "kube-dns" - + myPod.spec.get.dnsPolicy mustEqual DNSPolicy.Default myPod.spec.get.restartPolicy mustEqual RestartPolicy.Always myPod.spec.get.tolerations mustEqual List(ExistsToleration(Some("localhost.domain/url")), @@ -322,7 +327,7 @@ import Pod._ val vols = myPod.spec.get.volumes vols.length mustEqual 2 vols(0) mustEqual Volume("dns-token",Volume.Secret("token-system-dns")) - + val cntrs = myPod.spec.get.containers cntrs.length mustEqual 3 cntrs(0).name mustEqual "etcd" @@ -331,34 +336,34 @@ import Pod._ cntrs(0).terminationMessagePolicy mustEqual Some(Container.TerminationMessagePolicy.File) cntrs(0).resources.get.limits("cpu") mustEqual Resource.Quantity("100m") cntrs(0).command.length mustEqual 7 - + val etcdVolMounts=cntrs(0).volumeMounts etcdVolMounts.length mustEqual 1 etcdVolMounts(0).name mustEqual "default-token-zmwgp" - - val probe = cntrs(2).livenessProbe.get + + val probe = cntrs(2).livenessProbe.get probe.action match { case ExecAction(command) => command.length mustEqual 3 case _ => failure("liveness probe action must be an ExecAction") } probe.initialDelaySeconds mustEqual 30 probe.timeoutSeconds mustEqual 5 - + val ports = cntrs(2).ports // skyDNS ports ports.length mustEqual 2 val udpDnsPort = ports(0) udpDnsPort.containerPort mustEqual 53 udpDnsPort.protocol mustEqual Protocol.UDP udpDnsPort.name mustEqual "dns" - + val tcpDnsPort = ports(1) tcpDnsPort.containerPort mustEqual 53 tcpDnsPort.protocol mustEqual Protocol.TCP tcpDnsPort.name mustEqual "dns-tcp" - + cntrs(2).image equals "gcr.io/google_containers/skydns:2015-03-11-001" cntrs(2).imagePullPolicy equals None - + val status = myPod.status.get status.conditions(0) mustEqual Pod.Condition("Ready","False") status.phase.get mustEqual Pod.Phase.Running @@ -366,17 +371,17 @@ import Pod._ cntrStatuses.length mustEqual 3 cntrStatuses(0).restartCount mustEqual 3 cntrStatuses(0).lastState.get match { - case c: Container.Terminated => - c.exitCode mustEqual 2 + case c: Container.Terminated => + c.exitCode mustEqual 2 c.containerID.get mustEqual "docker://ec96c0a87e374d1b2f309c102b13e88a2605a6df0017472a6d7f808b559324aa" case _ => failure("container must be terminated") } cntrStatuses(2).state.get match { - case Container.Running(startTime) if (startTime.nonEmpty) => + case Container.Running(startTime) if (startTime.nonEmpty) => startTime.get.getHour mustEqual 16 // just a spot check } // write and read back in again, compare - val readPod = Json.fromJson[Pod](Json.toJson(myPod)).get + val readPod = Json.fromJson[Pod](Json.toJson(myPod)).get myPod mustEqual readPod } @@ -482,7 +487,7 @@ import Pod._ import NodeAffinity.{PreferredSchedulingTerm, PreferredSchedulingTerms} val affinityJsonSource = Source.fromURL(getClass.getResource("/exampleAffinityNoRequirements.json")) - + val affinityJsonStr = affinityJsonSource.mkString val myAffinity = Json.parse(affinityJsonStr).as[Affinity] @@ -496,7 +501,7 @@ import Pod._ import Affinity.{NodeAffinity, NodeSelectorOperator} val affinityJsonSource = s"""{ "nodeAffinity": { "preferredDuringSchedulingIgnoredDuringExecution": [ { "weight": 1, "preference": { "matchExpressions": [ { "key": "another-node-label-key", "operator": "In", "values": [ "another-node-label-value" ] } ] } } ] } }""" - + val myAffinity = Json.parse(affinityJsonSource).as[Affinity] myAffinity must_== Affinity(nodeAffinity = Some(NodeAffinity(requiredDuringSchedulingIgnoredDuringExecution = None, @@ -508,7 +513,7 @@ import Pod._ "a complex podlist can be read and written as json" >> { val podListJsonSource = Source.fromURL(getClass.getResource("/examplePodList.json")) val podListJsonStr = podListJsonSource.mkString - + val myPods = Json.parse(podListJsonStr).as[PodList] myPods.kind mustEqual "PodList" myPods.metadata.get.resourceVersion mustEqual "977" @@ -516,10 +521,32 @@ import Pod._ myPods.items(21).status.get.containerStatuses.exists( cs => cs.name.equals("grafana")) mustEqual true // write and read back in again, compare - val readPods = Json.fromJson[PodList](Json.toJson(myPods)).get + val readPods = Json.fromJson[PodList](Json.toJson(myPods)).get myPods mustEqual readPods } + "Pod SecurityContext with RuntimeDefault seccomp profile can be properly read and written as json" >> { + import Security.RuntimeDefaultProfile + + val podSecurityContextJsonSource = s"""{ "seccompProfile": { "type": "RuntimeDefault" } }""" + + val myPodSecurityContext = Json.parse(podSecurityContextJsonSource).as[PodSecurityContext] + myPodSecurityContext must_== PodSecurityContext(seccompProfile = Some(RuntimeDefaultProfile())) + val readPodSecurityContext = Json.fromJson[PodSecurityContext](Json.toJson(myPodSecurityContext)).get + myPodSecurityContext mustEqual readPodSecurityContext + } + + "Pod SecurityContext with Localhost seccomp profile can be properly read and written as json" >> { + import Security.LocalhostProfile + + val podSecurityContextJsonSource = s"""{ "seccompProfile": { "type": "Localhost", "localhostProfile": "custom.json" } }""" + + val myPodSecurityContext = Json.parse(podSecurityContextJsonSource).as[PodSecurityContext] + myPodSecurityContext must_== PodSecurityContext(seccompProfile = Some(LocalhostProfile(localhostProfile = "custom.json"))) + val readPodSecurityContext = Json.fromJson[PodSecurityContext](Json.toJson(myPodSecurityContext)).get + myPodSecurityContext mustEqual readPodSecurityContext + } + "a statefulset with pod affinity/anti-affinity can be read and written as json successfully" >> { val ssJsonSource=s"""{ "apiVersion": "apps/v1beta1", "kind": "StatefulSet", "metadata": { "name": "nginx-with-pod-affinity", "labels": { "app": "nginx", "security": "S1" } }, "spec": { "serviceName": "nginx", "replicas": 10, "selector": { "matchLabels": { "app": "nginx" } }, "template": { "metadata": { "labels": { "app": "nginx" } }, "spec": { "affinity": { "podAffinity": { "requiredDuringSchedulingIgnoredDuringExecution": [ { "labelSelector": { "matchExpressions": [{ "key": "security", "operator": "In", "values": [ "S1" ] }] }, "topologyKey": "failure-domain.beta.kubernetes.io/zone" } ] }, "podAntiAffinity": { "preferredDuringSchedulingIgnoredDuringExecution": [ { "weight": 100, "podAffinityTerm": { "labelSelector": { "matchExpressions": [{ "key": "security", "operator": "In", "values": [ "S2" ] }] }, "topologyKey": "kubernetes.io/hostname" } } ] } }, "containers": [ { "name": "nginx", "image": "nginx" } ] } } } }""" From 9b7cb78e3591644acbfa36e0a90df4966dce4d65 Mon Sep 17 00:00:00 2001 From: Serhiy Date: Wed, 28 Feb 2024 18:03:32 +0100 Subject: [PATCH 3/6] review changes --- client/src/main/scala/skuber/Security.scala | 3 +++ client/src/main/scala/skuber/json/package.scala | 5 +++-- client/src/test/scala/skuber/json/PodFormatSpec.scala | 11 +++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/client/src/main/scala/skuber/Security.scala b/client/src/main/scala/skuber/Security.scala index 10c339f7..ce9c3ed1 100644 --- a/client/src/main/scala/skuber/Security.scala +++ b/client/src/main/scala/skuber/Security.scala @@ -50,5 +50,8 @@ object Security { case class LocalhostProfile(localhostProfile: String) extends SeccompProfile { override val _type: SeccompProfileType = "Localhost" } + case class UnknownProfile() extends SeccompProfile { + override val _type: SeccompProfileType = "Unknown" + } } diff --git a/client/src/main/scala/skuber/json/package.scala b/client/src/main/scala/skuber/json/package.scala index 47d819ed..6a212f80 100644 --- a/client/src/main/scala/skuber/json/package.scala +++ b/client/src/main/scala/skuber/json/package.scala @@ -117,10 +117,10 @@ package object format { case Some(JsString("Localhost")) => val profileConfigPath: String = fields("localhostProfile").as[String] JsSuccess(Security.LocalhostProfile(profileConfigPath)) - case operator => JsError(s"Unknown Seccomp profile '$operator'") + case _ => JsSuccess(Security.UnknownProfile()) } - case _ => JsError(s"Unknown Seccomp") + case _ => JsSuccess(Security.UnknownProfile()) } override def writes(seccomp: Security.SeccompProfile): JsValue = seccomp match { @@ -136,6 +136,7 @@ package object format { "type" -> JsString(p._type), "localhostProfile" -> JsString(localhostProfile)) JsObject(fields) + case _ => JsObject.empty } } diff --git a/client/src/test/scala/skuber/json/PodFormatSpec.scala b/client/src/test/scala/skuber/json/PodFormatSpec.scala index 477c2eb9..b6b7c3d5 100644 --- a/client/src/test/scala/skuber/json/PodFormatSpec.scala +++ b/client/src/test/scala/skuber/json/PodFormatSpec.scala @@ -547,6 +547,17 @@ import Pod._ myPodSecurityContext mustEqual readPodSecurityContext } + "Pod SecurityContext with Unknown seccomp profile can be properly read and written as json" >> { + import Security.UnknownProfile + + val podSecurityContextJsonSource = s"""{ "seccompProfile": { "type": "Any"} }""" + + val myPodSecurityContext = Json.parse(podSecurityContextJsonSource).as[PodSecurityContext] + myPodSecurityContext must_== PodSecurityContext(seccompProfile = Some(UnknownProfile())) + val readPodSecurityContext = Json.fromJson[PodSecurityContext](Json.toJson(myPodSecurityContext)).get + myPodSecurityContext mustEqual readPodSecurityContext + } + "a statefulset with pod affinity/anti-affinity can be read and written as json successfully" >> { val ssJsonSource=s"""{ "apiVersion": "apps/v1beta1", "kind": "StatefulSet", "metadata": { "name": "nginx-with-pod-affinity", "labels": { "app": "nginx", "security": "S1" } }, "spec": { "serviceName": "nginx", "replicas": 10, "selector": { "matchLabels": { "app": "nginx" } }, "template": { "metadata": { "labels": { "app": "nginx" } }, "spec": { "affinity": { "podAffinity": { "requiredDuringSchedulingIgnoredDuringExecution": [ { "labelSelector": { "matchExpressions": [{ "key": "security", "operator": "In", "values": [ "S1" ] }] }, "topologyKey": "failure-domain.beta.kubernetes.io/zone" } ] }, "podAntiAffinity": { "preferredDuringSchedulingIgnoredDuringExecution": [ { "weight": 100, "podAffinityTerm": { "labelSelector": { "matchExpressions": [{ "key": "security", "operator": "In", "values": [ "S2" ] }] }, "topologyKey": "kubernetes.io/hostname" } } ] } }, "containers": [ { "name": "nginx", "image": "nginx" } ] } } } }""" From f091f39397efa9b395408c6fb149396f46cf9a68 Mon Sep 17 00:00:00 2001 From: Serhiy Date: Wed, 28 Feb 2024 18:03:54 +0100 Subject: [PATCH 4/6] integration tests --- .../scala/skuber/format/PodFormatSpec.scala | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 client/src/it/scala/skuber/format/PodFormatSpec.scala diff --git a/client/src/it/scala/skuber/format/PodFormatSpec.scala b/client/src/it/scala/skuber/format/PodFormatSpec.scala new file mode 100644 index 00000000..ea453c01 --- /dev/null +++ b/client/src/it/scala/skuber/format/PodFormatSpec.scala @@ -0,0 +1,210 @@ +package skuber.format + +import java.util.UUID.randomUUID +import org.scalatest.BeforeAndAfterAll +import org.scalatest.concurrent.Eventually +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.matchers.should.Matchers +import play.api.libs.json.Json +import scala.concurrent.duration._ +import skuber.Container +import skuber.DNSPolicy +import skuber.FutureUtil.FutureOps +import skuber.K8SFixture +import skuber.LabelSelector +import skuber.Pod +import skuber.PodList +import skuber.Resource.Quantity +import skuber.RestartPolicy +import skuber.Security.RuntimeDefaultProfile +import skuber.json.format._ +import skuber.k8sInit + +class PodFormatSpec extends K8SFixture with Eventually with Matchers with BeforeAndAfterAll with ScalaFutures { + val defaultLabels = Map("PodFormatSpec" -> this.suiteName) + override implicit val patienceConfig: PatienceConfig = PatienceConfig(10.second) + + val namePrefix: String = "foo-" + val podName: String = namePrefix + randomUUID().toString + val containerName = "nginx" + val nginxVersion = "1.7.9" + val nginxImage = s"192.168.49.1:5000/nginx:$nginxVersion" + + val podJsonStr = s""" + { + "kind": "Pod", + "apiVersion": "v1", + "metadata": { + "name": "$podName", + "generateName": "$namePrefix", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/pods/$podName", + "labels": { + ${defaultLabels.toList.map(v => s"\"${v._1}\": \"${v._2}\"").mkString(",")} + } + }, + "spec": { + "securityContext": { + "fsGroup": 1001, + "runAsGroup": 1001, + "runAsNonRoot": true, + "runAsUser": 1001, + "seccompProfile": { + "type": "RuntimeDefault" + } + }, + "volumes": [ + { + "name": "test-empty-dir-volume", + "emptyDir": { + "sizeLimit": "100Mi" + } + } + ], + "containers": [ + { + "name": "$containerName", + "image": "$nginxImage", + "resources": { + "limits": { + "cpu": "250m" + } + }, + "volumeMounts": [ + { + "name": "test-empty-dir-volume", + "readOnly": true, + "mountPath": "/test-dir" + } + ], + "livenessProbe": { + "failureThreshold": 3, + "tcpSocket": { + "port": 80 + }, + "initialDelaySeconds": 30, + "periodSeconds": 60, + "timeoutSeconds": 5 + }, + "imagePullPolicy": "IfNotPresent" + } + ], + "restartPolicy": "Always", + "dnsPolicy": "Default", + "serviceAccount": "default" + } + } + """ + + override def beforeAll(): Unit = { + val k8s = k8sInit + + val pod = Json.parse(podJsonStr).as[Pod] + k8s.create(pod).valueT + } + + override def afterAll() = { + val k8s = k8sInit + val requirements = defaultLabels.toSeq.map { case (k, _) => LabelSelector.ExistsRequirement(k) } + val labelSelector = LabelSelector(requirements: _*) + val results = k8s.deleteAllSelected[PodList](labelSelector).withTimeout() + results.futureValue + + results.onComplete { _ => + k8s.close + system.terminate().recover { case _ => () }.valueT + } + } + + behavior.of("PodFormat") + + it should "have the same metadata as configured" in { k8s => + val p = k8s.get[Pod](podName).valueT + p.name shouldBe podName + p.metadata.generateName shouldBe namePrefix + p.metadata.namespace shouldBe "default" + p.metadata.labels.exists(_ == "PodFormatSpec" -> this.suiteName) shouldBe true + } + + it should "have the same spec containers as configured" in { k8s => + val maybePodSpec = k8s.get[Pod](podName).valueT.spec + + maybePodSpec should not be empty + + val containers = maybePodSpec.get.containers + + containers should not be empty + containers.exists(_.name == containerName) shouldBe true + + val nginxContainer = containers.find(_.name == containerName).get + + nginxContainer.image shouldBe nginxImage + nginxContainer.volumeMounts should not be empty + nginxContainer.volumeMounts.exists(_.name == "test-empty-dir-volume") shouldBe true + nginxContainer.livenessProbe should not be empty + nginxContainer.resources should not be empty + nginxContainer.resources.get.limits.exists(_ == "cpu" -> Quantity("250m")) shouldBe true + + nginxContainer.imagePullPolicy shouldBe Option(Container.PullPolicy.IfNotPresent) + } + + it should "have the same spec pod security context as configured" in { k8s => + val maybePodSpec = k8s.get[Pod](podName).valueT.spec + + maybePodSpec should not be empty + + val maybeSecurityContext = maybePodSpec.get.securityContext + + maybeSecurityContext should not be empty + + val securityContext = maybeSecurityContext.get + + securityContext.fsGroup shouldBe Option(1001) + securityContext.runAsUser shouldBe Option(1001) + securityContext.runAsGroup shouldBe Option(1001) + securityContext.runAsNonRoot shouldBe Option(true) + securityContext.seccompProfile shouldBe Option(RuntimeDefaultProfile()) + } + + it should "have the same spec volumes as configured" in { k8s => + val maybePodSpec = k8s.get[Pod](podName).valueT.spec + + maybePodSpec should not be empty + + val volumes = maybePodSpec.get.volumes + + volumes should not be empty + volumes.exists(_.name == "test-empty-dir-volume") shouldBe true + } + + it should "have the same spec restartPolicy as configured" in { k8s => + val maybePodSpec = k8s.get[Pod](podName).valueT.spec + + maybePodSpec should not be empty + + val restartPolicy = maybePodSpec.get.restartPolicy + + restartPolicy shouldBe RestartPolicy.Always + } + + it should "have the same spec dnsPolicy as configured" in { k8s => + val maybePodSpec = k8s.get[Pod](podName).valueT.spec + + maybePodSpec should not be empty + + val dnsPolicy = maybePodSpec.get.dnsPolicy + + dnsPolicy shouldBe DNSPolicy.Default + } + + it should "have the same spec serviceAccount as configured" in { k8s => + val maybePodSpec = k8s.get[Pod](podName).valueT.spec + + maybePodSpec should not be empty + + val serviceAccount = maybePodSpec.get.serviceAccountName + + serviceAccount shouldBe "default" + } + +} From 04d1a43a3f1eb89fb62a18601728d4c67955a88f Mon Sep 17 00:00:00 2001 From: Serhiy Date: Wed, 28 Feb 2024 18:10:59 +0100 Subject: [PATCH 5/6] compilation fix --- client/src/it/scala/skuber/format/PodFormatSpec.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/it/scala/skuber/format/PodFormatSpec.scala b/client/src/it/scala/skuber/format/PodFormatSpec.scala index ea453c01..2c8866e1 100644 --- a/client/src/it/scala/skuber/format/PodFormatSpec.scala +++ b/client/src/it/scala/skuber/format/PodFormatSpec.scala @@ -40,7 +40,7 @@ class PodFormatSpec extends K8SFixture with Eventually with Matchers with Before "namespace": "default", "selfLink": "/api/v1/namespaces/default/pods/$podName", "labels": { - ${defaultLabels.toList.map(v => s"\"${v._1}\": \"${v._2}\"").mkString(",")} + ${defaultLabels.toList.map(v => s""""${v._1}": "${v._2}"""").mkString(",")} } }, "spec": { From b8c82f8b75858972f9d525e0716261f96dbeb15c Mon Sep 17 00:00:00 2001 From: Serhiy Date: Wed, 28 Feb 2024 18:18:54 +0100 Subject: [PATCH 6/6] remove service account name check and replace local registry image used during local tests --- .../it/scala/skuber/format/PodFormatSpec.scala | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/client/src/it/scala/skuber/format/PodFormatSpec.scala b/client/src/it/scala/skuber/format/PodFormatSpec.scala index 2c8866e1..ed876b00 100644 --- a/client/src/it/scala/skuber/format/PodFormatSpec.scala +++ b/client/src/it/scala/skuber/format/PodFormatSpec.scala @@ -28,7 +28,7 @@ class PodFormatSpec extends K8SFixture with Eventually with Matchers with Before val podName: String = namePrefix + randomUUID().toString val containerName = "nginx" val nginxVersion = "1.7.9" - val nginxImage = s"192.168.49.1:5000/nginx:$nginxVersion" + val nginxImage = s"nginx:$nginxVersion" val podJsonStr = s""" { @@ -90,8 +90,7 @@ class PodFormatSpec extends K8SFixture with Eventually with Matchers with Before } ], "restartPolicy": "Always", - "dnsPolicy": "Default", - "serviceAccount": "default" + "dnsPolicy": "Default" } } """ @@ -197,14 +196,4 @@ class PodFormatSpec extends K8SFixture with Eventually with Matchers with Before dnsPolicy shouldBe DNSPolicy.Default } - it should "have the same spec serviceAccount as configured" in { k8s => - val maybePodSpec = k8s.get[Pod](podName).valueT.spec - - maybePodSpec should not be empty - - val serviceAccount = maybePodSpec.get.serviceAccountName - - serviceAccount shouldBe "default" - } - }