From 5f81ffa71967257d40174d8237cc975d44173ec2 Mon Sep 17 00:00:00 2001 From: jcavanagh Date: Tue, 15 Sep 2020 13:04:04 -0500 Subject: [PATCH] feat(helm): Add Helm pipeline triggering (#988) - Helm trigger event handler - Semver checks against chart information Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .../netflix/spinnaker/echo/model/Trigger.java | 3 +- .../echo/model/trigger/HelmEvent.java | 27 ++++ .../echo-pipelinetriggers.gradle | 2 + .../eventhandlers/HelmEventHandler.java | 119 ++++++++++++++++++ .../eventhandlers/HelmEventHandlerSpec.groovy | 108 ++++++++++++++++ .../spinnaker/echo/test/RetrofitStubs.groovy | 9 ++ 6 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 echo-model/src/main/java/com/netflix/spinnaker/echo/model/trigger/HelmEvent.java create mode 100644 echo-pipelinetriggers/src/main/java/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/HelmEventHandler.java create mode 100644 echo-pipelinetriggers/src/test/groovy/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/HelmEventHandlerSpec.groovy diff --git a/echo-model/src/main/java/com/netflix/spinnaker/echo/model/Trigger.java b/echo-model/src/main/java/com/netflix/spinnaker/echo/model/Trigger.java index 6982d44f7..d2f696ab9 100644 --- a/echo-model/src/main/java/com/netflix/spinnaker/echo/model/Trigger.java +++ b/echo-model/src/main/java/com/netflix/spinnaker/echo/model/Trigger.java @@ -83,7 +83,8 @@ public enum Type { PUBSUB("pubsub"), DRYRUN("dryrun"), PIPELINE("pipeline"), - PLUGIN("plugin"); + PLUGIN("plugin"), + HELM("helm"); private final String type; diff --git a/echo-model/src/main/java/com/netflix/spinnaker/echo/model/trigger/HelmEvent.java b/echo-model/src/main/java/com/netflix/spinnaker/echo/model/trigger/HelmEvent.java new file mode 100644 index 000000000..5bdff30ce --- /dev/null +++ b/echo-model/src/main/java/com/netflix/spinnaker/echo/model/trigger/HelmEvent.java @@ -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; + } +} diff --git a/echo-pipelinetriggers/echo-pipelinetriggers.gradle b/echo-pipelinetriggers/echo-pipelinetriggers.gradle index 9df466e77..cdae35686 100644 --- a/echo-pipelinetriggers/echo-pipelinetriggers.gradle +++ b/echo-pipelinetriggers/echo-pipelinetriggers.gradle @@ -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" diff --git a/echo-pipelinetriggers/src/main/java/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/HelmEventHandler.java b/echo-pipelinetriggers/src/main/java/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/HelmEventHandler.java new file mode 100644 index 000000000..1b345bc4a --- /dev/null +++ b/echo-pipelinetriggers/src/main/java/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/HelmEventHandler.java @@ -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 { + 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 supportedTriggerTypes() { + return Collections.singletonList(TRIGGER_TYPE); + } + + @Override + public boolean handleEventType(String eventType) { + return eventType.equalsIgnoreCase(HelmEvent.TYPE); + } + + @Override + public Class 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 getArtifactsFromEvent(HelmEvent helmEvent, Trigger trigger) { + HelmEvent.Content content = helmEvent.getContent(); + Map 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 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 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)); + } +} diff --git a/echo-pipelinetriggers/src/test/groovy/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/HelmEventHandlerSpec.groovy b/echo-pipelinetriggers/src/test/groovy/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/HelmEventHandlerSpec.groovy new file mode 100644 index 000000000..f16fbec4e --- /dev/null +++ b/echo-pipelinetriggers/src/test/groovy/com/netflix/spinnaker/echo/pipelinetriggers/eventhandlers/HelmEventHandlerSpec.groovy @@ -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() + } +} 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 63570ec5b..1ec74945a 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 @@ -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) @@ -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 + } }