Skip to content

Commit

Permalink
Secrets with Older Metrics (#221)
Browse files Browse the repository at this point in the history
* Support secrets in Metronome (#216)

Description: propagation of secrets & serialization tests

JIRA issues: METRONOME-246

* removing shadowed unused import

* updated secret validations, rolled back marshalling test and bumped version number for si testing
  • Loading branch information
kensipe authored Apr 11, 2018
1 parent b4a9cc1 commit 534eae8
Show file tree
Hide file tree
Showing 17 changed files with 310 additions and 49 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,17 @@
"patternProperties": {
".*": {
"oneOf": [
{ "type": "string" }
{ "type": "string" },
{
"type": "object",
"description": "An environment variable set to a secret",
"properties": {
"secret": {
"type": "string",
"documentation": "The name of the secret to refer to. At runtime, the value of the secret will be injected into the value of the variable."
}
}
}
]
}
}
Expand Down Expand Up @@ -191,6 +201,21 @@
},
"required": ["containerPath", "hostPath", "mode"]
}
},
"secrets": {
"type": "object",
"patternProperties": {
".*": {
"type": "object",
"description": "An environment variable set to a secret",
"properties": {
"source": {
"type": "string",
"documentation": "The name of the secret to refer to. At runtime, the value of the secret will be injected into the value of the variable."
}
}
}
}
}
},
"required": ["cpus", "mem", "disk"]
Expand Down
28 changes: 27 additions & 1 deletion api/src/main/resources/public/api/v1/schema/jobspec.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,18 @@
"patternProperties": {
".*": {
"oneOf": [
{ "type": "string" }
{ "type": "string" },
{
"type": "object",
"description": "An environment variable set to a secret",
"properties": {
"secret": {
"type": "string",
"documentation": "The name of the secret to refer to. At runtime, the value of the secret will be injected into the value of the variable."
}
}
}

]
}
}
Expand Down Expand Up @@ -197,6 +208,21 @@
},
"required": ["containerPath", "hostPath", "mode"]
}
},
"secrets": {
"type": "object",
"patternProperties": {
".*": {
"type": "object",
"description": "An environment variable set to a secret",
"properties": {
"source": {
"type": "string",
"documentation": "The name of the secret to refer to. At runtime, the value of the secret will be injected into the value of the variable."
}
}
}
}
}
},
"required": ["cpus", "mem", "disk"]
Expand Down
5 changes: 3 additions & 2 deletions api/src/main/scala/dcos/metronome/api/v1/models/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,15 @@ package object models {
(__ \ "cmd").formatNullable[String] ~
(__ \ "args").formatNullable[Seq[String]] ~
(__ \ "user").formatNullable[String] ~
(__ \ "env").formatNullable[Map[String, String]].withDefault(JobRunSpec.DefaultEnv) ~
(__ \ "env").formatNullable[Map[String, EnvVarValueOrSecret]].withDefault(JobRunSpec.DefaultEnv) ~
(__ \ "placement").formatNullable[PlacementSpec].withDefault(JobRunSpec.DefaultPlacement) ~
(__ \ "artifacts").formatNullable[Seq[Artifact]].withDefault(JobRunSpec.DefaultArtifacts) ~
(__ \ "maxLaunchDelay").formatNullable[Duration].withDefault(JobRunSpec.DefaultMaxLaunchDelay) ~
(__ \ "docker").formatNullable[DockerSpec] ~
(__ \ "volumes").formatNullable[Seq[Volume]].withDefault(JobRunSpec.DefaultVolumes) ~
(__ \ "restart").formatNullable[RestartSpec].withDefault(JobRunSpec.DefaultRestartSpec) ~
(__ \ "taskKillGracePeriodSeconds").formatNullable[FiniteDuration])(JobRunSpec.apply, unlift(JobRunSpec.unapply))
(__ \ "taskKillGracePeriodSeconds").formatNullable[FiniteDuration] ~
(__ \ "secrets").formatNullable[Map[String, SecretDef]].withDefault(JobRunSpec.DefaultSecrets))(JobRunSpec.apply, unlift(JobRunSpec.unapply))

