diff --git a/echo-model/src/main/java/com/netflix/spinnaker/echo/model/WebhookContent.java b/echo-model/src/main/java/com/netflix/spinnaker/echo/model/WebhookContent.java new file mode 100644 index 000000000..5612bd420 --- /dev/null +++ b/echo-model/src/main/java/com/netflix/spinnaker/echo/model/WebhookContent.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 Nordix Foundation. + * + * 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.echo.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.netflix.spinnaker.echo.model.trigger.TriggerEvent; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import java.util.List; +import java.util.Map; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@JsonIgnoreProperties(ignoreUnknown = true) +public class WebhookContent extends TriggerEvent { + private Content content; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Content { + private List artifacts; + private Map parameters; + } +} diff --git a/echo-model/src/main/java/com/netflix/spinnaker/echo/model/trigger/CDEvent.java b/echo-model/src/main/java/com/netflix/spinnaker/echo/model/trigger/CDEvent.java new file mode 100644 index 000000000..358e01fc3 --- /dev/null +++ b/echo-model/src/main/java/com/netflix/spinnaker/echo/model/trigger/CDEvent.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2023 Nordix Foundation. + * + * 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.echo.model.trigger; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.netflix.spinnaker.echo.model.WebhookContent; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@JsonIgnoreProperties(ignoreUnknown = true) +public class CDEvent extends WebhookContent { + public static final String TYPE = "CDEVENTS"; +} diff --git a/echo-model/src/main/java/com/netflix/spinnaker/echo/model/trigger/WebhookEvent.java b/echo-model/src/main/java/com/netflix/spinnaker/echo/model/trigger/WebhookEvent.java index 78eaf4a95..b0be357fe 100644 --- a/echo-model/src/main/java/com/netflix/spinnaker/echo/model/trigger/WebhookEvent.java +++ b/echo-model/src/main/java/com/netflix/spinnaker/echo/model/trigger/WebhookEvent.java @@ -17,23 +17,13 @@ package com.netflix.spinnaker.echo.model.trigger; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.netflix.spinnaker.kork.artifacts.model.Artifact; -import java.util.List; -import java.util.Map; +import com.netflix.spinnaker.echo.model.WebhookContent; import lombok.Data; import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) @JsonIgnoreProperties(ignoreUnknown = true) -public class WebhookEvent extends TriggerEvent { +public class WebhookEvent extends WebhookContent { public static final String TYPE = "WEBHOOK"; - private Content content; - - @Data - @JsonIgnoreProperties(ignoreUnknown = true) - public static class Content { - private List artifacts; - private Map parameters; - } } diff --git a/echo-pipelinetriggers/src/main/java/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/CDEventsWebhookHandler.java b/echo-pipelinetriggers/src/main/java/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/CDEventsWebhookHandler.java new file mode 100644 index 000000000..22923286c --- /dev/null +++ b/echo-pipelinetriggers/src/main/java/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/CDEventsWebhookHandler.java @@ -0,0 +1,134 @@ +/* + * 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.echo.pipelinetriggers.eventhandlers; + +import static com.netflix.spinnaker.echo.pipelinetriggers.artifacts.ArtifactMatcher.isJsonPathConstraintInPayload; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.netflix.spectator.api.Registry; +import com.netflix.spinnaker.echo.model.Pipeline; +import com.netflix.spinnaker.echo.model.Trigger; +import com.netflix.spinnaker.echo.model.trigger.CDEvent; +import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Implementation of TriggerEventHandler for events of type {@link CDEvent}, which occur when a + * CDEvents webhook is received. + */ +@Slf4j +@Component +public class CDEventsWebhookHandler extends BaseTriggerEventHandler { + private static final String TRIGGER_TYPE = "cdevents"; + private static final List supportedTriggerTypes = List.of(TRIGGER_TYPE); + + @Autowired + public CDEventsWebhookHandler( + Registry registry, + ObjectMapper objectMapper, + FiatPermissionEvaluator fiatPermissionEvaluator) { + super(registry, objectMapper, fiatPermissionEvaluator); + } + + @Override + public List supportedTriggerTypes() { + return supportedTriggerTypes; + } + + @Override + public boolean handleEventType(String eventType) { + return eventType != null && !eventType.equals("manual"); + } + + @Override + public Class getEventType() { + return CDEvent.class; + } + + @Override + public boolean isSuccessfulTriggerEvent(CDEvent cdEvent) { + return true; + } + + @Override + protected Function buildTrigger(CDEvent cdEvent) { + CDEvent.Content content = cdEvent.getContent(); + Map payload = cdEvent.getPayload(); + + return trigger -> + trigger + .atParameters(content.getParameters()) + .atPayload(payload) + .atEventId(cdEvent.getEventId()); + } + + @Override + protected boolean isValidTrigger(Trigger trigger) { + return trigger.isEnabled() && TRIGGER_TYPE.equals(trigger.getType()); + } + + @Override + protected Predicate matchTriggerFor(CDEvent cdEvent) { + final String type = cdEvent.getDetails().getType(); + final String source = cdEvent.getDetails().getSource(); + + return trigger -> + trigger.getType() != null + && trigger.getType().equalsIgnoreCase(type) + && trigger.getSource() != null + && trigger.getSource().equals(source) + && (trigger.getAttributeConstraints() == null + || trigger.getAttributeConstraints() != null + && isAttributeConstraintInReqHeader( + trigger.getAttributeConstraints(), + cdEvent.getDetails().getRequestHeaders())) + && (trigger.getPayloadConstraints() == null + || (trigger.getPayloadConstraints() != null + && isJsonPathConstraintInPayload( + trigger.getPayloadConstraints(), cdEvent.getPayload()))); + } + + private boolean isAttributeConstraintInReqHeader( + final Map attConstraints, final Map> reqHeaders) { + for (Object key : attConstraints.keySet()) { + if (!reqHeaders.containsKey(key) || reqHeaders.get(key).isEmpty()) { + return false; + } + + if (attConstraints.get(key) != null + && !(reqHeaders.get(key).contains(attConstraints.get(key).toString()))) { + return false; + } + } + return true; + } + + @Override + public Map getAdditionalTags(Pipeline pipeline) { + return Map.of("type", pipeline.getTrigger().getType()); + } + + @Override + protected List getArtifactsFromEvent(CDEvent cdEvent, Trigger trigger) { + return cdEvent.getContent().getArtifacts(); + } +} diff --git a/echo-pipelinetriggers/src/test/groovy/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/CDEventsWebhookHandlerSpec.groovy b/echo-pipelinetriggers/src/test/groovy/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/CDEventsWebhookHandlerSpec.groovy new file mode 100644 index 000000000..0bbb20044 --- /dev/null +++ b/echo-pipelinetriggers/src/test/groovy/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/CDEventsWebhookHandlerSpec.groovy @@ -0,0 +1,151 @@ +/* + * 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.echo.pipelinetriggers.eventhandlers + + +import com.netflix.spectator.api.NoopRegistry +import com.netflix.spinnaker.echo.api.events.Metadata +import com.netflix.spinnaker.echo.jackson.EchoObjectMapper +import com.netflix.spinnaker.echo.model.pubsub.MessageDescription +import com.netflix.spinnaker.echo.model.pubsub.PubsubSystem +import com.netflix.spinnaker.echo.model.trigger.PubsubEvent +import com.netflix.spinnaker.echo.test.RetrofitStubs +import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator +import com.netflix.spinnaker.kork.artifacts.model.Artifact +import com.netflix.spinnaker.kork.artifacts.model.ExpectedArtifact +import groovy.json.JsonOutput +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Subject +import spock.lang.Unroll + +class CDEventsWebhookHandlerSpec extends Specification implements RetrofitStubs { + def registry = new NoopRegistry() + def objectMapper = EchoObjectMapper.getInstance() + def handlerSupport = new EventHandlerSupport() + def fiatPermissionEvaluator = Mock(FiatPermissionEvaluator) + + @Shared + def goodExpectedArtifacts = [ + ExpectedArtifact.builder() + .matchArtifact( + Artifact.builder() + .name('myArtifact') + .type('artifactType') + .build()) + .id('goodId') + .build() + ] + + @Subject + def eventHandler = new CDEventsWebhookHandler(registry, objectMapper, fiatPermissionEvaluator) + + void setup() { + fiatPermissionEvaluator.hasPermission(_ as String, _ as String, "APPLICATION", "EXECUTE") >> true + } + + def 'triggers pipelines for successful builds for CDEvent'() { + given: + def pipeline = createPipelineWith(goodExpectedArtifacts, trigger) + def pipelines = handlerSupport.pipelineCache(pipeline) + + when: + def matchingPipelines = eventHandler.getMatchingPipelines(event, pipelines) + print("matchingPipelines=======>>>> " +matchingPipelines) + + then: + matchingPipelines.size() == 1 + matchingPipelines[0].application == pipeline.application + matchingPipelines[0].name == pipeline.name + + where: + event = createCDEvent('pipelineRunFinished', + [foo: 'bar', artifacts: [[name: 'myArtifact', type: 'artifactType']]]) + trigger = enabledCDEventsTrigger + .withSource('pipelineRunFinished') + .withPayloadConstraints([foo: 'bar']) + .withExpectedArtifactIds(['goodId']) + } + + def 'attaches cdevents trigger to the pipeline'() { + given: + def pipelines = handlerSupport.pipelineCache(pipeline) + + when: + def matchingPipelines = eventHandler.getMatchingPipelines(event, pipelines) + + then: + matchingPipelines.size() == 1 + matchingPipelines[0].trigger.type == enabledCDEventsTrigger.type + + where: + event = createCDEvent('pipelineRunStarted') + pipeline = createPipelineWith([], + enabledCDEventsTrigger.withSource('pipelineRunStarted'), + disabledCDEventsTrigger) + } + + def "triggers pipeline on matching attribute constraints"() { + given: + def pipeline = createPipelineWith(goodExpectedArtifacts, trigger) + def pipelines = handlerSupport.pipelineCache(pipeline) + def requestHeaders = new TreeMap<>() + def listHeaders = new ArrayList<>() + listHeaders.add("dev.cdevents.artifactPublished") + requestHeaders.put("ce-type", listHeaders) + def event = createCDEventRequestHeaders('artifactPublished', + [foo: 'bar', artifacts: [[name: 'myArtifact', type: 'artifactType']]], requestHeaders ) + + when: + def matchingPipelines = eventHandler.getMatchingPipelines(event, pipelines) + + then: + matchingPipelines.size() == 1 + matchingPipelines[0].application == pipeline.application + matchingPipelines[0].name == pipeline.name + + where: + trigger = enabledCDEventsTrigger + .withSource('artifactPublished') + .withAttributeConstraints(['ce-type':'dev.cdevents.artifactPublished']) + .withPayloadConstraints([foo: 'bar']) + .withExpectedArtifactIds(['goodId']) + + } + + @Unroll + def "does not trigger #description pipelines for CDEvent"() { + given: + def pipelines = handlerSupport.pipelineCache(pipeline) + + when: + def matchingPipelines = eventHandler.getMatchingPipelines(event, pipelines) + + then: + matchingPipelines.size() == 0 + + where: + trigger | description + disabledCDEventsTrigger | 'disabled cdevents trigger' + enabledCDEventsTrigger.withSource('wrongName') | 'different source name' + enabledCDEventsTrigger.withSource('artifactPackaged').withPayloadConstraints([foo: 'bar']) | + 'unsatisfied payload constraints' + enabledWebhookTrigger.withSource('artifactPackaged') .withExpectedArtifactIds(['goodId']) | + 'unmatched expected artifact' + + pipeline = createPipelineWith(goodExpectedArtifacts, trigger) + event = createCDEvent('artifactPackaged') + } +} diff --git a/echo-test/src/main/groovy/com/netflix/spinnaker/echo/test/RetrofitStubs.groovy b/echo-test/src/main/groovy/com/netflix/spinnaker/echo/test/RetrofitStubs.groovy index 4fadd634f..9250a8412 100644 --- a/echo-test/src/main/groovy/com/netflix/spinnaker/echo/test/RetrofitStubs.groovy +++ b/echo-test/src/main/groovy/com/netflix/spinnaker/echo/test/RetrofitStubs.groovy @@ -56,7 +56,8 @@ trait RetrofitStubs { .enabled(false).type('pubsub').pubsubSystem('google').subscriptionName('projects/project/subscriptions/subscription').expectedArtifactIds([]).build() final Trigger enabledHelmTrigger = Trigger.builder().enabled(true).type('helm').account('account').version('1.0.0').digest('digest').build() final Trigger disabledHelmTrigger = Trigger.builder().enabled(false).type('helm').account('account').version('1.0.0').digest('digest').build() - + final Trigger enabledCDEventsTrigger = Trigger.builder().enabled(true).type('cdevents').build() + final Trigger disabledCDEventsTrigger = Trigger.builder().enabled(false).type('cdevents').build() private nextId = new AtomicInteger(1) RetrofitError unavailable() { @@ -109,6 +110,27 @@ trait RetrofitStubs { return res } + CDEvent createCDEvent(final String source) { + return createCDEvent(source, [:]) + } + + CDEvent createCDEvent(final String source, final Map payload) { + def res = new CDEvent() + res.details = new Metadata([type: CDEvent.TYPE, source: source]) + res.payload = payload + res.content = EchoObjectMapper.getInstance().convertValue(payload, CDEvent.Content) + return res + } + + CDEvent createCDEventRequestHeaders(final String source, final Map payload, final TreeMap requestHeaders) { + def res = new CDEvent() + res.details = new Metadata([type: CDEvent.TYPE, source: source]) + res.payload = payload + res.content = EchoObjectMapper.getInstance().convertValue(payload, CDEvent.Content) + res.details.requestHeaders = requestHeaders + return res + } + PubsubEvent createPubsubEvent(PubsubSystem pubsubSystem, String subscriptionName, List artifacts, Map payload) { def res = new PubsubEvent() diff --git a/echo-webhooks/echo-webhooks.gradle b/echo-webhooks/echo-webhooks.gradle index 6ddf6000d..1e5585ef0 100644 --- a/echo-webhooks/echo-webhooks.gradle +++ b/echo-webhooks/echo-webhooks.gradle @@ -20,6 +20,9 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-web" implementation "io.spinnaker.kork:kork-artifacts" implementation "javax.validation:validation-api" + implementation "io.cloudevents:cloudevents-spring:2.5.0" + implementation "io.cloudevents:cloudevents-json-jackson:2.5.0" + implementation "io.cloudevents:cloudevents-http-basic:2.5.0" testImplementation "org.junit.jupiter:junit-jupiter-api" testImplementation "org.assertj:assertj-core" diff --git a/echo-webhooks/src/main/groovy/com/netflix/spinnaker/echo/config/CloudEventHandlerConfiguration.java b/echo-webhooks/src/main/groovy/com/netflix/spinnaker/echo/config/CloudEventHandlerConfiguration.java new file mode 100644 index 000000000..fe55b9553 --- /dev/null +++ b/echo-webhooks/src/main/groovy/com/netflix/spinnaker/echo/config/CloudEventHandlerConfiguration.java @@ -0,0 +1,31 @@ +/* + * + * 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.echo.config; + +import io.cloudevents.spring.mvc.CloudEventHttpMessageConverter; +import java.util.List; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CloudEventHandlerConfiguration implements WebMvcConfigurer { + + @Override + public void configureMessageConverters(List> converters) { + converters.add(0, new CloudEventHttpMessageConverter()); + } +} diff --git a/echo-webhooks/src/main/groovy/com/netflix/spinnaker/echo/controllers/WebhooksController.groovy b/echo-webhooks/src/main/groovy/com/netflix/spinnaker/echo/controllers/WebhooksController.groovy index b47b33794..b7daa4f9c 100644 --- a/echo-webhooks/src/main/groovy/com/netflix/spinnaker/echo/controllers/WebhooksController.groovy +++ b/echo-webhooks/src/main/groovy/com/netflix/spinnaker/echo/controllers/WebhooksController.groovy @@ -26,9 +26,12 @@ import com.netflix.spinnaker.echo.api.events.Metadata import groovy.util.logging.Slf4j import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders +import org.springframework.http.ResponseEntity import org.springframework.util.CollectionUtils import org.springframework.util.MultiValueMap import org.springframework.web.bind.annotation.* +import io.cloudevents.CloudEvent +import java.nio.charset.StandardCharsets @RestController @Slf4j @@ -132,6 +135,38 @@ class WebhooksController { WebhookResponse.newInstance(eventProcessed: true, eventId: event.eventId) } + @RequestMapping(value = '/webhooks/cdevents/{source}', method = RequestMethod.POST) + ResponseEntity forwardEvent(@PathVariable String source, + @RequestBody CloudEvent cdevent, + @RequestHeader HttpHeaders headers) { + log.info("CDEvents Webhook received with source ${source} and with event type ${cdevent.getType()}") + + String ceDataJsonString = new String(cdevent.getData().toBytes(), StandardCharsets.UTF_8); + + Event event = new Event() + boolean sendEvent = true + event.details = new Metadata() + event.details.source = source + event.details.type = "cdevents" + event.details.requestHeaders = headers + event.rawContent = ceDataJsonString + Map postedEvent + try { + postedEvent = mapper.readValue(ceDataJsonString, Map) ?: [:] + } catch (Exception e) { + log.error("Failed to parse payload ceDataJsonString: {}", ceDataJsonString, e); + throw e + } + event.content = postedEvent + event.payload = new HashMap(postedEvent) + + propagator.processEvent(event) + + WebhookResponse.newInstance(eventProcessed: true, eventId: event.eventId) + ResponseEntity.ok().build(); + + } + private static class WebhookResponse { boolean eventProcessed; String eventId; diff --git a/echo-webhooks/src/test/groovy/com/netflix/spinnaker/echo/controllers/WebhooksControllerSpec.groovy b/echo-webhooks/src/test/groovy/com/netflix/spinnaker/echo/controllers/WebhooksControllerSpec.groovy index f71b05ca0..c045dd17f 100644 --- a/echo-webhooks/src/test/groovy/com/netflix/spinnaker/echo/controllers/WebhooksControllerSpec.groovy +++ b/echo-webhooks/src/test/groovy/com/netflix/spinnaker/echo/controllers/WebhooksControllerSpec.groovy @@ -28,6 +28,11 @@ import com.netflix.spinnaker.echo.scm.StashWebhookEventHandler import com.netflix.spinnaker.echo.scm.bitbucket.server.BitbucketServerEventHandler import org.springframework.http.HttpHeaders import spock.lang.Specification +import io.cloudevents.CloudEvent +import io.cloudevents.core.builder.CloudEventBuilder; + + +import java.nio.charset.StandardCharsets class WebhooksControllerSpec extends Specification { @@ -815,4 +820,40 @@ class WebhooksControllerSpec extends Specification { event.content.branch == "master" event.content.action == "repo:push" } + + void "handles CDEvents Webhook Event"() { + def event + + given: + WebhooksController controller = new WebhooksController(mapper: EchoObjectMapper.getInstance(), scmWebhookHandler: scmWebhookHandler) + controller.propagator = Mock(EventPropagator) + + HttpHeaders headers = new HttpHeaders(); + headers.add("Ce-Id", "1234") + headers.add("Ce-Specversion", "1.0") + headers.add("Ce-Type", "dev.cdevents.artifact.packaged") + + String eventData = "{\"id\": \"1234\", \"subject\": \"event\"}"; + CloudEvent cdevent = CloudEventBuilder.v1() // + .withId("12345") // + .withType("dev.cdevents.artifact.packaged") // + .withSource(URI.create("https://cdevents.dev")) // + .withData(eventData.getBytes(StandardCharsets.UTF_8)) // + .build(); + + when: + def response = controller.forwardEvent("artifactPackaged",cdevent, headers) + + then: + 1 * controller.propagator.processEvent(_) >> { + event = it[0] + } + + event.details.type == "cdevents" + event.details.source == "artifactPackaged" + event.rawContent == eventData + event.details.requestHeaders.get("Ce-Type")[0] == "dev.cdevents.artifact.packaged" + event.details.requestHeaders.get("Ce-Id")[0] == "1234" + + } }