Skip to content

Commit

Permalink
Merge pull request #366 from Dream1Master/seccomp_profile_support
Browse files Browse the repository at this point in the history
Add seccomp profile support to PodSecurityContext in Kubernetes for Skuber library
  • Loading branch information
hagay3 authored Feb 29, 2024
2 parents 2be10ae + b8c82f8 commit 075a396
Show file tree
Hide file tree
Showing 4 changed files with 356 additions and 66 deletions.
199 changes: 199 additions & 0 deletions client/src/it/scala/skuber/format/PodFormatSpec.scala
Original file line number Diff line number Diff line change
@@ -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
}

}
79 changes: 48 additions & 31 deletions client/src/main/scala/skuber/Security.scala
Original file line number Diff line number Diff line change
@@ -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)

}
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"
}

}
42 changes: 39 additions & 3 deletions client/src/main/scala/skuber/json/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] {

Expand Down Expand Up @@ -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 =>
Expand Down
Loading

0 comments on commit 075a396

Please sign in to comment.