Skip to content

Commit

Permalink
feat(helm): Add Helm pipeline triggering (#988)
Browse files Browse the repository at this point in the history
- Helm trigger event handler
- Semver checks against chart information

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
  • Loading branch information
jcavanagh and mergify[bot] committed Sep 15, 2020
1 parent cadb9b7 commit 5f81ffa
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 1 deletion.
Expand Up @@ -83,7 +83,8 @@ public enum Type {
PUBSUB("pubsub"),
DRYRUN("dryrun"),
PIPELINE("pipeline"),
PLUGIN("plugin");
PLUGIN("plugin"),
HELM("helm");

private final String type;

Expand Down
@@ -0,0 +1,27 @@
package com.netflix.spinnaker.echo.model.trigger;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

@Data
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public class HelmEvent extends TriggerEvent {
public static final String TYPE = "HELM";

Content content;

@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Content {
private String account;
private String chart;
private String version;
private String digest;
}
}
2 changes: 2 additions & 0 deletions echo-pipelinetriggers/echo-pipelinetriggers.gradle
Expand Up @@ -46,6 +46,8 @@ dependencies {
implementation "org.apache.commons:commons-lang3"
implementation "commons-codec:commons-codec"

implementation 'com.vdurmont:semver4j:3.1.0'

testImplementation project(':echo-test')
testImplementation "org.springframework.boot:spring-boot-starter-test"
testImplementation "org.junit.jupiter:junit-jupiter-api"
Expand Down
@@ -0,0 +1,119 @@
package com.netflix.spinnaker.echo.pipelinetriggers.eventhandlers;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Strings;
import com.netflix.spectator.api.Registry;
import com.netflix.spinnaker.echo.model.Trigger;
import com.netflix.spinnaker.echo.model.trigger.HelmEvent;
import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator;
import com.netflix.spinnaker.kork.artifacts.model.Artifact;
import com.vdurmont.semver4j.Semver;
import com.vdurmont.semver4j.Semver.SemverType;
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 org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class HelmEventHandler extends BaseTriggerEventHandler<HelmEvent> {
private static final String TRIGGER_TYPE = Trigger.Type.HELM.toString();

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

@Override
public List<String> supportedTriggerTypes() {
return Collections.singletonList(TRIGGER_TYPE);
}

@Override
public boolean handleEventType(String eventType) {
return eventType.equalsIgnoreCase(HelmEvent.TYPE);
}

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

@Override
public boolean isSuccessfulTriggerEvent(HelmEvent helmEvent) {
HelmEvent.Content content = helmEvent.getContent();
return !Strings.isNullOrEmpty(content.getChart())
&& !Strings.isNullOrEmpty(content.getVersion())
&& !Strings.isNullOrEmpty(content.getDigest());
}

@Override
protected List<Artifact> getArtifactsFromEvent(HelmEvent helmEvent, Trigger trigger) {
HelmEvent.Content content = helmEvent.getContent();
Map<String, Object> meta = new HashMap<>();
meta.put("digest", content.getDigest());

return Collections.singletonList(
Artifact.builder()
.type("helm/chart")
.name(content.getChart())
.version(content.getVersion())
.reference(content.getAccount())
.metadata(meta)
.build());
}

@Override
protected Function<Trigger, Trigger> buildTrigger(HelmEvent helmEvent) {
return trigger ->
trigger
.withArtifactName(helmEvent.getContent().getChart())
.withVersion(helmEvent.getContent().getVersion())
.withDigest(helmEvent.getContent().getDigest())
.withEventId(helmEvent.getEventId());
}

@Override
protected boolean isValidTrigger(Trigger trigger) {
return trigger.isEnabled()
&& TRIGGER_TYPE.equals(trigger.getType())
&& trigger.getAccount() != null;
}

@Override
protected Predicate<Trigger> matchTriggerFor(HelmEvent helmEvent) {
return trigger -> isMatchingTrigger(helmEvent, trigger);
}

private boolean satisfies(String eventVersion, String triggerSemVer) {
Boolean satisfiesSemVer;
try {
satisfiesSemVer = new Semver(eventVersion, SemverType.NPM).satisfies(triggerSemVer);
} catch (Exception e) {
satisfiesSemVer = false;
}

return satisfiesSemVer;
}

private boolean isMatchingTrigger(HelmEvent helmEvent, Trigger trigger) {
HelmEvent.Content content = helmEvent.getContent();
String helmVersion = content.getVersion();

String triggerSemVer = null;
if (StringUtils.isNotBlank(trigger.getVersion())) {
triggerSemVer = trigger.getVersion().trim();
}

return TRIGGER_TYPE.equals(trigger.getType())
&& (trigger.getAccount() != null && trigger.getAccount().equals(content.getAccount()))
&& (triggerSemVer == null || satisfies(helmVersion, triggerSemVer));
}
}
@@ -0,0 +1,108 @@
/*
* Copyright 2020 Apple, 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.echo.pipelinetriggers.eventhandlers

import com.netflix.spectator.api.NoopRegistry
import com.netflix.spinnaker.echo.jackson.EchoObjectMapper
import com.netflix.spinnaker.echo.model.Pipeline
import com.netflix.spinnaker.echo.test.RetrofitStubs
import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator
import spock.lang.Specification
import spock.lang.Subject
import spock.lang.Unroll

class HelmEventHandlerSpec extends Specification implements RetrofitStubs {
def registry = new NoopRegistry()
def objectMapper = EchoObjectMapper.getInstance()
def handlerSupport = new EventHandlerSupport()
def fiatPermissionEvaluator = Mock(FiatPermissionEvaluator)

@Subject
def eventHandler = new HelmEventHandler(registry, objectMapper, fiatPermissionEvaluator)

void setup() {
fiatPermissionEvaluator.hasPermission(_ as String, _ as String, "APPLICATION", "EXECUTE") >> true
}

@Unroll
def "honors pipeline trigger semver"() {
given:
def pipeline = createPipelineWith(trigger)
def pipelines = handlerSupport.pipelineCache(pipeline)

when:
def matchingPipelines = eventHandler.getMatchingPipelines(event, pipelines)

then:
matchingPipelines.size() == (matches ? 1 : 0)

where:
event | trigger | matches
createHelmEvent("1.0.0") | enabledHelmTrigger.withVersion(null) | true
createHelmEvent("1.0.0") | enabledHelmTrigger.withVersion("") | true
createHelmEvent("1.0.1") | enabledHelmTrigger.withVersion("~1.0.0") | true
createHelmEvent("1.1.0") | enabledHelmTrigger.withVersion("~1.0.0") | false
createHelmEvent("1.0.1") | enabledHelmTrigger.withVersion("^1.0.0") | true
createHelmEvent("1.1.0") | enabledHelmTrigger.withVersion("^1.0.0") | true
createHelmEvent("1.0.0") | enabledHelmTrigger.withVersion("1.0.0") | true
createHelmEvent("1.0.1") | enabledHelmTrigger.withVersion("1.0.0") | false
}

def "an event can trigger multiple pipelines"() {
given:
def cache = handlerSupport.pipelineCache(pipelines)

when:
def matchingPipelines = eventHandler.getMatchingPipelines(event, cache)

then:
matchingPipelines.size() == pipelines.size()

where:
event = createHelmEvent("1.0.0")
pipelines = (1..2).collect {
Pipeline.builder()
.application("application")
.name("pipeline$it")
.id("id")
.triggers([enabledHelmTrigger])
.build()
}
}

@Unroll
def "does not trigger #description pipelines"() {
given:
def pipelines = handlerSupport.pipelineCache(pipeline)

when:
def matchingPipelines = eventHandler.getMatchingPipelines(event, pipelines)

then:
matchingPipelines.size() == 0

where:
trigger | description
disabledHelmTrigger | "disabled Helm trigger"
nonJenkinsTrigger | "non-Helm"
enabledHelmTrigger.withAccount("FAKE") | "wrong account"
enabledHelmTrigger.withAccount(null) | "no account"

pipeline = createPipelineWith(trigger)
event = createHelmEvent()
}
}
Expand Up @@ -54,6 +54,8 @@ trait RetrofitStubs {
.enabled(true).type('pubsub').pubsubSystem('google').subscriptionName('projects/project/subscriptions/subscription').expectedArtifactIds([]).build()
final Trigger disabledGooglePubsubTrigger = Trigger.builder()
.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()

private nextId = new AtomicInteger(1)

Expand Down Expand Up @@ -136,4 +138,11 @@ trait RetrofitStubs {
.expectedArtifacts(expectedArtifacts)
.build()
}

HelmEvent createHelmEvent(String version = "1.0.0") {
def res = new HelmEvent()
res.content = new HelmEvent.Content("account", "chart", version, "digest")
res.details = new Metadata([type: HelmEvent.TYPE, source: "spock"])
return res
}
}

0 comments on commit 5f81ffa

Please sign in to comment.