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..ed876b00 --- /dev/null +++ b/client/src/it/scala/skuber/format/PodFormatSpec.scala @@ -0,0 +1,199 @@ +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"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" + } + } + """ + + 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 + } + +} diff --git a/client/src/main/scala/skuber/Security.scala b/client/src/main/scala/skuber/Security.scala index 072dfb25..ce9c3ed1 100644 --- a/client/src/main/scala/skuber/Security.scala +++ b/client/src/main/scala/skuber/Security.scala @@ -1,40 +1,57 @@ 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 - - case class Capabilities(add: List[Capability] = Nil, - drop: List[Capability] = Nil) - - case class SELinuxOptions(user: String = "", - role: String = "", - _type: String = "", - level: String = "") - - case class Sysctl(name: String, - value: String) - -} \ No newline at end of file + type SeccompProfileType = String + + case class Capabilities(add: List[Capability] = Nil, drop: List[Capability] = Nil) + + case class SELinuxOptions(user: String = "", role: String = "", _type: String = "", level: String = "") + + case class Sysctl(name: String, value: String) + + 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" + } + 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 6bdd0c10..6a212f80 100644 --- a/client/src/main/scala/skuber/json/package.scala +++ b/client/src/main/scala/skuber/json/package.scala @@ -105,6 +105,41 @@ 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 _ => JsSuccess(Security.UnknownProfile()) + } + + case _ => JsSuccess(Security.UnknownProfile()) + } + + 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) + case _ => JsObject.empty + } + } + 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 +268,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 +1020,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 => diff --git a/client/src/test/scala/skuber/json/PodFormatSpec.scala b/client/src/test/scala/skuber/json/PodFormatSpec.scala index 1a55c5f6..b6b7c3d5 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,43 @@ 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 + } + + "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" } ] } } } }"""