implicit lazy val JobSpecFormat: Format[JobSpec] = (
(__ \ "id").format[JobId] ~
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,42 @@ class JobSpecControllerTest extends PlaySpec with OneAppPerTestWithComponents[Mo
Then("a forbidden response is send")
status(unauthorized) mustBe FORBIDDEN
}

"create a job with secrets" in {
Given("No job")

When("A job is created")
val response = route(app, FakeRequest(POST, s"/v1/jobs").withJsonBody(jobSpecWithSecretsJson)).get

Then("The job is created")
status(response) mustBe CREATED
contentType(response) mustBe Some("application/json")
contentAsJson(response) mustBe jobSpecWithSecretsJson
}

"indicate a problem when creating a job without a secret definition" in {
Given("No job")

When("A job is created")
val response = route(app, FakeRequest(POST, s"/v1/jobs").withJsonBody(jobSpecWithSecretVarsOnlyJson)).get

Then("A validation error is returned")
status(response) mustBe UNPROCESSABLE_ENTITY
contentType(response) mustBe Some("application/json")
// contentAsJson(response) \ "message" mustBe JsDefined(JsString("Object is not valid"))
}

"indicate a problem when creating a job without a secret name" in {
Given("No job")

When("A job is created")
val response = route(app, FakeRequest(POST, s"/v1/jobs").withJsonBody(jobSpecWithSecretDefsOnlyJson)).get

Then("A validation error is returned")
status(response) mustBe UNPROCESSABLE_ENTITY
contentType(response) mustBe Some("application/json")
// contentAsJson(response) \ "message" mustBe JsDefined(JsString("Object is not valid"))
}
}

