diff --git a/front50/build.gradle b/front50/build.gradle index 2a86a32..0097189 100644 --- a/front50/build.gradle +++ b/front50/build.gradle @@ -21,7 +21,7 @@ spinnakerBundle { version = rootProject.version } -version = "v1.0.1" +version = "v1.0.1-SNAPSHOT" subprojects { group = "com.opsmx.plugin.stage.custom" diff --git a/front50/build/distributions/plugin-info.json b/front50/build/distributions/plugin-info.json new file mode 100644 index 0000000..7fda25e --- /dev/null +++ b/front50/build/distributions/plugin-info.json @@ -0,0 +1,17 @@ +{ + "id": "Opsmx.StaticPolicyPlugin", + "description": "An example of a PF4J-based plugin that provides a custom pipeline stage.", + "provider": "https://github.com/opsmx", + "releases": [ + { + "version": "v1.0.1-SNAPSHOT", + "date": "2023-12-18T12:26:11.987733Z", + "requires": "front50>=0.0.0", + "sha512sum": "83458e4245331599c526a6f94e2d3d0fac2a2b2fd695933c80a4d43b6aedc035eb56fa15aa100954c93ac3358f1015dc3c6193795f383ee04880c862a7668726", + "preferred": false, + "compatibility": [ + + ] + } + ] +} \ No newline at end of file diff --git a/front50/custom-stage-front50/custom-stage-front50.gradle b/front50/custom-stage-front50/custom-stage-front50.gradle index b6206e3..6c4ba26 100644 --- a/front50/custom-stage-front50/custom-stage-front50.gradle +++ b/front50/custom-stage-front50/custom-stage-front50.gradle @@ -19,7 +19,6 @@ apply plugin: "kotlin-spring" repositories { mavenCentral() jcenter() - maven { url "https://spinnaker-releases.bintray.com/jars" } } sourceSets { @@ -42,7 +41,8 @@ dependencies { compileOnly "org.pf4j:pf4j:${pf4jVersion}" compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" compileOnly "com.netflix.spinnaker.kork:kork-plugins-api:${korkVersion}" - compileOnly 'com.netflix.spinnaker.kork:kork-web:7.105.0' + compileOnly "com.netflix.spinnaker.kork:kork-web:7.105.0" + compileOnly group: 'io.spinnaker.front50', name: 'front50-api', version: '2.27.3' kapt "org.pf4j:pf4j:${pf4jVersion}" compileOnly group: 'com.squareup.retrofit', name: 'retrofit', version: '1.9.0' @@ -50,15 +50,6 @@ dependencies { compileOnly group: 'com.jakewharton.retrofit', name: 'retrofit1-okhttp3-client', version: '1.1.0' implementation group: 'com.jcraft', name: 'jsch', version: '0.1.55' - /*testImplementation "com.netflix.spinnaker.kork:kork-plugins-tck" - testImplementation(platform("io.spinnaker.front50:front50-bom:2.24.0")) - - testImplementation "org.junit.jupiter:junit-jupiter-api:5.5.2" - testImplementation "io.strikt:strikt-core:0.22.1" - testImplementation "dev.minutest:minutest:1.10.0" - testImplementation "io.mockk:mockk:1.9.3" - testImplementation "com.fasterxml.jackson.module:jackson-module-kotlin:2.11.1" - testImplementation "javax.servlet:javax.servlet-api:4.0.1"*/ testRuntime "org.junit.jupiter:junit-jupiter-engine:5.4.0" testRuntime "org.junit.platform:junit-platform-launcher:1.4.0" @@ -66,8 +57,8 @@ dependencies { implementation("org.apache.commons:commons-lang3:3.0") implementation 'com.google.code.gson:gson:2.8.8' - extraLibs group: 'com.netflix.spinnaker.front50', name: 'front50-core', version: '2.23.0' - configurations.compile.extendsFrom(configurations.extraLibs) + /*extraLibs group: 'com.netflix.spinnaker.front50', name: 'front50-core', version: '2.23.0' + configurations.compile.extendsFrom(configurations.extraLibs)*/ } configurations.all { diff --git a/front50/custom-stage-front50/src/main/java/com/opsmx/plugin/stage/custom/ApplicationNameValidation.java b/front50/custom-stage-front50/src/main/java/com/opsmx/plugin/stage/custom/ApplicationNameValidation.java deleted file mode 100644 index 5247ef3..0000000 --- a/front50/custom-stage-front50/src/main/java/com/opsmx/plugin/stage/custom/ApplicationNameValidation.java +++ /dev/null @@ -1,174 +0,0 @@ -package com.opsmx.plugin.stage.custom; - -import java.io.IOException; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Optional; -import java.util.Set; - -import org.apache.commons.lang3.StringUtils; -import org.pf4j.Extension; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.netflix.spinnaker.front50.ApplicationPermissionsService; -import com.netflix.spinnaker.front50.model.application.Application; -import com.netflix.spinnaker.front50.validator.ApplicationValidationErrors; -import com.netflix.spinnaker.front50.validator.ApplicationValidator; -import com.netflix.spinnaker.kork.plugins.api.internal.SpinnakerExtensionPoint; -import com.netflix.spinnaker.kork.web.exceptions.ValidationException; - -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import okhttp3.ResponseBody; - -@Extension -@Component -public class ApplicationNameValidation implements ApplicationValidator, SpinnakerExtensionPoint { - - private final Logger logger = LoggerFactory.getLogger(ApplicationNameValidation.class); - - @Value("${policy.opa.url:http://oes-server-svc.oes:8085}") - private String opaUrl; - - @Value("${policy.opa.resultKey:deny}") - private String opaResultKey; - - @Value("${policy.opa.policyLocation:/v1/staticPolicy/eval}") - private String opaPolicyLocation; - - @Value("${policy.opa.enabled:false}") - private boolean isOpaEnabled; - - @Value("${policy.opa.proxy:true}") - private boolean isOpaProxy; - - @Value("${policy.opa.deltaVerification:false}") - private boolean deltaVerification; - - private final Gson gson = new Gson(); - - /* OPA spits JSON */ - private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); - private final OkHttpClient opaClient = new OkHttpClient(); - - @Override - public void validate(Application application, ApplicationValidationErrors validationErrors) { - if (!isOpaEnabled) { - logger.info("OPA not enabled, returning"); - return; - } - String finalInput = null; - Response httpResponse; - try { - finalInput = getOpaInput(application); - logger.debug("Verifying {} with OPA", finalInput); - - RequestBody requestBody = RequestBody.create(JSON, finalInput); - String opaFinalUrl = String.format("%s/%s", opaUrl.endsWith("/") ? opaUrl.substring(0, opaUrl.length() - 1) : opaUrl, opaPolicyLocation.startsWith("/") ? opaPolicyLocation.substring(1) : opaPolicyLocation); - - logger.debug("OPA endpoint : {}", opaFinalUrl); - String opaStringResponse; - - /* fetch the response from the spawned call execution */ - httpResponse = doPost(opaFinalUrl, requestBody); - opaStringResponse = httpResponse.body().string(); - logger.info("OPA response: {}", opaStringResponse); - if (isOpaProxy) { - if (httpResponse.code() == 401 ) { - JsonObject opaResponse = gson.fromJson(opaStringResponse, JsonObject.class); - StringBuilder denyMessage = new StringBuilder(); - extractDenyMessage(opaResponse, denyMessage); - String opaMessage = denyMessage.toString(); - if (StringUtils.isNotBlank(opaMessage)) { - validationErrors.rejectValue( - "name", - "application.name.invalid with opa deny", - Optional.ofNullable(opaMessage) - .orElse("Application doesn't satisfy the policy specified")); - } else { - validationErrors.rejectValue( - "name", - "application.name.invalid","Application doesn't satisfy the policy specified"); - } - } else if (httpResponse.code() != 200 ) { - validationErrors.rejectValue( - "name", - "application.name.invalid", httpResponse.message());; - } - } - - } catch (Exception e) { - logger.error("Communication exception for OPA at {}: {}", this.opaUrl, e.toString()); - validationErrors.rejectValue( - "name", - "application.name.invalid", e.toString()); - } - } - - private void extractDenyMessage(JsonObject opaResponse, StringBuilder messagebuilder) { - Set> fields = opaResponse.entrySet(); - fields.forEach(field -> { - if (field.getKey().equalsIgnoreCase(opaResultKey)) { - JsonArray resultKey = field.getValue().getAsJsonArray(); - if (resultKey.size() != 0) { - resultKey.forEach(result -> { - if (StringUtils.isNotEmpty(messagebuilder)) { - messagebuilder.append(", "); - } - messagebuilder.append(result.getAsString()); - }); - } - }else if (field.getValue().isJsonObject()) { - extractDenyMessage(field.getValue().getAsJsonObject(), messagebuilder); - } else if (field.getValue().isJsonArray()){ - field.getValue().getAsJsonArray().forEach(obj -> { - extractDenyMessage(obj.getAsJsonObject(), messagebuilder); - }); - } - }); - } - - - private String getOpaInput(Application application) { - - JsonObject applicationJson = pipelineToJsonObject(application); - - String finalInput = gson.toJson(addWrapper(addWrapper(applicationJson, "app"), "input")); - return finalInput; - } - - private JsonObject pipelineToJsonObject(Application application) { - String applicationStr = gson.toJson(application, Application.class); - return gson.fromJson(applicationStr, JsonObject.class); - } - - private JsonObject addWrapper(JsonObject pipeline, String wrapper) { - JsonObject input = new JsonObject(); - input.add(wrapper, pipeline); - return input; - } - - private Response doPost(String url, RequestBody requestBody) throws IOException { - Request req = (new Request.Builder()).url(url).post(requestBody).build(); - return getResponse(url, req); - } - - private Response getResponse(String url, Request req) throws IOException { - Response httpResponse = this.opaClient.newCall(req).execute(); - ResponseBody responseBody = httpResponse.body(); - if (responseBody == null) { - throw new IOException("Http call yielded null response!! url:" + url); - } - return httpResponse; - } -} diff --git a/front50/custom-stage-front50/src/main/java/com/opsmx/plugin/stage/custom/OpaConfigProperties.java b/front50/custom-stage-front50/src/main/java/com/opsmx/plugin/stage/custom/OpaConfigProperties.java new file mode 100644 index 0000000..20d19db --- /dev/null +++ b/front50/custom-stage-front50/src/main/java/com/opsmx/plugin/stage/custom/OpaConfigProperties.java @@ -0,0 +1,92 @@ +package com.opsmx.plugin.stage.custom; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +@ConfigurationProperties(prefix = "policy.opa") +@EnableConfigurationProperties({OpaConfigProperties.class, OpaConfigProperties.Policy.class}) +public class OpaConfigProperties { + + private String url="http://opa:8181/v1/data"; + private String resultKey="deny"; + private boolean enabled=false; + private boolean proxy=true; + private boolean deltaVerification=false; + private List staticpolicies; + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getResultKey() { + return resultKey; + } + + public void setResultKey(String resultKey) { + this.resultKey = resultKey; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isProxy() { + return proxy; + } + + public void setProxy(boolean proxy) { + this.proxy = proxy; + } + + public boolean isDeltaVerification() { + return deltaVerification; + } + + public void setDeltaVerification(boolean deltaVerification) { + this.deltaVerification = deltaVerification; + } + + public List getStaticpolicies() { + return staticpolicies; + } + + public void setStaticpolicies(List staticpolicies) { + this.staticpolicies = staticpolicies; + } + + @Configuration + @ConfigurationProperties(prefix = "policy.opa.staticpolicies") + public static class Policy { + private String name; + private String packageName; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + } +} \ No newline at end of file diff --git a/front50/custom-stage-front50/src/main/java/com/opsmx/plugin/stage/custom/OpenPolicyAgentValidator.java b/front50/custom-stage-front50/src/main/java/com/opsmx/plugin/stage/custom/OpenPolicyAgentValidator.java index e6ae8de..88f12e7 100644 --- a/front50/custom-stage-front50/src/main/java/com/opsmx/plugin/stage/custom/OpenPolicyAgentValidator.java +++ b/front50/custom-stage-front50/src/main/java/com/opsmx/plugin/stage/custom/OpenPolicyAgentValidator.java @@ -1,26 +1,21 @@ package com.opsmx.plugin.stage.custom; import java.io.IOException; -import java.util.ArrayList; -import java.util.List; +import java.util.*; import java.util.Map.Entry; -import java.util.Optional; -import java.util.Set; +import com.google.gson.*; +import com.netflix.spinnaker.front50.api.validator.ValidatorErrors; import org.apache.commons.lang3.StringUtils; import org.pf4j.Extension; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.validation.Errors; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.netflix.spinnaker.front50.model.pipeline.Pipeline; -import com.netflix.spinnaker.front50.model.pipeline.PipelineDAO; -import com.netflix.spinnaker.front50.validator.PipelineValidator; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.stereotype.Component; + +import com.netflix.spinnaker.front50.api.model.pipeline.Pipeline; +import com.netflix.spinnaker.front50.api.validator.PipelineValidator; import com.netflix.spinnaker.kork.plugins.api.internal.SpinnakerExtensionPoint; import com.netflix.spinnaker.kork.web.exceptions.ValidationException; @@ -30,114 +25,103 @@ import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; - - @Extension +@Component +@ComponentScan("com.opsmx.plugin.stage.custom") public class OpenPolicyAgentValidator implements PipelineValidator, SpinnakerExtensionPoint { private final Logger logger = LoggerFactory.getLogger(OpenPolicyAgentValidator.class); + private static final String RESULT = "result"; - private final PipelineDAO pipelineDAO; - /* define configurable variables: - opaUrl: OPA or OPA-Proxy base url - opaResultKey: Not needed for Proxy. The key to watch in the return from OPA. - policyLocation: Where in OPA is the policy located, generally this is v0/location/to/policy/path - And for Proxy it is /v1/staticPolicy/eval - isOpaEnabled: Policy evaluation is skipped if this is false - isOpaProxy : true if Proxy is present instead of OPA server. - */ - @Value("${policy.opa.url:http://oes-server-svc.oes:8085}") - private String opaUrl; - - @Value("${policy.opa.resultKey:deny}") - private String opaResultKey; - - @Value("${policy.opa.policyLocation:/v1/staticPolicy/eval}") - private String opaPolicyLocation; - - @Value("${policy.opa.enabled:false}") - private boolean isOpaEnabled; + private OpaConfigProperties opaConfigProperties; - @Value("${policy.opa.proxy:true}") - private boolean isOpaProxy; - - @Value("${policy.opa.deltaVerification:false}") - private boolean deltaVerification; + public OpenPolicyAgentValidator(OpaConfigProperties opaConfigProperties) { + this.opaConfigProperties = opaConfigProperties; + } + /* define configurable variables: + opaUrl: OPA or OPA-Proxy base url + opaResultKey: Not needed for Proxy. The key to watch in the return from OPA. + policyLocation: Where in OPA is the policy located, generally this is v0/location/to/policy/path + And for Proxy it is /v1/staticPolicy/eval + isOpaEnabled: Policy evaluation is skipped if this is false + isOpaProxy : true if Proxy is present instead of OPA server. + */ private final Gson gson = new Gson(); /* OPA spits JSON */ private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); private final OkHttpClient opaClient = new OkHttpClient(); - public OpenPolicyAgentValidator(PipelineDAO pipelineDAO) { - this.pipelineDAO = pipelineDAO; - } - - @Override - public void validate(Pipeline pipeline, Errors errors) { - if (!isOpaEnabled) { + @Override + public void validate(Pipeline pipeline, ValidatorErrors errors) { + logger.debug("Start of the Policy Validation"); + if (!opaConfigProperties.isEnabled()) { logger.info("OPA not enabled, returning"); + logger.debug("End of the Policy Validation"); return; } String finalInput = null; Response httpResponse; try { - // Form input to opa - finalInput = getOpaInput(pipeline, deltaVerification); - + finalInput = getOpaInput(pipeline); logger.debug("Verifying {} with OPA", finalInput); - /* build our request to OPA */ RequestBody requestBody = RequestBody.create(JSON, finalInput); - String opaFinalUrl = String.format("%s/%s", opaUrl.endsWith("/") ? opaUrl.substring(0, opaUrl.length() - 1) : opaUrl, opaPolicyLocation.startsWith("/") ? opaPolicyLocation.substring(1) : opaPolicyLocation); - - logger.info("OPA endpoint : {}", opaFinalUrl); + logger.debug("OPA endpoint : {}", opaConfigProperties.getUrl()); String opaStringResponse; /* fetch the response from the spawned call execution */ - httpResponse = doPost(opaFinalUrl, requestBody); - opaStringResponse = httpResponse.body().string(); - logger.debug("OPA response: {}", opaStringResponse); - logger.info("proxy enabled : {}, statuscode : {}, opaResultKey : {}", isOpaProxy, httpResponse.code(), opaResultKey); - if (isOpaProxy) { - if (httpResponse.code() == 401 ) { - JsonObject opaResponse = gson.fromJson(opaStringResponse, JsonObject.class); - StringBuilder denyMessage = new StringBuilder(); - extractDenyMessage(opaResponse, denyMessage); - if (StringUtils.isNotBlank(denyMessage)) { - throw new ValidationException(denyMessage.toString(), null); - } else { - throw new ValidationException("There is no '" + opaResultKey + "' field in the OPA response", null); - } - } else if (httpResponse.code() != 200 ) { - throw new ValidationException(opaStringResponse, null); - } - } else { - if (httpResponse.code() == 401 ) { - JsonObject opaResponse = gson.fromJson(opaStringResponse, JsonObject.class); - StringBuilder denyMessage = new StringBuilder(); - extractDenyMessage(opaResponse, denyMessage); - if (StringUtils.isNotBlank(denyMessage)) { - throw new ValidationException(denyMessage.toString(), null); + if (!opaConfigProperties.getStaticpolicies().isEmpty()) { + for(OpaConfigProperties.Policy policy: opaConfigProperties.getStaticpolicies()){ + String opaFinalUrl = String.format("%s/%s", opaConfigProperties.getUrl().endsWith("/") ? opaConfigProperties.getUrl().substring(0, opaConfigProperties.getUrl().length() - 1) : opaConfigProperties.getUrl(), policy.getPackageName().startsWith("/") ? policy.getPackageName().substring(1) : policy.getPackageName()); + logger.debug("opaFinalUrl: {}", opaFinalUrl); + httpResponse = doPost(opaFinalUrl, requestBody); ; + opaStringResponse = httpResponse.body().string(); + logger.info("OPA response: {}", opaStringResponse); + logger.debug("proxy enabled : {}, statuscode : {}, opaResultKey : {}", opaConfigProperties.isProxy(), httpResponse.code(), opaConfigProperties.getResultKey()); + if (opaConfigProperties.isProxy()) { + if (httpResponse.code() != 200) { + throw new ValidationException(opaStringResponse, null); + }else{ + validateOPAResponse(opaStringResponse); + } } else { - throw new ValidationException("There is no '" + opaResultKey + "' field in the OPA response", null); + validateOPAResponse(opaStringResponse); } - } else if (httpResponse.code() != 200 ) { - throw new ValidationException(opaStringResponse, null); } } } catch (IOException e) { - logger.error("Communication exception for OPA at {}: {}", this.opaUrl, e.toString()); + e.printStackTrace(); + logger.error("Communication exception for OPA at {}: {}", opaConfigProperties.getUrl(), e.toString()); throw new ValidationException(e.toString(), null); } + logger.debug("End of the Policy Validation"); } + private void validateOPAResponse(String opaStringResponse){ + JsonObject opaResponse = gson.fromJson(opaStringResponse, JsonObject.class); + JsonObject opaResult; + if (opaResponse.has(RESULT)) { + opaResult = opaResponse.get(RESULT).getAsJsonObject(); + if (opaResult.has(opaConfigProperties.getResultKey())) { + StringBuilder denyMessage = new StringBuilder(); + extractDenyMessage(opaResponse, denyMessage); + if (StringUtils.isNotBlank(denyMessage)) { + throw new ValidationException(denyMessage.toString(), null); + } + } else { + throw new ValidationException("There is no '" + opaConfigProperties.getResultKey() + "' field in the OPA response", null); + } + } else { + throw new ValidationException("There is no 'result' field in the OPA response", null); + } + } private void extractDenyMessage(JsonObject opaResponse, StringBuilder messagebuilder) { Set> fields = opaResponse.entrySet(); fields.forEach(field -> { - if (field.getKey().equalsIgnoreCase(opaResultKey)) { + if (field.getKey().equalsIgnoreCase(opaConfigProperties.getResultKey())) { JsonArray resultKey = field.getValue().getAsJsonArray(); if (resultKey.size() != 0) { resultKey.forEach(result -> { @@ -157,50 +141,25 @@ private void extractDenyMessage(JsonObject opaResponse, StringBuilder messagebui }); } - private String getOpaInput(Pipeline pipeline, boolean deltaVerification) { + private String getOpaInput(Pipeline pipeline) { + logger.debug("Start of the getOpaInput"); String application; String pipelineName; String finalInput = null; - boolean initialSave = false; JsonObject newPipeline = pipelineToJsonObject(pipeline); if (newPipeline.has("application")) { application = newPipeline.get("application").getAsString(); pipelineName = newPipeline.get("name").getAsString(); - logger.debug("## input : {}", gson.toJson(newPipeline)); - if (newPipeline.has("stages")) { - initialSave = newPipeline.get("stages").getAsJsonArray().size() == 0; - } logger.debug("## application : {}, pipelineName : {}", application, pipelineName); - // if deltaVerification is true, add both current and new pipelines in single json - if (deltaVerification && !initialSave) { - List pipelines = - new ArrayList<>(pipelineDAO.getPipelinesByApplication(application, true)); - logger.debug("## pipeline list count : {}", pipelines.size()); - Optional currentPipeline = - pipelines.stream() - .filter(p -> ((String) p.getName()).equalsIgnoreCase(pipelineName)) - .findFirst(); - if (currentPipeline.isPresent()) { - finalInput = getFinalOpaInput(newPipeline, pipelineToJsonObject(currentPipeline.get())); - } else { - throw new ValidationException("There is no pipeline with name " + pipelineName, null); - } - } else { - finalInput = gson.toJson(addWrapper(addWrapper(newPipeline, "new"), "input")); - } + + finalInput = gson.toJson(addWrapper(addWrapper(newPipeline, "pipeline"), "input")); } else { throw new ValidationException("The received pipeline doesn't have application field", null); } + logger.debug("End of the getOpaInput"); return finalInput; } - private String getFinalOpaInput(JsonObject newPipeline, JsonObject currentPipeline) { - JsonObject input = new JsonObject(); - input.add("new", newPipeline); - input.add("current", currentPipeline); - return gson.toJson(addWrapper(input, "input")); - } - private JsonObject addWrapper(JsonObject pipeline, String wrapper) { JsonObject input = new JsonObject(); input.add(wrapper, pipeline); @@ -208,21 +167,24 @@ private JsonObject addWrapper(JsonObject pipeline, String wrapper) { } private JsonObject pipelineToJsonObject(Pipeline pipeline) { - String pipelineStr = gson.toJson(pipeline, Pipeline.class); - return gson.fromJson(pipelineStr, JsonObject.class); + logger.debug("Start of the pipelineToJsonObject"); + try { + String pipelineStr = gson.toJson(pipeline, Pipeline.class); + logger.debug("End of the pipelineToJsonObject"); + return gson.fromJson(pipelineStr, JsonObject.class); + } catch (JsonParseException e) { + e.printStackTrace(); + logger.error("Exception occure while converting the input pipline to Json :{}", e); + logger.debug("End of the pipelineToJsonObject"); + throw new ValidationException("Converstion Failed while converting the input pipline to Json:" + e.toString(), null); + } } - /* - * private Response doGet(String url) throws IOException { Request req = (new - * Request.Builder()).url(url).get().build(); return getResponse(url, req); } - */ - private Response doPost(String url, RequestBody requestBody) throws IOException { Request req = (new Request.Builder()).url(url).post(requestBody).build(); - return getResponse(url, req); + return getOPAResponse(url, req); } - - private Response getResponse(String url, Request req) throws IOException { + private Response getOPAResponse(String url, Request req) throws IOException { Response httpResponse = this.opaClient.newCall(req).execute(); ResponseBody responseBody = httpResponse.body(); if (responseBody == null) { @@ -230,4 +192,5 @@ private Response getResponse(String url, Request req) throws IOException { } return httpResponse; } + } \ No newline at end of file diff --git a/front50/plugin-info.json b/front50/plugin-info.json new file mode 100644 index 0000000..2ce85cb --- /dev/null +++ b/front50/plugin-info.json @@ -0,0 +1,17 @@ +{ + "id": "Opsmx.StaticPolicyPlugin", + "description": "An example of a PF4J-based plugin that provides a custom pipeline stage.", + "provider": "https://github.com/opsmx", + "releases": [ + { + "version": "v1.0.1-SNAPSHOT", + "date": "2023-12-18T14:37:54.207518Z", + "requires": "front50>=0.0.0", + "sha512sum": "b1c6512e1b3975b2c29bcc20d37b6d5d422e78983879d9d2c8f548b6f3c205eda929b12888b7a7b13b47f85967cb6b73f20f4e700ee2ee56380e7b613d9ad261", + "preferred": false, + "compatibility": [ + + ] + } + ] +} \ No newline at end of file diff --git a/front50/settings.gradle b/front50/settings.gradle index 490ec62..a37b8e3 100644 --- a/front50/settings.gradle +++ b/front50/settings.gradle @@ -9,7 +9,7 @@ pluginManagement { } } -rootProject.name = "staticpolicy" +rootProject.name = "Front50PolicyPlugin" include "custom-stage-front50" diff --git a/orca/build.gradle b/orca/build.gradle new file mode 100644 index 0000000..0098a9d --- /dev/null +++ b/orca/build.gradle @@ -0,0 +1,53 @@ +buildscript { + repositories { + mavenCentral() + } +} + +plugins { + id("io.spinnaker.plugin.bundler").version("$spinnakerGradleVersion") + id("com.palantir.git-version").version("0.12.2") + id("com.diffplug.spotless").version("5.1.0") +} + +repositories { + mavenCentral() +} + +spinnakerBundle { + pluginId = "Opsmx.RuntimePolicyPlugin" + description = "An example of a PF4J-based plugin that provides a custom pipeline stage." + provider = "https://github.com/opsmx" + version = rootProject.version +} + +version = "v1.0.2-SNAPSHOT" + +subprojects { + group = "com.opsmx.plugin.policy.runtime" + version = rootProject.version + + if (name != "custom-stage-deck") { + apply plugin: "com.diffplug.spotless" + spotless { + kotlin { + ktlint().userData([ + disabled_rules : "no-wildcard-imports", + indent_size : "2", + continuation_indent_size: "2", + ]) + } + } + } +} + +String normalizedVersion() { + String fullVersion = gitVersion() + String normalized = fullVersion.split("-").first() + if (fullVersion.contains("dirty")) { + return "$normalized-SNAPSHOT" + } else { + return normalized + } +} + diff --git a/orca/custom-policy-orca/custom-policy-orca.gradle b/orca/custom-policy-orca/custom-policy-orca.gradle new file mode 100644 index 0000000..0a82f16 --- /dev/null +++ b/orca/custom-policy-orca/custom-policy-orca.gradle @@ -0,0 +1,72 @@ +import org.yaml.snakeyaml.Yaml + +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" + classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlinVersion" + } +} + +apply plugin: "io.spinnaker.plugin.service-extension" +apply plugin: "maven-publish" +apply plugin: "kotlin" +apply plugin: "kotlin-kapt" +apply plugin: "kotlin-spring" + +repositories { + mavenCentral() + jcenter() +} + +sourceSets { + main { + java { srcDirs = ['src/main/java'] } + } +} + +spinnakerPlugin { + serviceName = "orca" + pluginClass = "com.opsmx.plugin.policy.runtime.RuntimePolicyPlugin" + requires="orca>=0.0.0" +} +configurations { + // configuration that holds jars to include in the jar + extraLibs +} + + +dependencies { + compileOnly "org.pf4j:pf4j:${pf4jVersion}" + compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + compileOnly "com.netflix.spinnaker.kork:kork-plugins-api:${korkVersion}" + compileOnly "com.netflix.spinnaker.kork:kork-web:7.105.0" + compileOnly "io.spinnaker.orca:orca-api:${orcaVersion}" + compileOnly "io.spinnaker.orca:orca-core:${orcaVersion}" + compileOnly "io.spinnaker.orca:orca-echo:${orcaVersion}" + compileOnly "io.spinnaker.orca:orca-front50:${orcaVersion}" + kapt "org.pf4j:pf4j:${pf4jVersion}" + + compileOnly group: 'com.squareup.retrofit', name: 'retrofit', version: '1.9.0' + compileOnly group: 'com.squareup.retrofit', name: 'converter-jackson', version: '1.9.0' + compileOnly group: 'com.jakewharton.retrofit', name: 'retrofit1-okhttp3-client', version: '1.1.0' + implementation group: 'com.jcraft', name: 'jsch', version: '0.1.55' + + testRuntime "org.junit.jupiter:junit-jupiter-engine:5.4.0" + testRuntime "org.junit.platform:junit-platform-launcher:1.4.0" + testRuntime "org.junit.platform:junit-platform-commons:1.5.2" +} + +configurations.all { + resolutionStrategy.eachDependency { DependencyResolveDetails details -> + if (details.requested.group == 'org.apache.logging.log4j') { + details.useVersion '2.17.1' + } + } +} + +tasks.withType(Test) { + useJUnitPlatform() +} diff --git a/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/OpaConfigProperties.java b/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/OpaConfigProperties.java new file mode 100644 index 0000000..2e78f1b --- /dev/null +++ b/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/OpaConfigProperties.java @@ -0,0 +1,91 @@ +package com.opsmx.plugin.policy.runtime; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +@ConfigurationProperties(prefix = "policy.opa") +@EnableConfigurationProperties({OpaConfigProperties.class, OpaConfigProperties.Policy.class}) +public class OpaConfigProperties { + private String url="http://opa:8181/v1/data"; + private String resultKey="deny"; + private boolean enabled=false; + private boolean proxy=true; + private boolean deltaVerification=false; + private List runtime; + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getResultKey() { + return resultKey; + } + + public void setResultKey(String resultKey) { + this.resultKey = resultKey; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isProxy() { + return proxy; + } + + public void setProxy(boolean proxy) { + this.proxy = proxy; + } + + public boolean isDeltaVerification() { + return deltaVerification; + } + + public void setDeltaVerification(boolean deltaVerification) { + this.deltaVerification = deltaVerification; + } + + public List getRuntime() { + return runtime; + } + + public void setRuntime(List runtime) { + this.runtime = runtime; + } + + @Configuration + @ConfigurationProperties(prefix = "policy.opa.runtime") + public static class Policy{ + private String name; + private String packageName; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + } + +} diff --git a/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/OpenPolicyAgentPreprocessor.java b/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/OpenPolicyAgentPreprocessor.java new file mode 100644 index 0000000..0d7a597 --- /dev/null +++ b/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/OpenPolicyAgentPreprocessor.java @@ -0,0 +1,234 @@ +package com.opsmx.plugin.policy.runtime; + +import java.io.IOException; +import java.util.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.*; +import com.netflix.spinnaker.kork.plugins.api.internal.SpinnakerExtensionPoint; +import com.netflix.spinnaker.orca.api.pipeline.ExecutionPreprocessor; +import okhttp3.*; +import org.apache.commons.lang3.StringUtils; +import org.pf4j.Extension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.ComponentScan; + +import com.netflix.spinnaker.kork.web.exceptions.ValidationException; + +import org.springframework.stereotype.Component; + +import javax.annotation.Nonnull; + +@Extension +@Component +@ComponentScan("com.opsmx.plugin.policy.runtime") +public class OpenPolicyAgentPreprocessor implements ExecutionPreprocessor, SpinnakerExtensionPoint { + + private final Logger logger = LoggerFactory.getLogger(OpenPolicyAgentPreprocessor.class); + private static final String RESULT = "result"; + private static final String STATUS = "status"; + private OpaConfigProperties opaConfigProperties; + @Autowired + ObjectMapper objectMapper; + + /* define configurable variables: + opaUrl: OPA or OPA-Proxy base url + opaResultKey: Not needed for Proxy. The key to watch in the return from OPA. + policyLocation: Where in OPA is the policy located, generally this is v0/location/to/policy/path + And for Proxy it is /v1/staticPolicy/eval + isOpaEnabled: Policy evaluation is skipped if this is false + isOpaProxy : true if Proxy is present instead of OPA server. + */ + + + private final Gson gson = new Gson(); + + /* OPA spits JSON */ + private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + private final OkHttpClient opaClient = new OkHttpClient(); + @Autowired + public OpenPolicyAgentPreprocessor(OpaConfigProperties opaConfigProperties) { + this.opaConfigProperties = opaConfigProperties; + } + @Override + public boolean supports(@Nonnull Map pipeline, @Nonnull Type type){ + logger.debug("ExecutionPreprocessor Type :{}",type); + if(type.equals(Type.PIPELINE)){ + return true; + } + return false; + } + @Override + public Map process(@Nonnull Map pipeline){ + logger.debug("Start of the Policy Validation"); + logger.debug("input Pipeline :{}", pipeline); + if (!opaConfigProperties.isEnabled()) { + logger.info("OPA not enabled, returning"); + logger.debug("End of the Policy Validation"); + return pipeline; + } + try { + /*if(isChildPipeline(pipeline)){ + logger.debug("This pipeline is a child pipeline and trigger by parent "); + logger.debug("End of the Policy Validation"); + return pipeline; + }*/ + // Form input to opa + String finalInput = getOpaInput(pipeline); + + logger.debug("Verifying {} with OPA", finalInput); + + /* build our request to OPA */ + RequestBody requestBody = RequestBody.create(JSON, finalInput); + + logger.debug("OPA endpoint : {}", opaConfigProperties.getUrl()); + String opaStringResponse = "{}"; + logger.debug("Policy list :"+opaConfigProperties.getRuntime().size()); + + if (!opaConfigProperties.getRuntime().isEmpty()) { + for (OpaConfigProperties.Policy policy : opaConfigProperties.getRuntime()) { + String opaFinalUrl = String.format("%s/%s", opaConfigProperties.getUrl().endsWith("/") ? opaConfigProperties.getUrl().substring(0, opaConfigProperties.getUrl().length() - 1) : opaConfigProperties.getUrl(), policy.getPackageName().startsWith("/") ? policy.getPackageName().substring(1) : policy.getPackageName()); + logger.debug("opaFinalUrl: {}", opaFinalUrl); + Response httpResponse = doPost(opaFinalUrl, requestBody); + opaStringResponse = httpResponse.body().string(); + logger.debug("OPA response: {}", opaStringResponse); + logger.debug("proxy enabled : {}, statuscode : {}, opaResultKey : {}", opaConfigProperties.isProxy(), httpResponse.code(), opaConfigProperties.getResultKey()); + if (opaConfigProperties.isProxy()) { + if (httpResponse.code() != 200) { + throw new ValidationException(opaStringResponse, null); + }else{ + validateOPAResponse(opaStringResponse); + } + } else { + validateOPAResponse(opaStringResponse); + } + } + } + + } catch (IOException e) { + e.printStackTrace(); + logger.error("Communication exception for OPA at {}: {}", opaConfigProperties.getUrl(), e.toString()); + logger.debug("End of the Policy Validation"); + throw new ValidationException(e.toString(), null); + } catch (Exception e) { + e.printStackTrace(); + logger.error("Exception occured : {}", e); + logger.error("Some thing wrong While processing the OPA Validation, input : {}", pipeline); + logger.debug("End of the Policy Validation"); + throw new ValidationException(e.toString(), null); + } + logger.debug("End of the Policy Validation"); + return pipeline; + } + + private boolean isChildPipeline(Map pipeline) { + if( pipeline.containsKey("trigger") ) { + Map trigger = (Map) pipeline.get("trigger"); + if (trigger.containsKey("type") && trigger.get("type").toString().equalsIgnoreCase("pipeline") && trigger.containsKey("parentExecution")) { + return true; + } + } + return false; + } + + private void validateOPAResponse(String opaStringResponse){ + JsonObject opaResponse = gson.fromJson(opaStringResponse, JsonObject.class); + JsonObject opaResult; + if (opaResponse.has(RESULT)) { + opaResult = opaResponse.get(RESULT).getAsJsonObject(); + if (opaResult.has(opaConfigProperties.getResultKey())) { + StringBuilder denyMessage = new StringBuilder(); + extractDenyMessage(opaResponse, denyMessage); + if (StringUtils.isNotBlank(denyMessage)) { + throw new ValidationException(denyMessage.toString(), null); + } + } else { + throw new ValidationException("There is no '" + opaConfigProperties.getResultKey() + "' field in the OPA response", null); + } + } else { + throw new ValidationException("There is no 'result' field in the OPA response", null); + } + } + + private void extractDenyMessage(JsonObject opaResponse, StringBuilder messagebuilder) { + Set> fields = opaResponse.entrySet(); + fields.forEach( + field -> { + if (field.getKey().equalsIgnoreCase(opaConfigProperties.getResultKey())) { + JsonArray resultKey = field.getValue().getAsJsonArray(); + if (resultKey.size() != 0) { + resultKey.forEach( + result -> { + if (StringUtils.isNotEmpty(messagebuilder)) { + messagebuilder.append(", "); + } + messagebuilder.append(result.getAsString()); + }); + } + } else if (field.getValue().isJsonObject()) { + extractDenyMessage(field.getValue().getAsJsonObject(), messagebuilder); + } else if (field.getValue().isJsonArray()) { + field.getValue().getAsJsonArray().forEach(obj -> { + extractDenyMessage(obj.getAsJsonObject(), messagebuilder); + }); + } + }); + } + + private String getOpaInput(Map pipeline) { + logger.debug("Start of the getOpaInput"); + String application; + String pipelineName; + try { + //JsonObject newPipeline = pipelineToJsonObject(pipeline); + if (pipeline.containsKey("application")) { + application = pipeline.get("application").toString(); + pipelineName = pipeline.get("name").toString(); + logger.debug("## application : {}, pipelineName : {}", application, pipelineName); + logger.debug("End of the getOpaInput"); + return objectMapper.writeValueAsString(addWrapper(addWrapper(pipeline, "pipeline"), "input")); + } else { + throw new ValidationException("The received pipeline doesn't have application field", null); + } + } catch (Exception e) { + e.printStackTrace(); + logger.error("Exception occured converting the PipelineExecution :{}", e); + throw new ValidationException("Failed to convert the PipelineExecution to OPA Input :" + e.toString(), null); + } + } + + private Map addWrapper(Map pipeline, String wrapper) { + Map input = new HashMap<>(); + input.put(wrapper, pipeline); + return input; + } + + private JsonObject pipelineToJsonObject(Map pipeline) { + logger.debug("Start of the pipelineToJsonObject"); + try { + String pipelineStr = gson.toJson(pipeline, Map.class); + logger.debug("End of the pipelineToJsonObject"); + return gson.fromJson(pipelineStr, JsonObject.class); + }catch (Exception e){ + e.printStackTrace(); + logger.error("Exception occure while converting the input pipline to Json :{}", e); + throw new ValidationException("Converstion Failed while converting the input pipline to Json:" + e.toString(), null); + } + } + + private Response doPost(String url, RequestBody requestBody) throws IOException { + Request req = (new Request.Builder()).url(url).post(requestBody).build(); + return getOPAResponse(url, req); + } + private Response getOPAResponse(String url, Request req) throws IOException { + Response httpResponse = this.opaClient.newCall(req).execute(); + ResponseBody responseBody = httpResponse.body(); + if (responseBody == null) { + throw new IOException("Http call yielded null response!! url:" + url); + } + return httpResponse; + } +} \ No newline at end of file diff --git a/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/RestartTaskExecutionInterceptor.java b/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/RestartTaskExecutionInterceptor.java new file mode 100644 index 0000000..da53e8a --- /dev/null +++ b/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/RestartTaskExecutionInterceptor.java @@ -0,0 +1,101 @@ +package com.opsmx.plugin.policy.runtime; + +import com.netflix.spinnaker.orca.api.pipeline.Task; +import com.netflix.spinnaker.orca.api.pipeline.TaskExecutionInterceptor; +import com.netflix.spinnaker.orca.api.pipeline.models.ExecutionStatus; +import com.netflix.spinnaker.orca.api.pipeline.models.StageExecution; +import com.netflix.spinnaker.orca.api.pipeline.models.TaskExecution; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +public class RestartTaskExecutionInterceptor implements TaskExecutionInterceptor { + + private final Logger logger = LoggerFactory.getLogger(RestartTaskExecutionInterceptor.class); + + private ValidationRestartPipeline restartPipelineValidationTask; + + public RestartTaskExecutionInterceptor(ValidationRestartPipeline restartPipelineValidationTask) { + this.restartPipelineValidationTask = restartPipelineValidationTask; + } + + @Override + public StageExecution beforeTaskExecution(Task task, StageExecution stage) { + logger.debug("Start of the beforeTaskExecution RestartTaskExecutionInterceptor"); + logger.debug("stage :{}",stage.getExecution()); + logger.debug("Cancellation Reason :{}",stage.getExecution().getCancellationReason()); + logger.debug("stage type :{}",stage.getType()); + logger.debug("stage tasks :{}",stage.getTasks()); + List taskExecutions = stage.getTasks(); + taskExecutions.stream().forEach(taskExecution -> { logger.debug("Task Execution :{}",taskExecution.getName());}); + if (stage.getExecution()!=null && stage.getContext().containsKey("restartDetails")){ + logger.info("Stage is being restarted, stage type : {}",stage.getType()); + if(isValidStageType(stage.getType()) && !isRepeating(stage.getContext())){ + restartPipelineValidationTask.execute(stage); + } + } else if (stage.getExecution()!=null && stage.getExecution().getCancellationReason() != null ){ + logger.info("Failed pipeline got restarted :{} ",stage.getExecution().getCancellationReason()); + // if(stage.isManualJudgmentType()){ + if(isValidStageType(stage.getType())){ + logger.info("***************************"); + taskExecutions.stream().forEach(taskExecution -> { + logger.debug("Task Execution :{}",taskExecution.getName()); + taskExecution.setStatus(ExecutionStatus.TERMINAL); + taskExecution.setEndTime(System.currentTimeMillis()); + }); + stage.setStatus(ExecutionStatus.TERMINAL); + stage.getExecution().setStatus(ExecutionStatus.TERMINAL); + setCancelReason(stage); + stage.getContext().put("errors", stage.getExecution().getCancellationReason()); + } + } + /*else if (stage.getExecution()!=null && stage.getContext().containsKey("failPipeline")){ + logger.info("Pipeline is being Reschedule, stage type : {}",stage.getType()); + if("manualJudgment".equalsIgnoreCase(stage.getType()) && stage.getContext().get("failPipeline").toString().equalsIgnoreCase("true")){ + restartPipelineValidationTask.execute(stage); + } + }*/ + + logger.debug("End of the beforeTaskExecution RestartTaskExecutionInterceptor"); + return stage; + } + + private void setCancelReason(StageExecution stage) { + List errors =new ArrayList<>(); + errors.add(stage.getExecution().getCancellationReason()); + stage.getExecution().getContext().put("exception", new HashMap<>().put("details", new HashMap<>().put("errors",errors))); + } + + private boolean isRepeating(Map context){ + long restartTime = Long.valueOf(String.valueOf(((Map)context.get("restartDetails")).get("restartTime"))); + long systemTime = System.currentTimeMillis(); + logger.debug("restartTime :{}, systemTime :{} , diff :{}",restartTime, systemTime, (systemTime-restartTime)); + //ManualJudgmentTask and MonitorPipelineTask using 15 seconds to repeat task execution (backoffPeriod) + if((systemTime-restartTime) > 15000){ + return true; + } + return false; + } + private boolean isValidStageType(String stageType){ + if (stageType.equalsIgnoreCase("manualJudgment")) { + return true; + } else if (stageType.equalsIgnoreCase("evaluateVariables")) { + return true; + } else if (stageType.equalsIgnoreCase("pipeline")) { + return true; + } else if (stageType.equalsIgnoreCase("runJob")) { + return true; + } else if (stageType.equalsIgnoreCase("startScript")) { + return true; + } else if (stageType.equalsIgnoreCase("jenkins")) { + return true; + } + return false; + } +} diff --git a/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/RuntimePolicyPlugin.java b/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/RuntimePolicyPlugin.java new file mode 100644 index 0000000..45228a6 --- /dev/null +++ b/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/RuntimePolicyPlugin.java @@ -0,0 +1,23 @@ +package com.opsmx.plugin.policy.runtime; + +import org.pf4j.Plugin; +import org.pf4j.PluginWrapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RuntimePolicyPlugin extends Plugin { + + private final Logger log = LoggerFactory.getLogger(getClass()); + + public RuntimePolicyPlugin(PluginWrapper wrapper) { + super(wrapper); + } + + public void start() { + log.info("Runtime Policy plugin start()"); + } + + public void stop() { + log.info("Runtime Policy plugin stop()"); + } +} \ No newline at end of file diff --git a/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/ValidationRestartPipeline.java b/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/ValidationRestartPipeline.java new file mode 100644 index 0000000..0b629fd --- /dev/null +++ b/orca/custom-policy-orca/src/main/java/com/opsmx/plugin/policy/runtime/ValidationRestartPipeline.java @@ -0,0 +1,216 @@ +package com.opsmx.plugin.policy.runtime; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.*; +import com.netflix.spinnaker.kork.web.exceptions.ValidationException; +import com.netflix.spinnaker.orca.api.pipeline.models.PipelineExecution; +import com.netflix.spinnaker.orca.api.pipeline.models.StageExecution; +import com.netflix.spinnaker.orca.api.pipeline.models.Trigger; +import com.netflix.spinnaker.orca.front50.Front50Service; +import okhttp3.*; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +@Component +public class ValidationRestartPipeline { + private final Logger logger = LoggerFactory.getLogger(ValidationRestartPipeline.class); + private static final String RESULT = "result"; + private static final String STATUS = "status"; + private OpaConfigProperties opaConfigProperties; + + private Front50Service front50Service; + + @Autowired + ObjectMapper objectMapper; + + /* define configurable variables: + opaUrl: OPA or OPA-Proxy base url + opaResultKey: Not needed for Proxy. The key to watch in the return from OPA. + policyLocation: Where in OPA is the policy located, generally this is v0/location/to/policy/path + And for Proxy it is /v1/staticPolicy/eval + isOpaEnabled: Policy evaluation is skipped if this is false + isOpaProxy : true if Proxy is present instead of OPA server. + */ + + + private final Gson gson = new Gson(); + + /* OPA spits JSON */ + private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + private final OkHttpClient opaClient = new OkHttpClient(); + + @Autowired + public ValidationRestartPipeline(OpaConfigProperties opaConfigProperties, Front50Service front50Service) { + logger.debug("Start of the ValidationRestartPipeline Constructor"); + this.opaConfigProperties = opaConfigProperties; + this.front50Service = front50Service; + logger.debug("End of the ValidationRestartPipeline Constructor"); + } + + public void execute(@NotNull StageExecution stageExecution) { + logger.debug("Start of the ValidationRestartPipeline Policy Validation"); + if (!opaConfigProperties.isEnabled()) { + logger.info("OPA not enabled, returning"); + logger.debug("End of the ValidationRestartPipeline Policy Validation"); + return; + } + + try { + + // Form input to opa + String finalInput = getOpaInput(getPipeline(stageExecution)); + logger.debug("Verifying with OPA input :{} ", finalInput); + /* build our request to OPA */ + RequestBody requestBody = RequestBody.create(JSON, finalInput); + logger.debug("OPA endpoint : {}", opaConfigProperties.getUrl()); + String opaStringResponse ="{}"; + + /* fetch the response from the spawned call execution */ + if (!opaConfigProperties.getRuntime().isEmpty()) { + for(OpaConfigProperties.Policy policy: opaConfigProperties.getRuntime()){ + String opaFinalUrl = String.format("%s/%s", opaConfigProperties.getUrl().endsWith("/") ? opaConfigProperties.getUrl().substring(0, opaConfigProperties.getUrl().length() - 1) : opaConfigProperties.getUrl(), policy.getPackageName().startsWith("/") ? policy.getPackageName().substring(1) : policy.getPackageName()); + logger.debug("opaFinalUrl: {}", opaFinalUrl); + Response httpResponse = doPost(opaFinalUrl, requestBody); ; + opaStringResponse = httpResponse.body().string(); + logger.info("OPA response: {}", opaStringResponse); + logger.debug("proxy enabled : {}, statuscode : {}, opaResultKey : {}", opaConfigProperties.isProxy(), httpResponse.code(), opaConfigProperties.getResultKey()); + if (opaConfigProperties.isProxy()) { + if (httpResponse.code() != 200) { + throw new ValidationException(opaStringResponse, null); + }else{ + validateOPAResponse(opaStringResponse); + } + } else { + validateOPAResponse(opaStringResponse); + } + } + } + } catch (IOException e) { + e.printStackTrace(); + logger.error("Communication exception for OPA at {}: {}", opaConfigProperties.getUrl(), e.toString()); + throw new ValidationException(e.toString(), null); + } + logger.debug("End of the ValidationRestartPipeline Policy Validation"); + } + + private Map getPipeline(StageExecution stageExecution){ + PipelineExecution pipelineExecution = stageExecution.getExecution(); + + String applicationName = pipelineExecution.getApplication(); + String pipelineId = pipelineExecution.getPipelineConfigId(); + if (!StringUtils.isEmpty(pipelineId)) { + return front50Service.getPipelines(applicationName).stream() + .filter(m -> m.containsKey("id")) + .filter(m -> m.get("id").equals(pipelineId)) + .findFirst() + .orElse(null); + } + return null; + } + private boolean isChildPipeline(PipelineExecution pipelineExecution) { + if (pipelineExecution.getTrigger() != null) { + Trigger trigger = pipelineExecution.getTrigger(); + if (!StringUtils.isEmpty(trigger.getType()) && trigger.getType().equalsIgnoreCase("pipeline")) { + return true; + } + } + return false; + } + + private void validateOPAResponse(String opaStringResponse){ + JsonObject opaResponse = gson.fromJson(opaStringResponse, JsonObject.class); + JsonObject opaResult; + if (opaResponse.has(RESULT)) { + opaResult = opaResponse.get(RESULT).getAsJsonObject(); + if (opaResult.has(opaConfigProperties.getResultKey())) { + StringBuilder denyMessage = new StringBuilder(); + extractDenyMessage(opaResponse, denyMessage); + if (StringUtils.isNotBlank(denyMessage)) { + throw new ValidationException(denyMessage.toString(), null); + } + } else { + throw new ValidationException("There is no '" + opaConfigProperties.getResultKey() + "' field in the OPA response", null); + } + } else { + throw new ValidationException("There is no 'result' field in the OPA response", null); + } + } + + private void extractDenyMessage(JsonObject opaResponse, StringBuilder messagebuilder) { + Set> fields = opaResponse.entrySet(); + fields.forEach(field -> { + if (field.getKey().equalsIgnoreCase(opaConfigProperties.getResultKey())) { + JsonArray resultKey = field.getValue().getAsJsonArray(); + if (resultKey.size() != 0) { + resultKey.forEach(result -> { + if (StringUtils.isNotEmpty(messagebuilder)) { + messagebuilder.append(", "); + } + messagebuilder.append(result.getAsString()); + }); + } + } else if (field.getValue().isJsonObject()) { + extractDenyMessage(field.getValue().getAsJsonObject(), messagebuilder); + } else if (field.getValue().isJsonArray()) { + field.getValue().getAsJsonArray().forEach(obj -> { + extractDenyMessage(obj.getAsJsonObject(), messagebuilder); + }); + } + }); + } + + private String getOpaInput(Map pipeline) { + logger.debug("Start of the getOpaInput"); + String application; + String pipelineName; + try { + //Map newPipeline = pipelineToMapObject(pipeline); + if (pipeline.containsKey("application")) { + application = pipeline.get("application").toString(); + pipelineName = pipeline.get("name").toString(); + logger.debug("## application : {}, pipelineName : {}", application, pipelineName); + logger.debug("End of the getOpaInput"); + return objectMapper.writeValueAsString(addWrapper(addWrapper(pipeline, "pipeline"), "input")); + } else { + throw new ValidationException("The received pipeline doesn't have application field", null); + } + } catch (Exception e) { + e.printStackTrace(); + logger.error("Exception occured converting the PipelineExecution :{}", e); + throw new ValidationException("Failed to convert the PipelineExecution to OPA Input :" + e.toString(), null); + } + } + + private Map addWrapper(Map pipeline, String wrapper) { + Map input = new HashMap(); + input.put(wrapper, pipeline); + return input; + } + + private Map pipelineToMapObject(PipelineExecution pipelineExecution) { + logger.debug("Converting the Pipeline Execution to Map Object"); + return objectMapper.convertValue(pipelineExecution, Map.class); + } + + private Response doPost(String url, RequestBody requestBody) throws IOException { + Request req = (new Request.Builder()).url(url).post(requestBody).build(); + return getOPAResponse(url, req); + } + private Response getOPAResponse(String url, Request req) throws IOException { + Response httpResponse = this.opaClient.newCall(req).execute(); + ResponseBody responseBody = httpResponse.body(); + if (responseBody == null) { + throw new IOException("Http call yielded null response!! url:" + url); + } + return httpResponse; + } +} diff --git a/orca/gradle.properties b/orca/gradle.properties new file mode 100644 index 0000000..8ede597 --- /dev/null +++ b/orca/gradle.properties @@ -0,0 +1,11 @@ +spinnakerRelease=master-20210212010018 +org.gradle.parallel=true + +spinnakerGradleVersion=8.10.0 +pf4jVersion=3.2.0 +korkVersion=7.99.1 +#orcaVersion=2.19.0-20210209140018 +#echoVersion=2.17.0-20210303170018 +orcaVersion=8.27.4 +echoVersion=2.32.0 +kotlinVersion=1.3.50 diff --git a/orca/gradle/wrapper/gradle-wrapper.jar b/orca/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..490fda8 Binary files /dev/null and b/orca/gradle/wrapper/gradle-wrapper.jar differ diff --git a/orca/gradle/wrapper/gradle-wrapper.properties b/orca/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a4b4429 --- /dev/null +++ b/orca/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/orca/gradlew b/orca/gradlew new file mode 100755 index 0000000..2fe81a7 --- /dev/null +++ b/orca/gradlew @@ -0,0 +1,183 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/orca/gradlew.bat b/orca/gradlew.bat new file mode 100644 index 0000000..62bd9b9 --- /dev/null +++ b/orca/gradlew.bat @@ -0,0 +1,103 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/orca/plugin-info.json b/orca/plugin-info.json new file mode 100644 index 0000000..ecb1aba --- /dev/null +++ b/orca/plugin-info.json @@ -0,0 +1,17 @@ +{ + "id": "Opsmx.RuntimePolicyPlugin", + "description": "An example of a PF4J-based plugin that provides a custom pipeline stage.", + "provider": "https://github.com/opsmx", + "releases": [ + { + "version": "v1.0.1-SNAPSHOT", + "date": "2023-12-18T14:38:21.071875Z", + "requires": "orca>=0.0.0", + "sha512sum": "03c7787f41c522c42d31523afeb8316d7b8ea8e50aa5f45ae116baf2f0944b71c3f05a93e0812fc6d24c8555a97a5c72467d28286c48b56700e3ae3c334b57fe", + "preferred": false, + "compatibility": [ + + ] + } + ] +} \ No newline at end of file diff --git a/orca/script.sh b/orca/script.sh new file mode 100644 index 0000000..28066c0 --- /dev/null +++ b/orca/script.sh @@ -0,0 +1,3 @@ +#! /bin/bash + +ls -l diff --git a/orca/settings.gradle b/orca/settings.gradle new file mode 100644 index 0000000..c5af8d1 --- /dev/null +++ b/orca/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + repositories { + mavenLocal() + maven { + url "https://plugins.gradle.org/m2/" + } + maven { url "https://dl.bintray.com/spinnaker/gradle/" } + gradlePluginPortal() + } +} + +rootProject.name = "OrcaPolicyPlugin" + +include "custom-policy-orca" + +def setBuildFile(project) { + project.buildFileName = "${project.name}.gradle" + project.children.each { + setBuildFile(it) + } +} + +rootProject.children.each { + setBuildFile(it) +} diff --git a/plugin-info.json b/plugin-info.json new file mode 100644 index 0000000..e6efeb6 --- /dev/null +++ b/plugin-info.json @@ -0,0 +1,17 @@ +{ + "id": "Opsmx.StaticPolicyPlugin", + "description": "An example of a PF4J-based plugin that provides a custom pipeline stage.", + "provider": "https://github.com/opsmx", + "releases": [ + { + "version": "v1.0.1-SNAPSHOT", + "date": "2023-12-18T12:55:50.817904Z", + "requires": "front50>=0.0.0", + "sha512sum": "9c4fe47eb9a4f18e0cef9494f82a8b4c7e664e757372e82fa775841e464f43ad868b66bd8a3377ec8a8540d4d94e9675e045aaddcf68a8d87009763d30ac4c25", + "preferred": false, + "compatibility": [ + + ] + } + ] +} \ No newline at end of file