diff --git a/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/IgorService.java b/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/IgorService.java index c5a76873bb..913e062617 100644 --- a/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/IgorService.java +++ b/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/IgorService.java @@ -16,6 +16,7 @@ package com.netflix.spinnaker.orca.igor; import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import com.netflix.spinnaker.orca.igor.model.AwsCodeBuildExecution; import com.netflix.spinnaker.orca.igor.model.GoogleCloudBuild; import com.netflix.spinnaker.orca.igor.model.GoogleCloudBuildRepoSource; import java.util.List; @@ -87,6 +88,14 @@ GoogleCloudBuild runGoogleCloudBuildTrigger( @Path("triggerId") String triggerId, @Body GoogleCloudBuildRepoSource repoSource); + @POST("/codebuild/builds/start/{account}") + AwsCodeBuildExecution startAwsCodeBuild( + @Path("account") String account, @Body Map requestInput); + + @GET("/codebuild/builds/{account}/{buildId}") + AwsCodeBuildExecution getAwsCodeBuildExecution( + @Path("account") String account, @Path("buildId") String buildId); + @GET("/delivery-config/manifest") Map getDeliveryConfigManifest( @Query("scmType") String repoType, diff --git a/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/model/AwsCodeBuildExecution.java b/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/model/AwsCodeBuildExecution.java new file mode 100644 index 0000000000..782c5546ac --- /dev/null +++ b/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/model/AwsCodeBuildExecution.java @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Amazon.com, Inc. + * + * 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 + * + * http://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. + */ + +package com.netflix.spinnaker.orca.igor.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.netflix.spinnaker.orca.ExecutionStatus; +import lombok.Data; +import lombok.Getter; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class AwsCodeBuildExecution { + private final String arn; + private final Status status; + private final AwsCodeBuildLogs logs; + private final String buildUrl; + + @JsonCreator + public AwsCodeBuildExecution( + @JsonProperty("arn") String arn, + @JsonProperty("buildStatus") String buildStatus, + @JsonProperty("logs") AwsCodeBuildLogs logs) { + this.arn = arn; + this.status = Status.fromString(buildStatus); + this.buildUrl = getBuildUrl(arn); + this.logs = logs; + } + + private String getBuildUrl(String arn) { + final String[] arnSplit = arn.split("/"); + final String region = arnSplit[0].split(":")[3]; + final String buildId = arnSplit[1]; + final String project = buildId.split(":")[0]; + return String.format( + "https://%s.console.aws.amazon.com/codesuite/codebuild/projects/%s/build/%s/log?region=%s", + region, project, buildId, region); + } + + @Data + private static class AwsCodeBuildLogs { + private String deepLink; + private String s3DeepLink; + } + + public enum Status { + IN_PROGRESS(ExecutionStatus.RUNNING), + SUCCEEDED(ExecutionStatus.SUCCEEDED), + FAILED(ExecutionStatus.TERMINAL), + FAULT(ExecutionStatus.TERMINAL), + TIMED_OUT(ExecutionStatus.TERMINAL), + STOPPED(ExecutionStatus.CANCELED), + UNKNOWN(ExecutionStatus.TERMINAL); + + @Getter private ExecutionStatus executionStatus; + + Status(ExecutionStatus executionStatus) { + this.executionStatus = executionStatus; + } + + public static Status fromString(String status) { + try { + return valueOf(status); + } catch (NullPointerException | IllegalArgumentException e) { + return UNKNOWN; + } + } + } +} diff --git a/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/model/AwsCodeBuildStageDefinition.java b/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/model/AwsCodeBuildStageDefinition.java new file mode 100644 index 0000000000..d66ea7560f --- /dev/null +++ b/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/model/AwsCodeBuildStageDefinition.java @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Amazon.com, Inc. + * + * 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 + * + * http://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. + */ + +package com.netflix.spinnaker.orca.igor.model; + +import lombok.Data; + +@Data +public class AwsCodeBuildStageDefinition implements RetryableStageDefinition { + private String account; + private String projectName; + private AwsCodeBuildExecution buildInfo; + private int consecutiveErrors; +} diff --git a/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/pipeline/AwsCodeBuildStage.java b/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/pipeline/AwsCodeBuildStage.java new file mode 100644 index 0000000000..254d1b27b7 --- /dev/null +++ b/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/pipeline/AwsCodeBuildStage.java @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Amazon.com, Inc. + * + * 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 + * + * http://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. + */ +package com.netflix.spinnaker.orca.igor.pipeline; + +import com.netflix.spinnaker.orca.igor.tasks.MonitorAwsCodeBuildTask; +import com.netflix.spinnaker.orca.igor.tasks.StartAwsCodeBuildTask; +import com.netflix.spinnaker.orca.pipeline.StageDefinitionBuilder; +import com.netflix.spinnaker.orca.pipeline.TaskNode; +import com.netflix.spinnaker.orca.pipeline.model.Stage; +import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AwsCodeBuildStage implements StageDefinitionBuilder { + @Override + public void taskGraph(@Nonnull Stage stage, @Nonnull TaskNode.Builder builder) { + builder + .withTask("startAwsCodeBuildTask", StartAwsCodeBuildTask.class) + .withTask("monitorAwsCodeBuildTask", MonitorAwsCodeBuildTask.class); + } +} diff --git a/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/tasks/MonitorAwsCodeBuildTask.java b/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/tasks/MonitorAwsCodeBuildTask.java new file mode 100644 index 0000000000..1b177e77d8 --- /dev/null +++ b/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/tasks/MonitorAwsCodeBuildTask.java @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Amazon.com, Inc. + * + * 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 + * + * http://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. + */ + +package com.netflix.spinnaker.orca.igor.tasks; + +import com.netflix.spinnaker.orca.OverridableTimeoutRetryableTask; +import com.netflix.spinnaker.orca.TaskResult; +import com.netflix.spinnaker.orca.igor.IgorService; +import com.netflix.spinnaker.orca.igor.model.AwsCodeBuildExecution; +import com.netflix.spinnaker.orca.igor.model.AwsCodeBuildStageDefinition; +import com.netflix.spinnaker.orca.pipeline.model.Stage; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nonnull; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MonitorAwsCodeBuildTask extends RetryableIgorTask + implements OverridableTimeoutRetryableTask { + @Getter protected long backoffPeriod = TimeUnit.SECONDS.toMillis(10); + @Getter protected long timeout = TimeUnit.HOURS.toMillis(8); // maximum build timeout + + private final IgorService igorService; + + @Override + @Nonnull + public TaskResult tryExecute(@Nonnull AwsCodeBuildStageDefinition stageDefinition) { + AwsCodeBuildExecution execution = + igorService.getAwsCodeBuildExecution( + stageDefinition.getAccount(), getBuildId(stageDefinition.getBuildInfo().getArn())); + Map context = new HashMap<>(); + context.put("buildInfo", execution); + return TaskResult.builder(execution.getStatus().getExecutionStatus()).context(context).build(); + } + + @Override + @Nonnull + protected AwsCodeBuildStageDefinition mapStage(@Nonnull Stage stage) { + return stage.mapTo(AwsCodeBuildStageDefinition.class); + } + + private String getBuildId(String arn) { + return arn.split("/")[1]; + } +} diff --git a/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/tasks/StartAwsCodeBuildTask.java b/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/tasks/StartAwsCodeBuildTask.java new file mode 100644 index 0000000000..e4bf5a5bca --- /dev/null +++ b/orca-igor/src/main/groovy/com/netflix/spinnaker/orca/igor/tasks/StartAwsCodeBuildTask.java @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Amazon.com, Inc. + * + * 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 + * + * http://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. + */ + +package com.netflix.spinnaker.orca.igor.tasks; + +import com.netflix.spinnaker.orca.ExecutionStatus; +import com.netflix.spinnaker.orca.Task; +import com.netflix.spinnaker.orca.TaskResult; +import com.netflix.spinnaker.orca.igor.IgorService; +import com.netflix.spinnaker.orca.igor.model.AwsCodeBuildExecution; +import com.netflix.spinnaker.orca.igor.model.AwsCodeBuildStageDefinition; +import com.netflix.spinnaker.orca.pipeline.model.Stage; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class StartAwsCodeBuildTask implements Task { + private static final String PROJECT_NAME = "projectName"; + + private final IgorService igorService; + + @Override + @Nonnull + public TaskResult execute(@Nonnull Stage stage) { + AwsCodeBuildStageDefinition stageDefinition = stage.mapTo(AwsCodeBuildStageDefinition.class); + + Map requestInput = new HashMap<>(); + requestInput.put(PROJECT_NAME, stageDefinition.getProjectName()); + + AwsCodeBuildExecution execution = + igorService.startAwsCodeBuild(stageDefinition.getAccount(), requestInput); + + Map context = stage.getContext(); + context.put("buildInfo", execution); + return TaskResult.builder(ExecutionStatus.SUCCEEDED).context(context).build(); + } +} diff --git a/orca-igor/src/test/groovy/com/netflix/spinnaker/orca/igor/pipeline/AwsCodeBuildStageSpec.groovy b/orca-igor/src/test/groovy/com/netflix/spinnaker/orca/igor/pipeline/AwsCodeBuildStageSpec.groovy new file mode 100644 index 0000000000..9b597b1bb0 --- /dev/null +++ b/orca-igor/src/test/groovy/com/netflix/spinnaker/orca/igor/pipeline/AwsCodeBuildStageSpec.groovy @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Amazon.com, Inc. + * + * 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 + * + * http://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. + */ + +package com.netflix.spinnaker.orca.igor.pipeline + +import com.netflix.spinnaker.orca.igor.tasks.StartAwsCodeBuildTask +import spock.lang.Specification + +import static com.netflix.spinnaker.orca.test.model.ExecutionBuilder.stage + +class AwsCodeBuildStageSpec extends Specification { + def ACCOUNT = "codebuild-account" + def PROJECT_NAME = "test" + + def "should start a build"() { + given: + def awsCodeBuildStage = new AwsCodeBuildStage() + + def stage = stage { + type = "awsCodeBuild" + context = [ + account: ACCOUNT, + projectName: PROJECT_NAME, + ] + } + + when: + def tasks = awsCodeBuildStage.buildTaskGraph(stage) + + then: + tasks.findAll { + it.implementingClass == StartAwsCodeBuildTask + }.size() == 1 + } +} diff --git a/orca-igor/src/test/groovy/com/netflix/spinnaker/orca/igor/tasks/MonitorAwsCodeBuildTaskSpec.groovy b/orca-igor/src/test/groovy/com/netflix/spinnaker/orca/igor/tasks/MonitorAwsCodeBuildTaskSpec.groovy new file mode 100644 index 0000000000..14c313cad8 --- /dev/null +++ b/orca-igor/src/test/groovy/com/netflix/spinnaker/orca/igor/tasks/MonitorAwsCodeBuildTaskSpec.groovy @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Amazon.com, Inc. + * + * 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 + * + * http://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. + */ + +package com.netflix.spinnaker.orca.igor.tasks + +import com.netflix.spinnaker.orca.ExecutionStatus +import com.netflix.spinnaker.orca.TaskResult +import com.netflix.spinnaker.orca.igor.IgorService +import com.netflix.spinnaker.orca.igor.model.AwsCodeBuildExecution +import com.netflix.spinnaker.orca.pipeline.model.Execution +import com.netflix.spinnaker.orca.pipeline.model.Stage +import retrofit.RetrofitError +import spock.lang.Specification +import spock.lang.Subject +import spock.lang.Unroll + +class MonitorAwsCodeBuildTaskSpec extends Specification { + String ACCOUNT = "my-account" + String BUILD_ID = "test:c7715bbf-5c12-44d6-87ef-8149473e02f7" + String ARN = "arn:aws:codebuild:us-west-2:123456789012:build/$BUILD_ID" + + Execution execution = Mock(Execution) + IgorService igorService = Mock(IgorService) + + @Subject + MonitorAwsCodeBuildTask task = new MonitorAwsCodeBuildTask(igorService) + + @Unroll + def "task returns #executionStatus when build returns #buildStatus"() { + given: + def igorResponse = new AwsCodeBuildExecution(ARN, buildStatus, null) + def stage = new Stage(execution, "awsCodeBuild", [ + account: ACCOUNT, + buildInfo: [ + arn: ARN + ], + ]) + + when: + TaskResult result = task.execute(stage) + + then: + 1 * igorService.getAwsCodeBuildExecution(ACCOUNT, BUILD_ID) >> igorResponse + 0 * igorService._ + result.getStatus() == executionStatus + result.getContext().buildInfo == igorResponse + + where: + buildStatus | executionStatus + "IN_PROGRESS" | ExecutionStatus.RUNNING + "SUCCEEDED" | ExecutionStatus.SUCCEEDED + "FAILED" | ExecutionStatus.TERMINAL + "FAULT" | ExecutionStatus.TERMINAL + "TIMED_OUT" | ExecutionStatus.TERMINAL + "STOPPED" | ExecutionStatus.CANCELED + "UNKNOWN" | ExecutionStatus.TERMINAL + } + + def "task returns RUNNING when communcation with igor fails"() { + given: + def stage = new Stage(execution, "awsCodeBuild", [ + account: ACCOUNT, + buildInfo: [ + arn: ARN + ], + ]) + + when: + TaskResult result = task.execute(stage) + + then: + 1 * igorService.getAwsCodeBuildExecution(ACCOUNT, BUILD_ID) >> { throw stubRetrofitError() } + 0 * igorService._ + result.getStatus() == ExecutionStatus.RUNNING + } + + def stubRetrofitError() { + return Stub(RetrofitError) { + getKind() >> RetrofitError.Kind.NETWORK + } + } +} diff --git a/orca-igor/src/test/groovy/com/netflix/spinnaker/orca/igor/tasks/StartAwsCodeBuildTaskSpec.groovy b/orca-igor/src/test/groovy/com/netflix/spinnaker/orca/igor/tasks/StartAwsCodeBuildTaskSpec.groovy new file mode 100644 index 0000000000..0c09ca197d --- /dev/null +++ b/orca-igor/src/test/groovy/com/netflix/spinnaker/orca/igor/tasks/StartAwsCodeBuildTaskSpec.groovy @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Amazon.com, Inc. + * + * 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 + * + * http://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. + */ + +package com.netflix.spinnaker.orca.igor.tasks + +import com.netflix.spinnaker.orca.ExecutionStatus +import com.netflix.spinnaker.orca.TaskResult +import com.netflix.spinnaker.orca.igor.IgorService +import com.netflix.spinnaker.orca.igor.model.AwsCodeBuildExecution +import com.netflix.spinnaker.orca.pipeline.model.Execution +import com.netflix.spinnaker.orca.pipeline.model.Stage +import spock.lang.Specification +import spock.lang.Subject + +class StartAwsCodeBuildTaskSpec extends Specification { + def ACCOUNT = "codebuild-account" + def PROJECT_NAME = "test" + def ARN = "arn:aws:codebuild:us-west-2:123456789012:build/test:c7715bbf-5c12-44d6-87ef-8149473e02f7" + + Execution execution = Mock(Execution) + IgorService igorService = Mock(IgorService) + + @Subject + StartAwsCodeBuildTask task = new StartAwsCodeBuildTask(igorService) + + def "should start a build"() { + given: + def igorResponse = new AwsCodeBuildExecution(ARN, null, null) + def stage = new Stage(execution, "awsCodeBuild", [account: ACCOUNT, projectName: PROJECT_NAME]) + + when: + TaskResult result = task.execute(stage) + + then: + 1 * igorService.startAwsCodeBuild(ACCOUNT, _) >> igorResponse + result.status == ExecutionStatus.SUCCEEDED + result.context.buildInfo.arn == igorResponse.arn + } +}