From e03b778faf620d6eea35224f8516dcbcf96e03a0 Mon Sep 17 00:00:00 2001 From: Rob Zienert Date: Wed, 26 Apr 2017 11:48:02 -0700 Subject: [PATCH] feat(core): Adding pipeline template storage (#222) --- .../config/CommonStorageServiceDAOConfig.java | 14 +++ ...orageServiceConfigurationProperties.groovy | 1 + .../spinnaker/front50/model/ObjectType.java | 2 + .../pipeline/DefaultPipelineTemplateDAO.java | 54 +++++++++ .../model/pipeline/PipelineTemplate.java | 74 ++++++++++++ .../model/pipeline/PipelineTemplateDAO.java | 26 ++++ .../spinnaker/front50/config/GcsConfig.java | 1 - .../front50/redis/RedisConfig.groovy | 6 + .../redis/RedisPipelineTemplateDAO.java | 106 ++++++++++++++++ .../front50/config/Front50WebConfig.groovy | 7 ++ .../controllers/PipelineController.groovy | 1 + .../PipelineTemplateController.java | 113 ++++++++++++++++++ 12 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/pipeline/DefaultPipelineTemplateDAO.java create mode 100644 front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/pipeline/PipelineTemplate.java create mode 100644 front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/pipeline/PipelineTemplateDAO.java create mode 100644 front50-redis/src/main/groovy/com/netflix/spinnaker/front50/redis/RedisPipelineTemplateDAO.java create mode 100644 front50-web/src/main/groovy/com/netflix/spinnaker/front50/controllers/PipelineTemplateController.java diff --git a/front50-core/src/main/groovy/com/netflix/spinnaker/front50/config/CommonStorageServiceDAOConfig.java b/front50-core/src/main/groovy/com/netflix/spinnaker/front50/config/CommonStorageServiceDAOConfig.java index 446fa519e..001d1a58c 100644 --- a/front50-core/src/main/groovy/com/netflix/spinnaker/front50/config/CommonStorageServiceDAOConfig.java +++ b/front50-core/src/main/groovy/com/netflix/spinnaker/front50/config/CommonStorageServiceDAOConfig.java @@ -26,8 +26,10 @@ import com.netflix.spinnaker.front50.model.notification.NotificationDAO; import com.netflix.spinnaker.front50.model.pipeline.DefaultPipelineDAO; import com.netflix.spinnaker.front50.model.pipeline.DefaultPipelineStrategyDAO; +import com.netflix.spinnaker.front50.model.pipeline.DefaultPipelineTemplateDAO; import com.netflix.spinnaker.front50.model.pipeline.PipelineDAO; import com.netflix.spinnaker.front50.model.pipeline.PipelineStrategyDAO; +import com.netflix.spinnaker.front50.model.pipeline.PipelineTemplateDAO; import com.netflix.spinnaker.front50.model.project.DefaultProjectDAO; import com.netflix.spinnaker.front50.model.project.ProjectDAO; import com.netflix.spinnaker.front50.model.serviceaccount.DefaultServiceAccountDAO; @@ -126,6 +128,18 @@ PipelineDAO pipelineDAO(StorageService storageService, ); } + @Bean + PipelineTemplateDAO pipelineTemplateDAO(StorageService storageService, + StorageServiceConfigurationProperties storageServiceConfigurationProperties, + Registry registry) { + return new DefaultPipelineTemplateDAO( + storageService, + Schedulers.from(Executors.newFixedThreadPool(storageServiceConfigurationProperties.getPipelineTemplate().getThreadPool())), + storageServiceConfigurationProperties.getPipelineTemplate().getRefreshMs(), + registry + ); + } + @Bean SnapshotDAO snapshotDAO(StorageService storageService, StorageServiceConfigurationProperties storageServiceConfigurationProperties, diff --git a/front50-core/src/main/groovy/com/netflix/spinnaker/front50/config/StorageServiceConfigurationProperties.groovy b/front50-core/src/main/groovy/com/netflix/spinnaker/front50/config/StorageServiceConfigurationProperties.groovy index bc884d61e..88b9302b9 100644 --- a/front50-core/src/main/groovy/com/netflix/spinnaker/front50/config/StorageServiceConfigurationProperties.groovy +++ b/front50-core/src/main/groovy/com/netflix/spinnaker/front50/config/StorageServiceConfigurationProperties.groovy @@ -30,6 +30,7 @@ class StorageServiceConfigurationProperties { PerObjectType notification = new PerObjectType(20, TimeUnit.MINUTES.toMillis(1)) PerObjectType pipelineStrategy = new PerObjectType(20, TimeUnit.MINUTES.toMillis(1)) PerObjectType pipeline = new PerObjectType(20, TimeUnit.MINUTES.toMillis(1)) + PerObjectType pipelineTemplate = new PerObjectType(20, TimeUnit.MINUTES.toMillis(1)) PerObjectType snapshot = new PerObjectType(2, TimeUnit.MINUTES.toMillis(1)) // not commonly used outside of Netflix diff --git a/front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/ObjectType.java b/front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/ObjectType.java index 063a15e85..0a2d438b8 100644 --- a/front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/ObjectType.java +++ b/front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/ObjectType.java @@ -19,6 +19,7 @@ import com.netflix.spinnaker.front50.model.application.Application; import com.netflix.spinnaker.front50.model.notification.Notification; import com.netflix.spinnaker.front50.model.pipeline.Pipeline; +import com.netflix.spinnaker.front50.model.pipeline.PipelineTemplate; import com.netflix.spinnaker.front50.model.project.Project; import com.netflix.spinnaker.front50.model.serviceaccount.ServiceAccount; import com.netflix.spinnaker.front50.model.snapshot.Snapshot; @@ -28,6 +29,7 @@ public enum ObjectType { PROJECT(Project.class, "projects", "project-metadata.json"), PIPELINE(Pipeline.class, "pipelines", "pipeline-metadata.json"), STRATEGY(Pipeline.class, "pipeline-strategies", "pipeline-strategy-metadata.json"), + PIPELINE_TEMPLATE(PipelineTemplate.class, "pipeline-templates", "pipeline-template-metadata.json"), NOTIFICATION(Notification.class, "notifications", "notification-metadata.json"), SERVICE_ACCOUNT(ServiceAccount.class, "serviceAccounts", "serviceAccount-metadata.json"), APPLICATION(Application.class, "applications", "application-metadata.json"), diff --git a/front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/pipeline/DefaultPipelineTemplateDAO.java b/front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/pipeline/DefaultPipelineTemplateDAO.java new file mode 100644 index 000000000..c666aea0b --- /dev/null +++ b/front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/pipeline/DefaultPipelineTemplateDAO.java @@ -0,0 +1,54 @@ +/* + * Copyright 2017 Netflix, 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.front50.model.pipeline; + +import com.netflix.spectator.api.Registry; +import com.netflix.spinnaker.front50.model.ObjectType; +import com.netflix.spinnaker.front50.model.StorageService; +import com.netflix.spinnaker.front50.model.StorageServiceSupport; +import org.springframework.util.Assert; +import rx.Scheduler; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +public class DefaultPipelineTemplateDAO extends StorageServiceSupport implements PipelineTemplateDAO { + + public DefaultPipelineTemplateDAO(StorageService service, + Scheduler scheduler, + long refreshIntervalMs, + Registry registry) { + super(ObjectType.PIPELINE_TEMPLATE, service, scheduler, refreshIntervalMs, registry); + } + + @Override + public Collection getPipelineTemplatesByScope(List scope) { + return all() + .stream() + .filter(pt -> pt.containsAnyScope(scope)) + .collect(Collectors.toList()); + } + + @Override + public PipelineTemplate create(String id, PipelineTemplate item) { + Assert.notNull(item.getId(), "id field must NOT to be null!"); + Assert.notEmpty(item.getScopes(), "scopes field must have at least ONE scope!"); + + update(id, item); + return findById(id); + } +} diff --git a/front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/pipeline/PipelineTemplate.java b/front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/pipeline/PipelineTemplate.java new file mode 100644 index 000000000..2aa01cc5a --- /dev/null +++ b/front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/pipeline/PipelineTemplate.java @@ -0,0 +1,74 @@ +/* + * Copyright 2017 Netflix, 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.front50.model.pipeline; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.netflix.spinnaker.front50.model.Timestamped; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class PipelineTemplate extends HashMap implements Timestamped { + + @JsonIgnore + @SuppressWarnings("unchecked") + public List getScopes() { + Map metadata = (Map) super.get("metadata"); + if (metadata == null || metadata.isEmpty()) { + return Collections.emptyList(); + } + return (List) metadata.get("scopes"); + } + + @Override + public String getId() { + return (String) super.get("id"); + } + + @Override + public Long getLastModified() { + String updateTs = (String) super.get("updateTs"); + return (updateTs != null) ? Long.valueOf(updateTs) : null; + } + + @Override + public void setLastModified(Long lastModified) { + super.put("updateTs", lastModified.toString()); + } + + @Override + public String getLastModifiedBy() { + return (String) super.get("lastModifiedBy"); + } + + @Override + public void setLastModifiedBy(String lastModifiedBy) { + super.put("lastModifiedBy", lastModifiedBy); + } + + public boolean containsAnyScope(List scope) { + for (String s : scope) { + for (String s2 : getScopes()) { + if (s.equalsIgnoreCase(s2)) { + return true; + } + } + } + return false; + } +} diff --git a/front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/pipeline/PipelineTemplateDAO.java b/front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/pipeline/PipelineTemplateDAO.java new file mode 100644 index 000000000..b487da7bc --- /dev/null +++ b/front50-core/src/main/groovy/com/netflix/spinnaker/front50/model/pipeline/PipelineTemplateDAO.java @@ -0,0 +1,26 @@ +/* + * Copyright 2017 Netflix, 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.front50.model.pipeline; + +import com.netflix.spinnaker.front50.model.ItemDAO; + +import java.util.Collection; +import java.util.List; + +public interface PipelineTemplateDAO extends ItemDAO { + + Collection getPipelineTemplatesByScope(List scope); +} diff --git a/front50-gcs/src/main/java/com/netflix/spinnaker/front50/config/GcsConfig.java b/front50-gcs/src/main/java/com/netflix/spinnaker/front50/config/GcsConfig.java index 8a0744a82..64e6708fc 100644 --- a/front50-gcs/src/main/java/com/netflix/spinnaker/front50/config/GcsConfig.java +++ b/front50-gcs/src/main/java/com/netflix/spinnaker/front50/config/GcsConfig.java @@ -34,7 +34,6 @@ import java.util.Optional; import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; @Configuration @ConditionalOnExpression("${spinnaker.gcs.enabled:false}") diff --git a/front50-redis/src/main/groovy/com/netflix/spinnaker/front50/redis/RedisConfig.groovy b/front50-redis/src/main/groovy/com/netflix/spinnaker/front50/redis/RedisConfig.groovy index e25e0d69c..d2357e68a 100644 --- a/front50-redis/src/main/groovy/com/netflix/spinnaker/front50/redis/RedisConfig.groovy +++ b/front50-redis/src/main/groovy/com/netflix/spinnaker/front50/redis/RedisConfig.groovy @@ -19,6 +19,7 @@ package com.netflix.spinnaker.front50.redis import com.netflix.spinnaker.front50.model.application.Application import com.netflix.spinnaker.front50.model.notification.Notification import com.netflix.spinnaker.front50.model.pipeline.Pipeline +import com.netflix.spinnaker.front50.model.pipeline.PipelineTemplate import com.netflix.spinnaker.front50.model.project.Project import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression import org.springframework.boot.context.properties.EnableConfigurationProperties @@ -54,6 +55,11 @@ class RedisConfig { new RedisPipelineDAO(redisTemplate: template) } + @Bean + RedisPipelineTemplateDAO redisPipelineTemplateDAO(RedisTemplate template) { + new RedisPipelineTemplateDAO(redisTemplate: template) + } + @Bean RedisNotificationDAO redisNotificationDAO(RedisTemplate template) { new RedisNotificationDAO(redisTemplate: template) diff --git a/front50-redis/src/main/groovy/com/netflix/spinnaker/front50/redis/RedisPipelineTemplateDAO.java b/front50-redis/src/main/groovy/com/netflix/spinnaker/front50/redis/RedisPipelineTemplateDAO.java new file mode 100644 index 000000000..55c6a5d24 --- /dev/null +++ b/front50-redis/src/main/groovy/com/netflix/spinnaker/front50/redis/RedisPipelineTemplateDAO.java @@ -0,0 +1,106 @@ +/* + * Copyright 2017 Netflix, 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.front50.redis; + +import com.google.common.collect.Lists; +import com.netflix.spinnaker.front50.exception.NotFoundException; +import com.netflix.spinnaker.front50.model.pipeline.PipelineTemplate; +import com.netflix.spinnaker.front50.model.pipeline.PipelineTemplateDAO; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.util.Assert; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +public class RedisPipelineTemplateDAO implements PipelineTemplateDAO { + + static final String BOOK_KEEPING_KEY = "com.netflix.spinnaker:front50:pipelineTemplates"; + + RedisTemplate redisTemplate; + + @Override + public Collection getPipelineTemplatesByScope(List scope) { + return all() + .stream() + .filter(it -> it.containsAnyScope(scope)) + .collect(Collectors.toList()); + } + + @Override + public PipelineTemplate findById(String id) throws NotFoundException { + PipelineTemplate pipelineTemplate = (PipelineTemplate) redisTemplate.opsForHash().get(BOOK_KEEPING_KEY, id); + if (pipelineTemplate == null) { + throw new NotFoundException("No pipeline template found with id '" + id + "'"); + } + return pipelineTemplate; + } + + @Override + public Collection all() { + return all(true); + } + + @Override + public Collection all(boolean refresh) { + return Lists.newArrayList(redisTemplate.opsForHash().scan(BOOK_KEEPING_KEY, ScanOptions.scanOptions().match("*").build())) + .stream() + .map(e -> (PipelineTemplate) e.getValue()) + .collect(Collectors.toList()); + } + + @Override + public Collection history(String id, int maxResults) { + return Lists.newArrayList(findById(id)); + } + + @Override + public PipelineTemplate create(String id, PipelineTemplate item) { + Assert.notNull(item.getId(), "id field must NOT to be null!"); + Assert.notEmpty(item.getScopes(), "scope field must have at least ONE scope!"); + + redisTemplate.opsForHash().put(BOOK_KEEPING_KEY, item.getId(), item); + + return item; + } + + @Override + public void update(String id, PipelineTemplate item) { + item.setLastModified(System.currentTimeMillis()); + create(id, item); + } + + @Override + public void delete(String id) { + redisTemplate.opsForHash().delete(BOOK_KEEPING_KEY, id); + } + + @Override + public void bulkImport(Collection items) { + items.forEach(it -> create(it.getId(), it)); + } + + @Override + public boolean isHealthy() { + try { + redisTemplate.opsForHash().get("", ""); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/front50-web/src/main/groovy/com/netflix/spinnaker/front50/config/Front50WebConfig.groovy b/front50-web/src/main/groovy/com/netflix/spinnaker/front50/config/Front50WebConfig.groovy index cddf8c1b8..9df82fb8e 100644 --- a/front50-web/src/main/groovy/com/netflix/spinnaker/front50/config/Front50WebConfig.groovy +++ b/front50-web/src/main/groovy/com/netflix/spinnaker/front50/config/Front50WebConfig.groovy @@ -24,6 +24,8 @@ import com.netflix.spinnaker.front50.model.application.ApplicationDAO import com.netflix.spinnaker.front50.model.application.ApplicationPermissionDAO import com.netflix.spinnaker.front50.model.pipeline.PipelineDAO import com.netflix.spinnaker.front50.model.pipeline.PipelineStrategyDAO +import com.netflix.spinnaker.front50.model.pipeline.PipelineTemplateDAO + import com.netflix.spinnaker.front50.model.project.ProjectDAO import com.netflix.spinnaker.front50.model.serviceaccount.ServiceAccountDAO import com.netflix.spinnaker.kork.web.interceptors.MetricsInterceptor @@ -82,6 +84,11 @@ public class Front50WebConfig extends WebMvcConfigurerAdapter { return new ItemDAOHealthIndicator(itemDAO: pipelineDAO) } + @Bean + ItemDAOHealthIndicator pipelineTemplateDAOHealthIndicator(PipelineTemplateDAO pipelineTemplateDAO) { + return new ItemDAOHealthIndicator(itemDAO: pipelineTemplateDAO) + } + @Bean ItemDAOHealthIndicator pipelineStrategyDAOHealthIndicator(PipelineStrategyDAO pipelineStrategyDAO) { return new ItemDAOHealthIndicator(itemDAO: pipelineStrategyDAO) diff --git a/front50-web/src/main/groovy/com/netflix/spinnaker/front50/controllers/PipelineController.groovy b/front50-web/src/main/groovy/com/netflix/spinnaker/front50/controllers/PipelineController.groovy index ba00329c6..709def346 100644 --- a/front50-web/src/main/groovy/com/netflix/spinnaker/front50/controllers/PipelineController.groovy +++ b/front50-web/src/main/groovy/com/netflix/spinnaker/front50/controllers/PipelineController.groovy @@ -18,6 +18,7 @@ package com.netflix.spinnaker.front50.controllers import com.netflix.spinnaker.front50.model.pipeline.Pipeline import com.netflix.spinnaker.front50.model.pipeline.PipelineDAO + import groovy.transform.InheritConstructors import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpStatus diff --git a/front50-web/src/main/groovy/com/netflix/spinnaker/front50/controllers/PipelineTemplateController.java b/front50-web/src/main/groovy/com/netflix/spinnaker/front50/controllers/PipelineTemplateController.java new file mode 100644 index 000000000..4daf8895e --- /dev/null +++ b/front50-web/src/main/groovy/com/netflix/spinnaker/front50/controllers/PipelineTemplateController.java @@ -0,0 +1,113 @@ +/* + * Copyright 2017 Netflix, 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.front50.controllers; + +import com.netflix.spinnaker.front50.exception.NotFoundException; +import com.netflix.spinnaker.front50.model.pipeline.PipelineTemplate; +import com.netflix.spinnaker.front50.model.pipeline.PipelineTemplateDAO; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("pipelineTemplates") +public class PipelineTemplateController { + + @Autowired + PipelineTemplateDAO pipelineTemplateDAO; + + // TODO rz - Add fiat authz + + @RequestMapping(value = "", method = RequestMethod.GET) + List list(@RequestParam(required = false, value = "scopes", defaultValue = "global") List scopes) { + return (List) pipelineTemplateDAO.getPipelineTemplatesByScope(scopes); + } + + @RequestMapping(value = "", method = RequestMethod.POST) + void save(@RequestBody PipelineTemplate pipelineTemplate) { + checkForDuplicatePipelineTemplate(pipelineTemplate.getId()); + pipelineTemplateDAO.create(pipelineTemplate.getId(), pipelineTemplate); + } + + @RequestMapping(value = "{id}", method = RequestMethod.GET) + PipelineTemplate get(@PathVariable String id) { + return pipelineTemplateDAO.findById(id); + } + + @RequestMapping(value = "{id}", method = RequestMethod.PUT) + PipelineTemplate update(@PathVariable String id, @RequestBody PipelineTemplate pipelineTemplate) { + PipelineTemplate existingPipelineTemplate = pipelineTemplateDAO.findById(id); + if (!pipelineTemplate.getId().equals(existingPipelineTemplate.getId())) { + throw new InvalidPipelineTemplateRequestException("The provided id " + id + " doesn't match the pipeline template id " + pipelineTemplate.getId()); + } + + pipelineTemplate.setLastModified(System.currentTimeMillis()); + pipelineTemplateDAO.update(id, pipelineTemplate); + + return pipelineTemplate; + } + + private void checkForDuplicatePipelineTemplate(String id) { + try { + pipelineTemplateDAO.findById(id); + } catch (NotFoundException e) { + return; + } + throw new DuplicatePipelineTemplateIdException("A pipeline template with the id " + id + " already exists"); + } + + @ExceptionHandler(InvalidPipelineTemplateRequestException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + Map handleInvalidPipelineTemplateRequestException(InvalidPipelineTemplateRequestException e) { + Map m = new HashMap<>(); + m.put("error", e.getMessage()); + m.put("status", HttpStatus.BAD_REQUEST); + return m; + } + + @ExceptionHandler(DuplicatePipelineTemplateIdException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + Map handleDuplicatePipelineTemplateIdException(DuplicatePipelineTemplateIdException e) { + Map m = new HashMap<>(); + m.put("error", e.getMessage()); + m.put("status", HttpStatus.BAD_REQUEST); + return m; + } + + static class InvalidPipelineTemplateRequestException extends RuntimeException { + InvalidPipelineTemplateRequestException(String message) { + super(message); + } + } + + static class DuplicatePipelineTemplateIdException extends RuntimeException { + DuplicatePipelineTemplateIdException(String message) { + super(message); + } + } + +}