Skip to content

Commit

Permalink
feat(core): Add DynamicStageResolver to help stage plugin migrations (#…
Browse files Browse the repository at this point in the history
…3570)

* refactor(core): Introduce interface for StageResolver

* feat(core): Add DynamicStageResolver to help stage plugin migrations

Allows for multiple stages to be wired up with the same alias, using dynamic config to dictate which to use.

It defaults disabled, but can be enabled with `dynamic-stage-resolver.enabled`.
  • Loading branch information
robzienert committed Apr 2, 2020
1 parent ccddb75 commit e15c310
Show file tree
Hide file tree
Showing 16 changed files with 444 additions and 83 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,16 @@
package com.netflix.spinnaker.orca.kato.pipeline

import com.netflix.spinnaker.orca.api.pipeline.models.ExecutionStatus
import com.netflix.spinnaker.orca.jackson.OrcaObjectMapper
import com.netflix.spinnaker.orca.kato.pipeline.support.ResizeSupport
import com.netflix.spinnaker.orca.kato.pipeline.support.TargetReference
import com.netflix.spinnaker.orca.kato.pipeline.support.TargetReferenceSupport
import com.netflix.spinnaker.orca.pipeline.graph.StageGraphBuilderImpl
import com.netflix.spinnaker.orca.pipeline.model.PipelineExecutionImpl
import com.netflix.spinnaker.orca.pipeline.model.StageExecutionImpl
import com.netflix.spinnaker.orca.pipeline.util.StageNavigator
import spock.lang.Shared
import spock.lang.Specification

