Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cdevents-webhooks) : Consume CDEvents webhook API implementation #1290

Merged
merged 20 commits into from
Jun 27, 2023
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1a85892
feat(cdevents-webhooks) : CDEvents webhook API implementation
rjalander Mar 29, 2023
9b5c98e
Merge branch 'master' into webhooks_cdevents
rjalander Apr 12, 2023
857d491
feat(cdevents-webhooks) : tests for CDEvents webhook API
rjalander Apr 12, 2023
052499e
Merge branch 'webhooks_cdevents' of github.com:Nordix/echo into webho…
rjalander Apr 12, 2023
11c0faa
Merge branch 'master' into webhooks_cdevents
rjalander Apr 19, 2023
4c05845
Merge branch 'master' into webhooks_cdevents
rjalander May 17, 2023
e955a4b
Merge branch 'master' into webhooks_cdevents
rjalander May 22, 2023
da4d45c
fix(cdevents-webhooks) : Response type to match with events-broker ex…
rjalander May 22, 2023
8e3391c
Merge branch 'webhooks_cdevents' of github.com:Nordix/echo into webho…
rjalander May 22, 2023
478dae5
Merge branch 'master' into webhooks_cdevents
rjalander Jun 16, 2023
b0568b3
addressing review comments
rjalander Jun 22, 2023
ce67228
Merge branch 'webhooks_cdevents' of github.com:Nordix/echo into webho…
rjalander Jun 22, 2023
55bd979
Update echo-pipelinetriggers/src/main/java/com/netflix/spinnaker/echo…
rjalander Jun 22, 2023
0558992
Update echo-pipelinetriggers/src/main/java/com/netflix/spinnaker/echo…
rjalander Jun 22, 2023
82684d2
Updating cloudevents version to 2.5.0
rjalander Jun 22, 2023
97102eb
Merge branch 'master' into webhooks_cdevents
rjalander Jun 22, 2023
c9159af
fixing format violations
rjalander Jun 23, 2023
637fbf6
Merge branch 'webhooks_cdevents' of github.com:Nordix/echo into webho…
rjalander Jun 23, 2023
1a4304a
fix format violations
rjalander Jun 26, 2023
0fb7090
Merge branch 'master' into webhooks_cdevents
rjalander Jun 27, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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 lombok.Data;
import lombok.EqualsAndHashCode;

import java.util.List;
import java.util.Map;

@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<Artifact> artifacts;
private Map<?, ?> parameters;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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 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 CDEvent extends WebhookContent {
public static final String TYPE = "CDEVENTS";
}
mattgogerly marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.netflix.spinnaker.echo.model.trigger;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.netflix.spinnaker.echo.model.WebhookContent;
import com.netflix.spinnaker.kork.artifacts.model.Artifact;
import java.util.List;
import java.util.Map;
Expand All @@ -26,14 +27,6 @@
@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<Artifact> artifacts;
private Map<?, ?> parameters;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* 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.Collections;
import java.util.HashMap;
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<CDEvent> {
private static final String TRIGGER_TYPE = "cdevents";
private static final List<String> supportedTriggerTypes = List.of(TRIGGER_TYPE);

@Autowired
public CDEventsWebhookHandler(
Registry registry,
ObjectMapper objectMapper,
FiatPermissionEvaluator fiatPermissionEvaluator) {
super(registry, objectMapper, fiatPermissionEvaluator);
}

@Override
public List<String> supportedTriggerTypes() {
return supportedTriggerTypes;
}

@Override
public boolean handleEventType(String eventType) {
return eventType != null && !eventType.equals("manual");
}

@Override
public Class<CDEvent> getEventType() {
return CDEvent.class;
}

@Override
public boolean isSuccessfulTriggerEvent(CDEvent cdEvent) {
return true;
}

@Override
protected Function<Trigger, Trigger> 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<Trigger> 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<String, List<String>> 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<String, String> getAdditionalTags(Pipeline pipeline) {
return Map.of("type", pipeline.getTrigger().getType());
}

@Override
protected List<Artifact> getArtifactsFromEvent(CDEvent cdEvent, Trigger trigger) {
return cdEvent.getContent().getArtifacts();
}
}
Original file line number Diff line number Diff line change
@@ -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')
}
}