diff --git a/orca-integrations-gremlin/orca-integrations-gremlin.gradle b/orca-integrations-gremlin/orca-integrations-gremlin.gradle new file mode 100644 index 0000000000..06be5ee4a9 --- /dev/null +++ b/orca-integrations-gremlin/orca-integrations-gremlin.gradle @@ -0,0 +1,9 @@ +apply from: "$rootDir/gradle/kotlin.gradle" + +dependencies { + compile project(":orca-core") + compile project(":orca-kotlin") + compile project(":orca-retrofit") + + testCompile project(":orca-core-tck") +} diff --git a/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/config/GremlinConfiguration.kt b/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/config/GremlinConfiguration.kt new file mode 100644 index 0000000000..64a1f7ef19 --- /dev/null +++ b/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/config/GremlinConfiguration.kt @@ -0,0 +1,55 @@ +package com.netflix.spinnaker.orca.config; + +import com.fasterxml.jackson.databind.PropertyNamingStrategy +import com.fasterxml.jackson.databind.SerializationFeature +import com.netflix.spinnaker.orca.gremlin.GremlinConverter +import com.netflix.spinnaker.orca.gremlin.GremlinService +import com.netflix.spinnaker.orca.jackson.OrcaObjectMapper +import com.netflix.spinnaker.orca.retrofit.logging.RetrofitSlf4jLog +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import retrofit.Endpoint +import retrofit.Endpoints +import retrofit.RequestInterceptor +import retrofit.RestAdapter +import retrofit.client.Client + +@Configuration +@ConditionalOnProperty("integrations.gremlin.enabled") +@ComponentScan( + "com.netflix.spinnaker.orca.gremlin.pipeline", + "com.netflix.spinnaker.orca.gremlin.tasks" +) +class GremlinConfiguration { + + @Bean + fun gremlinEndpoint( + @Value("\${integrations.gremlin.baseUrl}") gremlinBaseUrl: String): Endpoint { + return Endpoints.newFixedEndpoint(gremlinBaseUrl) + } + + @Bean + fun gremlinService( + retrofitClient: Client, + gremlinEndpoint: Endpoint, + spinnakerRequestInterceptor: RequestInterceptor + ): GremlinService { + val mapper = OrcaObjectMapper + .newInstance() + .setPropertyNamingStrategy( + PropertyNamingStrategy.SNAKE_CASE) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // we want Instant serialized as ISO string + return RestAdapter.Builder() + .setRequestInterceptor(spinnakerRequestInterceptor) + .setEndpoint(gremlinEndpoint) + .setClient(retrofitClient) + .setLogLevel(RestAdapter.LogLevel.BASIC) + .setLog(RetrofitSlf4jLog(GremlinService::class.java)) + .setConverter(GremlinConverter(mapper)) + .build() + .create(GremlinService::class.java) + } +} diff --git a/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/GremlinConverter.java b/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/GremlinConverter.java new file mode 100644 index 0000000000..e909c98584 --- /dev/null +++ b/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/GremlinConverter.java @@ -0,0 +1,54 @@ +package com.netflix.spinnaker.orca.gremlin; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import retrofit.converter.ConversionException; +import retrofit.converter.Converter; +import retrofit.mime.TypedByteArray; +import retrofit.mime.TypedInput; +import retrofit.mime.TypedOutput; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Type; +import java.util.stream.Collectors; + +public class GremlinConverter implements Converter { + private static final String MIME_TYPE = "application/json; charset=UTF-8"; + + private final ObjectMapper objectMapper; + + public GremlinConverter() { + this(new ObjectMapper()); + } + + public GremlinConverter(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override public Object fromBody(TypedInput body, Type type) throws ConversionException { + try { + if (type.getTypeName().equals(String.class.getName())) { + return new BufferedReader(new InputStreamReader(body.in())) + .lines().collect(Collectors.joining("\n")); + } else { + JavaType javaType = objectMapper.getTypeFactory().constructType(type); + return objectMapper.readValue(body.in(), javaType); + } + } catch (final IOException ioe) { + throw new ConversionException(ioe); + } + } + + @Override public TypedOutput toBody(Object object) { + try { + String json = objectMapper.writeValueAsString(object); + return new TypedByteArray(MIME_TYPE, json.getBytes("UTF-8")); + } catch (JsonProcessingException | UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } +} diff --git a/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/GremlinService.kt b/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/GremlinService.kt new file mode 100644 index 0000000000..63277182a6 --- /dev/null +++ b/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/GremlinService.kt @@ -0,0 +1,46 @@ +package com.netflix.spinnaker.orca.gremlin + +import retrofit.http.*; + +interface GremlinService { + @POST("/attacks/new") + @Headers( + "Content-Type: application/json", + "X-Gremlin-Agent: spinnaker/0.1.0" + ) + fun create( + @Header("Authorization") authHeader: String, + @Body attackParameters: AttackParameters + ): String + + @GET("/executions") + @Headers( + "X-Gremlin-Agent: spinnaker/0.1.0" + ) + fun getStatus( + @Header("Authorization") authHeader: String, + @Query("taskId") attackGuid: String + ): List + + @DELETE("/attacks/{attackGuid}") + @Headers( + "X-Gremlin-Agent: spinnaker/0.1.0" + ) + fun haltAttack( + @Header("Authorization") authHeader: String, + @Path("attackGuid") attackGuid: String + ): Void +} + +data class AttackParameters( + val command: Map, + val target: Map +) + +data class AttackStatus( + val guid: String, + val stage: String, + val stageLifecycle: String, + val endTime: String?, + val output: String? +) diff --git a/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/pipeline/GremlinStage.java b/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/pipeline/GremlinStage.java new file mode 100644 index 0000000000..02fcbb15d9 --- /dev/null +++ b/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/pipeline/GremlinStage.java @@ -0,0 +1,72 @@ +package com.netflix.spinnaker.orca.gremlin.pipeline; + +import com.netflix.spinnaker.orca.CancellableStage; +import com.netflix.spinnaker.orca.gremlin.GremlinService; +import com.netflix.spinnaker.orca.gremlin.tasks.LaunchGremlinAttackTask; +import com.netflix.spinnaker.orca.gremlin.tasks.MonitorGremlinAttackTask; +import com.netflix.spinnaker.orca.pipeline.StageDefinitionBuilder; +import com.netflix.spinnaker.orca.pipeline.TaskNode; +import com.netflix.spinnaker.orca.pipeline.model.Stage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import javax.annotation.Nonnull; +import java.util.Map; +import java.util.Optional; + +@Component +public class GremlinStage implements StageDefinitionBuilder, CancellableStage { + public final static String APIKEY_KEY = "gremlinApiKey"; + public final static String COMMAND_TEMPLATE_ID_KEY = "gremlinCommandTemplateId"; + public final static String TARGET_TEMPLATE_ID_KEY = "gremlinTargetTemplateId"; + public final static String GUID_KEY = "gremlinAttackGuid"; + public final static String TERMINAL_KEY = "isGremlinTerminal"; + + @Autowired + private GremlinService gremlinService; + + @Override + public void taskGraph(@Nonnull Stage stage, @Nonnull TaskNode.Builder builder) { + builder + .withTask("launchGremlinAttack", LaunchGremlinAttackTask.class) + .withTask("monitorGremlinAttack", MonitorGremlinAttackTask.class); + } + + @Override + public Result cancel(Stage stage) { + final Map ctx = stage.getContext(); + final boolean isAttackCompleted = Optional.ofNullable(ctx.get(TERMINAL_KEY)) + .map(s -> { + try { + return Boolean.parseBoolean((String) s); + } catch (final Exception ex) { + return false; + } + }) + .orElse(false); + + if (!isAttackCompleted) { + gremlinService.haltAttack(getApiKey(ctx), getAttackGuid(ctx)); + return new CancellableStage.Result(stage, ctx); + } + return null; + } + + public static String getApiKey(final Map ctx) { + final String apiKey = (String) ctx.get(APIKEY_KEY); + if (apiKey == null || apiKey.isEmpty()) { + throw new RuntimeException("No API Key provided"); + } else { + return "Key " + apiKey; + } + } + + public static String getAttackGuid(final Map ctx) { + final String guid = (String) ctx.get(GUID_KEY); + if (guid == null || guid.isEmpty()) { + throw new RuntimeException("Could not find an active Gremlin attack GUID"); + } else { + return guid; + } + } +} diff --git a/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/tasks/LaunchGremlinAttackTask.java b/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/tasks/LaunchGremlinAttackTask.java new file mode 100644 index 0000000000..70ebefe443 --- /dev/null +++ b/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/tasks/LaunchGremlinAttackTask.java @@ -0,0 +1,58 @@ +package com.netflix.spinnaker.orca.gremlin.tasks; + +import com.netflix.spinnaker.orca.ExecutionStatus; +import com.netflix.spinnaker.orca.Task; +import com.netflix.spinnaker.orca.TaskResult; +import com.netflix.spinnaker.orca.gremlin.AttackParameters; +import com.netflix.spinnaker.orca.gremlin.GremlinService; +import com.netflix.spinnaker.orca.gremlin.pipeline.GremlinStage; +import com.netflix.spinnaker.orca.pipeline.model.Stage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import javax.annotation.Nonnull; +import java.util.HashMap; +import java.util.Map; + +import static com.netflix.spinnaker.orca.gremlin.pipeline.GremlinStage.COMMAND_TEMPLATE_ID_KEY; +import static com.netflix.spinnaker.orca.gremlin.pipeline.GremlinStage.GUID_KEY; +import static com.netflix.spinnaker.orca.gremlin.pipeline.GremlinStage.TARGET_TEMPLATE_ID_KEY; + +@Component +public class LaunchGremlinAttackTask implements Task { + private static final String GREMLIN_TEMPLATE_ID_KEY = "template_id"; + + @Autowired + private GremlinService gremlinService; + + @Nonnull + @Override + public TaskResult execute(@Nonnull Stage stage) { + final Map ctx = stage.getContext(); + + final String apiKey = GremlinStage.getApiKey(ctx); + + final String commandTemplateId = (String) ctx.get(COMMAND_TEMPLATE_ID_KEY); + if (commandTemplateId == null || commandTemplateId.isEmpty()) { + throw new RuntimeException("No command template provided"); + } + + final String targetTemplateId = (String) ctx.get(TARGET_TEMPLATE_ID_KEY); + if (targetTemplateId == null || targetTemplateId.isEmpty()) { + throw new RuntimeException("No target template provided"); + } + + final Map commandViaTemplate = new HashMap<>(); + commandViaTemplate.put(GREMLIN_TEMPLATE_ID_KEY, commandTemplateId); + + final Map targetViaTemplate = new HashMap<>(); + targetViaTemplate.put(GREMLIN_TEMPLATE_ID_KEY, targetTemplateId); + + final AttackParameters newAttack = new AttackParameters(commandViaTemplate, targetViaTemplate); + + final String createdGuid = gremlinService.create(apiKey, newAttack); + final Map responseMap = new HashMap<>(); + responseMap.put(GUID_KEY, createdGuid); + return new TaskResult(ExecutionStatus.SUCCEEDED, responseMap); + } +} diff --git a/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/tasks/MonitorGremlinAttackTask.java b/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/tasks/MonitorGremlinAttackTask.java new file mode 100644 index 0000000000..23a74dc711 --- /dev/null +++ b/orca-integrations-gremlin/src/main/java/com/netflix/spinnaker/orca/gremlin/tasks/MonitorGremlinAttackTask.java @@ -0,0 +1,75 @@ +package com.netflix.spinnaker.orca.gremlin.tasks; + +import com.netflix.spinnaker.orca.ExecutionStatus; +import com.netflix.spinnaker.orca.OverridableTimeoutRetryableTask; +import com.netflix.spinnaker.orca.Task; +import com.netflix.spinnaker.orca.TaskResult; +import com.netflix.spinnaker.orca.gremlin.AttackStatus; +import com.netflix.spinnaker.orca.gremlin.GremlinService; +import com.netflix.spinnaker.orca.gremlin.pipeline.GremlinStage; +import com.netflix.spinnaker.orca.pipeline.model.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import javax.annotation.Nonnull; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static com.netflix.spinnaker.orca.gremlin.pipeline.GremlinStage.TERMINAL_KEY; + +@Component +public class MonitorGremlinAttackTask implements OverridableTimeoutRetryableTask, Task { + @Autowired + private GremlinService gremlinService; + + private final Logger log = LoggerFactory.getLogger(getClass()); + + @Nonnull + @Override + public TaskResult execute(@Nonnull Stage stage) { + final Map ctx = stage.getContext(); + + final String apiKey = GremlinStage.getApiKey(ctx); + final String attackGuid = GremlinStage.getAttackGuid(ctx); + + final List statuses = gremlinService.getStatus(apiKey, attackGuid); + + boolean foundFailedAttack = false; + String failureType = ""; + String failureOutput = ""; + + for (final AttackStatus status : statuses) { + if (status.getEndTime() == null) { + return new TaskResult(ExecutionStatus.RUNNING, ctx); + } + if (isFailure(status.getStageLifecycle())) { + foundFailedAttack = true; + failureType = status.getStage(); + failureOutput = status.getOutput(); + } + } + ctx.put(TERMINAL_KEY, "true"); + if (foundFailedAttack) { + throw new RuntimeException("Gremlin run failed (" + failureType + ") with output : " + failureOutput); + } else { + return new TaskResult(ExecutionStatus.SUCCEEDED, ctx); + } + } + + @Override + public long getBackoffPeriod() { + return TimeUnit.SECONDS.toMillis(10); + } + + @Override + public long getTimeout() { + return TimeUnit.MINUTES.toMillis(15); + } + + private boolean isFailure(final String gremlinStageName) { + return gremlinStageName.equals("Error"); + } +} diff --git a/orca-web/config/orca.yml b/orca-web/config/orca.yml index a14bf61e56..10a410e981 100644 --- a/orca-web/config/orca.yml +++ b/orca-web/config/orca.yml @@ -48,6 +48,11 @@ tasks: logging: config: classpath:logback-defaults.xml +integrations: + gremlin: + enabled: false + baseUrl: https://api.gremlin.com/v1 + # This configuration lets you configure Webhook stages that will appear as native stages in the Deck UI. # Properties that are set here will not be displayed in the GUI #webhook: diff --git a/orca-web/orca-web.gradle b/orca-web/orca-web.gradle index 0de394e3d7..e5dd73574b 100644 --- a/orca-web/orca-web.gradle +++ b/orca-web/orca-web.gradle @@ -50,6 +50,7 @@ dependencies { compile project(":orca-migration") compile project(":orca-sql") compile project(":orca-sql-mysql") + compile project(":orca-integrations-gremlin") runtime spinnaker.dependency("kork") compile spinnaker.dependency('korkExceptions') diff --git a/orca-web/src/main/groovy/com/netflix/spinnaker/orca/Main.groovy b/orca-web/src/main/groovy/com/netflix/spinnaker/orca/Main.groovy index 305aa21bd9..da97694649 100644 --- a/orca-web/src/main/groovy/com/netflix/spinnaker/orca/Main.groovy +++ b/orca-web/src/main/groovy/com/netflix/spinnaker/orca/Main.groovy @@ -25,6 +25,7 @@ import com.netflix.spinnaker.orca.applications.config.ApplicationConfig import com.netflix.spinnaker.orca.bakery.config.BakeryConfiguration import com.netflix.spinnaker.orca.clouddriver.config.CloudDriverConfiguration import com.netflix.spinnaker.orca.clouddriver.config.ClouddriverJobConfiguration +import com.netflix.spinnaker.orca.config.GremlinConfiguration import com.netflix.spinnaker.orca.config.KeelConfiguration import com.netflix.spinnaker.orca.config.OrcaConfiguration import com.netflix.spinnaker.orca.config.PipelineTemplateConfiguration @@ -78,7 +79,8 @@ import org.springframework.scheduling.annotation.EnableAsync KayentaConfiguration, WebhookConfiguration, KeelConfiguration, - QosConfiguration + QosConfiguration, + GremlinConfiguration ]) @ComponentScan([ "com.netflix.spinnaker.config" diff --git a/settings.gradle b/settings.gradle index 5d5e2c7f30..6f5d04a521 100644 --- a/settings.gradle +++ b/settings.gradle @@ -46,7 +46,8 @@ include "orca-extensionpoint", "orca-qos", "orca-migration", "orca-sql", - "orca-sql-mysql" + "orca-sql-mysql", + "orca-integrations-gremlin" rootProject.name = "orca"