class ResizeAsgStageSpec extends Specification {

@Shared
def stageNavigator = new StageNavigator([])

def mapper = OrcaObjectMapper.newInstance()
def targetReferenceSupport = Mock(TargetReferenceSupport)
def resizeSupport = new ResizeSupport(targetReferenceSupport: targetReferenceSupport)
def stageBuilder = new ResizeAsgStage()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright 2019 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.orca;

import static java.lang.String.format;

import com.netflix.spinnaker.orca.api.pipeline.graph.StageDefinitionBuilder;
import com.netflix.spinnaker.orca.api.simplestage.SimpleStage;
import com.netflix.spinnaker.orca.pipeline.SimpleStageDefinitionBuilder;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import javax.annotation.Nonnull;

/**
* {@code StageResolver} allows for {@code StageDefinitionBuilder} retrieval via bean name or alias.
*
* <p>Aliases represent the previous bean names that a {@code StageDefinitionBuilder} registered as.
*/
public class DefaultStageResolver implements StageResolver {
private final Map<String, StageDefinitionBuilder> stageDefinitionBuilderByAlias = new HashMap<>();

public DefaultStageResolver(
Collection<StageDefinitionBuilder> stageDefinitionBuilders,
Collection<SimpleStage> simpleStages) {
for (StageDefinitionBuilder stageDefinitionBuilder : stageDefinitionBuilders) {
stageDefinitionBuilderByAlias.put(stageDefinitionBuilder.getType(), stageDefinitionBuilder);
for (String alias : stageDefinitionBuilder.aliases()) {
if (stageDefinitionBuilderByAlias.containsKey(alias)) {
throw new DuplicateStageAliasException(
format(
"Duplicate stage alias detected (alias: %s, previous: %s, current: %s)",
alias,
stageDefinitionBuilderByAlias.get(alias).getClass().getCanonicalName(),
stageDefinitionBuilder.getClass().getCanonicalName()));
}

stageDefinitionBuilderByAlias.put(alias, stageDefinitionBuilder);
}
}

simpleStages.forEach(
s -> stageDefinitionBuilderByAlias.put(s.getName(), new SimpleStageDefinitionBuilder(s)));
}

/**
* Fetch a {@code StageDefinitionBuilder} by {@code type} or {@code typeAlias}.
*
* @param type StageDefinitionBuilder type
* @param typeAlias StageDefinitionBuilder alias (optional)
* @return the StageDefinitionBuilder matching {@code type} or {@code typeAlias}
* @throws NoSuchStageDefinitionBuilderException if StageDefinitionBuilder does not exist
*/
@Override
@Nonnull
public StageDefinitionBuilder getStageDefinitionBuilder(@Nonnull String type, String typeAlias) {
StageDefinitionBuilder stageDefinitionBuilder =
stageDefinitionBuilderByAlias.getOrDefault(
type, stageDefinitionBuilderByAlias.get(typeAlias));

if (stageDefinitionBuilder == null) {
throw new NoSuchStageDefinitionBuilderException(type, stageDefinitionBuilderByAlias.keySet());
}

return stageDefinitionBuilder;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/*
* Copyright 2020 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.orca

import com.netflix.spinnaker.kork.dynamicconfig.DynamicConfigService
import com.netflix.spinnaker.kork.exceptions.SystemException
import com.netflix.spinnaker.orca.api.pipeline.graph.StageDefinitionBuilder
import com.netflix.spinnaker.orca.api.simplestage.SimpleStage
import com.netflix.spinnaker.orca.pipeline.SimpleStageDefinitionBuilder
import org.slf4j.LoggerFactory

/**
* Allows for multiple stages to be wired up with the same alias, using dynamic config to dictate which to use.
*
* This class makes migrating stages originally written directly into Orca to a plugin model easier. To prevent stage
* "downtime", a stage will be included in Orca as well as by a plugin, both of which will share one or more aliases.
* Duplicate safety checks are still performed: If a config does not exist for a [StageDefinitionBuilder.getType] or
* its alias, exceptions will be thrown, which will cause the application to not start.
*
* This resolver is more expensive than [DefaultStageResolver], so unless you are migrating stages, usage of this
* [StageResolver] is not recommended.
*
* The config values follow a convention of `dynamic-stage-resolver.${stageDefinitionBuilderAlias}`, where the value
* should be the canonical class name of the desired [StageDefinitionBuilder]. Having two builders with the same alias
* and canonical class name is never allowed.
*/
class DynamicStageResolver(
private val dynamicConfigService: DynamicConfigService,
stageDefinitionBuilders: Collection<StageDefinitionBuilder>,
simpleStages: Collection<SimpleStage<*>>?
) : StageResolver {

private val log by lazy { LoggerFactory.getLogger(javaClass) }

private val stageDefinitionBuildersByAlias: MutableMap<String, MutableList<StageDefinitionBuilder>> = mutableMapOf()
private val fallbackPreferences: MutableMap<String, String> = mutableMapOf()

init {
stageDefinitionBuilders.forEach { builder ->
putOrAdd(builder.type, builder)
builder.aliases().forEach { alias ->
putOrAdd(alias, builder)
}
}
simpleStages?.forEach {
putOrAdd(it.name, SimpleStageDefinitionBuilder(it))
}

stageDefinitionBuildersByAlias.filter { it.value.size > 1 }.also {
validatePreferences(it)
validateClassNames(it)
cachePreferences(it)
}
}

override fun getStageDefinitionBuilder(type: String, typeAlias: String?): StageDefinitionBuilder {
var builder: StageDefinitionBuilder? = null

val builderForType = stageDefinitionBuildersByAlias[type]
if (builderForType != null) {
builder = builderForType.resolveByPreference(type)
}

if (builder == null && typeAlias != null) {
builder = stageDefinitionBuildersByAlias[typeAlias]?.resolveByPreference(typeAlias)
}

if (builder == null) {
throw StageResolver.NoSuchStageDefinitionBuilderException(type, stageDefinitionBuildersByAlias.keys)
}

return builder
}

private fun putOrAdd(key: String, stageDefinitionBuilder: StageDefinitionBuilder) {
stageDefinitionBuildersByAlias.computeIfAbsent(key) { mutableListOf() }.add(stageDefinitionBuilder)
}

/**
* Ensures that any conflicting [StageDefinitionBuilder] keys have config set to resolve the preferred instance.
*/
private fun validatePreferences(duplicates: Map<String, MutableList<StageDefinitionBuilder>>) {
duplicates.forEach { duplicate ->
val pref = getPreference(duplicate.key)

if (pref == NO_PREFERENCE) {
throw NoPreferenceConfigPresentException(duplicate.key)
}

// Ensure the preference is actually valid: Is there a StageDefinitionBuilder with a matching canonical name?
duplicate.value.map { it.javaClass.canonicalName }.let {
if (!it.contains(pref)) {
throw InvalidStageDefinitionBuilderPreference(duplicate.key, pref, it)
}
}
}
}

/**
* Ensures that no conflicting [StageDefinitionBuilder]s have the same canonical class name.
*/
private fun validateClassNames(duplicates: Map<String, MutableList<StageDefinitionBuilder>>) {
duplicates
.filter { entry ->
entry.value.map { it.javaClass.canonicalName }.distinct().size != entry.value.size
}
.also {
if (it.isNotEmpty()) {
throw ConflictingClassNamesException(it.keys)
}
}
}

/**
* Caches all preferences for fallback if a specific dynamic config becomes invalid later. It is preferable to use
* an old value rather than throwing a runtime exception because no [StageDefinitionBuilder] could be located.
*/
private fun cachePreferences(duplicates: Map<String, MutableList<StageDefinitionBuilder>>) {
duplicates.forEach {
fallbackPreferences[it.key] = getPreference(it.key)
}
}

/**
* Locates the [StageDefinitionBuilder] for a given [type], falling back to the original defaults if the config has
* changed to an invalid value.
*/
private fun List<StageDefinitionBuilder>.resolveByPreference(type: String): StageDefinitionBuilder? {
if (isEmpty()) {
return null
}

if (size == 1) {
return first()
}

val pref = getPreference(type)
val builder = firstOrNull { it.javaClass.canonicalName == pref }
if (builder == null && fallbackPreferences.containsKey(type)) {
log.warn("Preference for '$type' ($pref) is invalid, falling back to '${fallbackPreferences[type]}'")
return firstOrNull { it.javaClass.canonicalName == fallbackPreferences[type] }
}

return builder
}

private fun getPreference(type: String): String =
dynamicConfigService.getConfig(
String::class.java,
"dynamic-stage-resolver.$type",
NO_PREFERENCE
)

internal inner class NoPreferenceConfigPresentException(key: String) : SystemException(
"No DynamicStageResolver preference config set for conflicting StageDefinitionBuilder of type '$key'"
)

internal inner class ConflictingClassNamesException(keys: Set<String>) : SystemException(
"Conflicting StageDefinitionBuilder class names for keys: ${keys.joinToString()}"
)

internal inner class InvalidStageDefinitionBuilderPreference(
key: String,
pref: String,
candidates: List<String>
) : SystemException(
"Preference for '$key' StageDefinitionBuilder of '$pref' is invalid. " +
"Valid canonical names are: ${candidates.joinToString()}"
)

private companion object {
const val NO_PREFERENCE = "no-preference-config-defined"
}
}
Original file line number Diff line number Diff line change
@@ -1,60 +1,43 @@
/*
* Copyright 2019 Netflix, Inc.
* Copyright 2020 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
* 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.orca;

import static java.lang.String.format;

import com.netflix.spinnaker.orca.api.pipeline.graph.StageDefinitionBuilder;
import com.netflix.spinnaker.orca.api.simplestage.SimpleStage;
import com.netflix.spinnaker.orca.pipeline.SimpleStageDefinitionBuilder;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import javax.annotation.Nonnull;

/**
* {@code StageResolver} allows for {@code StageDefinitionBuilder} retrieval via bean name or alias.
*
* <p>Aliases represent the previous bean names that a {@code StageDefinitionBuilder} registered as.
*/
public class StageResolver {
private final Map<String, StageDefinitionBuilder> stageDefinitionBuilderByAlias = new HashMap<>();

public StageResolver(
Collection<StageDefinitionBuilder> stageDefinitionBuilders,
Collection<SimpleStage> simpleStages) {
for (StageDefinitionBuilder stageDefinitionBuilder : stageDefinitionBuilders) {
stageDefinitionBuilderByAlias.put(stageDefinitionBuilder.getType(), stageDefinitionBuilder);
for (String alias : stageDefinitionBuilder.aliases()) {
if (stageDefinitionBuilderByAlias.containsKey(alias)) {
throw new DuplicateStageAliasException(
format(
"Duplicate stage alias detected (alias: %s, previous: %s, current: %s)",
alias,
stageDefinitionBuilderByAlias.get(alias).getClass().getCanonicalName(),
stageDefinitionBuilder.getClass().getCanonicalName()));
}
public interface StageResolver {

stageDefinitionBuilderByAlias.put(alias, stageDefinitionBuilder);
}
}

simpleStages.forEach(
s -> stageDefinitionBuilderByAlias.put(s.getName(), new SimpleStageDefinitionBuilder(s)));
/**
* Fetch a {@code StageDefinitionBuilder} by {@code type}.
*
* @param type StageDefinitionBuilder type
* @return the StageDefinitionBuilder matching {@code type} or {@code typeAlias}
* @throws DefaultStageResolver.NoSuchStageDefinitionBuilderException if StageDefinitionBuilder
* does not exist
*/
@Nonnull
default StageDefinitionBuilder getStageDefinitionBuilder(@Nonnull String type) {
return getStageDefinitionBuilder(type, null);
}

/**
Expand All @@ -63,20 +46,11 @@ public StageResolver(
* @param type StageDefinitionBuilder type
* @param typeAlias StageDefinitionBuilder alias (optional)
* @return the StageDefinitionBuilder matching {@code type} or {@code typeAlias}
* @throws NoSuchStageDefinitionBuilderException if StageDefinitionBuilder does not exist
* @throws DefaultStageResolver.NoSuchStageDefinitionBuilderException if StageDefinitionBuilder
* does not exist
*/
@Nonnull
public StageDefinitionBuilder getStageDefinitionBuilder(@Nonnull String type, String typeAlias) {
StageDefinitionBuilder stageDefinitionBuilder =
stageDefinitionBuilderByAlias.getOrDefault(
type, stageDefinitionBuilderByAlias.get(typeAlias));

if (stageDefinitionBuilder == null) {
throw new NoSuchStageDefinitionBuilderException(type, stageDefinitionBuilderByAlias.keySet());
}

return stageDefinitionBuilder;
}
StageDefinitionBuilder getStageDefinitionBuilder(@Nonnull String type, String typeAlias);

class DuplicateStageAliasException extends IllegalStateException {
DuplicateStageAliasException(String message) {
Expand Down
Loading

0 comments on commit e15c310

Please sign in to comment.