"GET /jobs" should {
Expand Down Expand Up @@ -380,6 +416,21 @@ class JobSpecControllerTest extends PlaySpec with OneAppPerTestWithComponents[Mo
val jobSpec1Json = Json.toJson(jobSpec1)
val jobSpec2 = spec("spec2")
val jobSpec2Json = Json.toJson(jobSpec2)
val jobSpecWithSecrets = {
val jobSpec = spec("spec-with-secrets")
jobSpec.copy(run = jobSpec.run.copy(env = Map("secretVar" -> EnvVarSecret("secretId")), secrets = Map("secretId" -> SecretDef("source"))))
}
val jobSpecWithSecretVarsOnly = {
val jobSpec = spec("spec-with-secret-vars-only")
jobSpec.copy(run = jobSpec.run.copy(env = Map("secretVar" -> EnvVarSecret("secretId"))))
}
val jobSpecWithSecretDefsOnly = {
val jobSpec = spec("spec-with-secret-defs-only")
jobSpec.copy(run = jobSpec.run.copy(secrets = Map("secretId" -> SecretDef("source"))))
}
val jobSpecWithSecretsJson = Json.toJson(jobSpecWithSecrets)
val jobSpecWithSecretVarsOnlyJson = Json.toJson(jobSpecWithSecretVarsOnly)
val jobSpecWithSecretDefsOnlyJson = Json.toJson(jobSpecWithSecretDefsOnly)
val auth = new TestAuthFixture

before {
Expand Down
15 changes: 15 additions & 0 deletions jobs/src/main/protobuf/metronome.proto
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,21 @@ message JobSpec {
// https://github.com/mesosphere/marathon/blob/releases/1.3/docs/docs/rest-api/public/api/v2/schema/AppDefinition.json
// int64 in mesos and int32 in marathon... going with int64
optional int64 task_kill_grace_period_seconds = 14;

// key value pair used to store secrets path
message EnvironmentVariableSecret {
optional string name = 1;
optional string secretId = 2;
}
repeated EnvironmentVariableSecret environmentSecrets = 15;

// key value pair used to store secrets path
message Secret {
optional string id = 1;
optional string source = 2;
}
repeated Secret secrets = 16;

}
optional RunSpec run = 6;
}
41 changes: 41 additions & 0 deletions jobs/src/main/scala/dcos/metronome/model/EnvVarValueOrSecret.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package dcos.metronome.model

trait EnvVarValueOrSecret

case class EnvVarValue(value: String) extends EnvVarValueOrSecret

object EnvVarValue {
implicit object playJsonFormat extends play.api.libs.json.Format[EnvVarValue] {
def reads(json: play.api.libs.json.JsValue): play.api.libs.json.JsResult[EnvVarValue] = {
json.validate[String].map(EnvVarValue.apply)
}
def writes(envVarValue: EnvVarValue): play.api.libs.json.JsValue = {
play.api.libs.json.JsString(envVarValue.value)
}
}
}

/**
* An environment variable set to a secret
* @param secret The name of the secret to refer to. At runtime, the value of the
* secret will be injected into the value of the variable.
*/
case class EnvVarSecret(secret: String) extends EnvVarValueOrSecret

object EnvVarSecret {
implicit val playJsonFormat = play.api.libs.json.Json.format[EnvVarSecret]
}

object EnvVarValueOrSecret {
implicit object playJsonFormat extends play.api.libs.json.Format[EnvVarValueOrSecret] {
def reads(json: play.api.libs.json.JsValue): play.api.libs.json.JsResult[EnvVarValueOrSecret] = {
json.validate[EnvVarValue].orElse(json.validate[EnvVarSecret])
}
def writes(envOrSecret: EnvVarValueOrSecret): play.api.libs.json.JsValue = {
envOrSecret match {
case envVarValue: EnvVarValue => play.api.libs.json.Json.toJson(envVarValue)(EnvVarValue.playJsonFormat)
case envVarSecret: EnvVarSecret => play.api.libs.json.Json.toJson(envVarSecret)(EnvVarSecret.playJsonFormat)
}
}
}
}
54 changes: 35 additions & 19 deletions jobs/src/main/scala/dcos/metronome/model/JobRunSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,21 @@ import scala.collection.immutable._
case class Artifact(uri: String, extract: Boolean = true, executable: Boolean = false, cache: Boolean = false)

case class JobRunSpec(
cpus: Double = JobRunSpec.DefaultCpus,
mem: Double = JobRunSpec.DefaultMem,
disk: Double = JobRunSpec.DefaultDisk,
cmd: Option[String] = JobRunSpec.DefaultCmd,
args: Option[Seq[String]] = JobRunSpec.DefaultArgs,
user: Option[String] = JobRunSpec.DefaultUser,
env: Map[String, String] = JobRunSpec.DefaultEnv,
placement: PlacementSpec = JobRunSpec.DefaultPlacement,
artifacts: Seq[Artifact] = JobRunSpec.DefaultArtifacts,
maxLaunchDelay: Duration = JobRunSpec.DefaultMaxLaunchDelay,
docker: Option[DockerSpec] = JobRunSpec.DefaultDocker,
volumes: Seq[Volume] = JobRunSpec.DefaultVolumes,
restart: RestartSpec = JobRunSpec.DefaultRestartSpec,
taskKillGracePeriodSeconds: Option[FiniteDuration] = JobRunSpec.DefaultTaskKillGracePeriodSeconds)
cpus: Double = JobRunSpec.DefaultCpus,
mem: Double = JobRunSpec.DefaultMem,
disk: Double = JobRunSpec.DefaultDisk,
cmd: Option[String] = JobRunSpec.DefaultCmd,
args: Option[Seq[String]] = JobRunSpec.DefaultArgs,
user: Option[String] = JobRunSpec.DefaultUser,
env: Map[String, EnvVarValueOrSecret] = JobRunSpec.DefaultEnv,
placement: PlacementSpec = JobRunSpec.DefaultPlacement,
artifacts: Seq[Artifact] = JobRunSpec.DefaultArtifacts,
maxLaunchDelay: Duration = JobRunSpec.DefaultMaxLaunchDelay,
docker: Option[DockerSpec] = JobRunSpec.DefaultDocker,
volumes: Seq[Volume] = JobRunSpec.DefaultVolumes,
restart: RestartSpec = JobRunSpec.DefaultRestartSpec,
taskKillGracePeriodSeconds: Option[FiniteDuration] = JobRunSpec.DefaultTaskKillGracePeriodSeconds,
secrets: Map[String, SecretDef] = JobRunSpec.DefaultSecrets)

object JobRunSpec {
val DefaultCpus: Double = 1.0
Expand All @@ -36,26 +37,41 @@ object JobRunSpec {
val DefaultCmd = None
val DefaultArgs = None
val DefaultUser = None
val DefaultEnv = Map.empty[String, String]
val DefaultEnv = Map.empty[String, EnvVarValueOrSecret]
val DefaultArtifacts = Seq.empty[Artifact]
val DefaultDocker = None
val DefaultVolumes = Seq.empty[Volume]
val DefaultRestartSpec = RestartSpec()
val DefaultTaskKillGracePeriodSeconds = None
val DefaultSecrets = Map.empty[String, SecretDef]

implicit lazy val validJobRunSpec: Validator[JobRunSpec] = new Validator[JobRunSpec] {
import com.wix.accord._
import ViolationBuilder._

override def apply(jobRunSpec: JobRunSpec): Result = {
if (jobRunSpec.cmd.isDefined || jobRunSpec.docker.exists(d => d.image.nonEmpty))
Success
else
RuleViolation(jobRunSpec, JobRunSpecMessages.cmdOrDockerValidation, None)
var violations = Set.empty[Result]

def check(test: Boolean, errorMessage: String) = {
if (!test) {
violations += RuleViolation(jobRunSpec, errorMessage, None)
}
}
val envVarDefinedSecretNames = jobRunSpec.env.values.collect { case EnvVarSecret(secretName) => secretName }.toSet
val providedSecretNames = jobRunSpec.secrets.keySet

check(jobRunSpec.cmd.isDefined || jobRunSpec.docker.exists(d => d.image.nonEmpty), JobRunSpecMessages.cmdOrDockerValidation)
check(envVarDefinedSecretNames == providedSecretNames, JobRunSpecMessages.secretsValidation(envVarDefinedSecretNames, providedSecretNames))

violations.headOption.getOrElse(Success)
}

}
}

object JobRunSpecMessages {
val cmdOrDockerValidation = "Cmd or docker image must be specified"
def secretsValidation(envVarSecretsName: Set[String], providedSecretsNames: Set[String]) = {
s"Secret names are different from provided secrets. Defined: ${envVarSecretsName.mkString(", ")}, Provided: ${providedSecretsNames.mkString(", ")}"
}
}
15 changes: 9 additions & 6 deletions jobs/src/main/scala/dcos/metronome/model/JobSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ package model
import com.wix.accord.dsl._
import com.wix.accord.Validator
import mesosphere.marathon.api.v2.Validation._
import mesosphere.marathon.plugin.{ Secret, EnvVarValue, RunSpec }
import mesosphere.marathon.plugin.{ Secret, RunSpec }

import scala.collection.immutable._
import JobRunSpec._
import dcos.metronome.model.JobRunSpec._
import dcos.metronome.utils.glue.MarathonConversions
import mesosphere.marathon.api.v2.Validation._
import mesosphere.marathon

case class JobSpec(
id: JobId,
Expand All @@ -17,10 +20,10 @@ case class JobSpec(
run: JobRunSpec = JobSpec.DefaultRunSpec) extends RunSpec {
def schedule(id: String): Option[ScheduleSpec] = schedules.find(_.id == id)

override def user: Option[String] = run.user
override def acceptedResourceRoles: Option[Predef.Set[String]] = None
override def secrets: Map[String, Secret] = Map.empty
override def env: Map[String, EnvVarValue] = mesosphere.marathon.state.EnvVarValue(run.env)
override val user: Option[String] = run.user
override val acceptedResourceRoles: Option[Predef.Set[String]] = None
override val secrets: Map[String, Secret] = MarathonConversions.secretsToMarathon(run.secrets)
override val env: Map[String, marathon.state.EnvVarValue] = MarathonConversions.envVarToMarathon(run.env)
}

object JobSpec {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package dcos.metronome
package model

import mesosphere.marathon.plugin.{ EnvVarValue, PathId, RunSpec, Secret }
import dcos.metronome.utils.glue.MarathonConversions
import mesosphere.marathon.plugin.{ PathId, Secret, RunSpec }
import mesosphere.marathon.plugin
import mesosphere.marathon.state.Timestamp

import scala.collection.immutable.Map
Expand All @@ -17,6 +19,6 @@ case class QueuedJobRunInfo(
lazy val runId: String = id.path.last
override def user: Option[String] = run.user
override def secrets: Map[String, Secret] = Map.empty
override def env: Map[String, EnvVarValue] = mesosphere.marathon.state.EnvVarValue(run.env)
override def labels = Map.empty[String, String]
override val env: Map[String, plugin.EnvVarValue] = MarathonConversions.envVarToMarathon(run.env)
override val labels = Map.empty[String, String]
}
26 changes: 26 additions & 0 deletions jobs/src/main/scala/dcos/metronome/model/SecretDef.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package dcos.metronome.model

/**
* A secret declaration
* @param source reference to a secret which will be injected with a value from the secret store.
*/
case class SecretDef(source: String)

object SecretDef {
import play.api.libs.json.Reads._
implicit object playJsonFormat extends play.api.libs.json.Format[SecretDef] {
def reads(json: play.api.libs.json.JsValue): play.api.libs.json.JsResult[SecretDef] = {
val source = json.\("source").validate[String](play.api.libs.json.JsPath.read[String](minLength[String](ConstraintSourceMinLength)))
val _errors = Seq(("source", source)).collect({
case (field, e: play.api.libs.json.JsError) => e.repath(play.api.libs.json.JsPath.\(field)).asInstanceOf[play.api.libs.json.JsError]
})
if (_errors.nonEmpty) _errors.reduceOption[play.api.libs.json.JsError](_.++(_)).getOrElse(_errors.head)
else play.api.libs.json.JsSuccess(SecretDef(source = source.get))
}
def writes(secret: SecretDef): play.api.libs.json.JsValue = {
val source = play.api.libs.json.Json.toJson(secret.source)
play.api.libs.json.JsObject(Seq(("source", source)).filter(_._2 != play.api.libs.json.JsNull).++(Seq.empty))
}
}
val ConstraintSourceMinLength = 1
}
Loading

0 comments on commit 534eae8

Please sign in to comment.