From ac28355eb675da6ae7e44347d8f540a6402d1497 Mon Sep 17 00:00:00 2001 From: Rob Fletcher Date: Thu, 15 Mar 2018 17:24:00 -0700 Subject: [PATCH] Kayenta tests (#2056) * refactor(kayenta): Use strongly typed classes to represent Kayenta stage config. * refactor(kayenta): Migrate tests to Kotlin --- orca-kayenta/orca-kayenta.gradle | 2 - .../pipeline/KayentaCanaryStageSpec.groovy | 427 ------------------ .../AggregateCanaryResultsTaskSpec.groovy | 93 ---- .../orca/kayenta/KayentaServiceTest.kt | 3 +- .../pipeline/KayentaCanaryStageTest.kt | 353 +++++++++++++++ .../tasks/AggregateCanaryResultsTaskTest.kt | 106 +++++ orca-queue/orca-queue.gradle | 3 +- .../orca/q/handler/AbortStageHandlerTest.kt | 2 +- .../q/handler/CompleteStageHandlerTest.kt | 2 +- .../orca/q/handler/CompleteTaskHandlerTest.kt | 2 +- .../orca/q/handler/RestartStageHandlerTest.kt | 6 +- .../orca/q/handler/RunTaskHandlerTest.kt | 2 +- .../orca/q/handler/SkipStageHandlerTest.kt | 2 +- .../orca/q/handler/StartStageHandlerTest.kt | 2 +- .../orca/q/handler/StartTaskHandlerTest.kt | 2 +- .../orca/q/metrics/AtlasQueueMonitorTest.kt | 2 +- orca-test-kotlin/orca-test-kotlin.gradle | 2 + .../netflix/spinnaker/spek/SpekExtensions.kt | 78 ++++ .../kotlin/com/netflix/spinnaker/time/Time.kt | 12 +- 19 files changed, 560 insertions(+), 541 deletions(-) delete mode 100644 orca-kayenta/src/test/groovy/com/netflix/spinnaker/orca/kayenta/pipeline/KayentaCanaryStageSpec.groovy delete mode 100644 orca-kayenta/src/test/groovy/com/netflix/spinnaker/orca/kayenta/tasks/AggregateCanaryResultsTaskSpec.groovy create mode 100644 orca-kayenta/src/test/kotlin/com/netflix/spinnaker/orca/kayenta/pipeline/KayentaCanaryStageTest.kt create mode 100644 orca-kayenta/src/test/kotlin/com/netflix/spinnaker/orca/kayenta/tasks/AggregateCanaryResultsTaskTest.kt create mode 100644 orca-test-kotlin/src/main/kotlin/com/netflix/spinnaker/spek/SpekExtensions.kt rename orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/time/fixed_clock.kt => orca-test-kotlin/src/main/kotlin/com/netflix/spinnaker/time/Time.kt (77%) diff --git a/orca-kayenta/orca-kayenta.gradle b/orca-kayenta/orca-kayenta.gradle index 415c25ca72..dae78da5ac 100644 --- a/orca-kayenta/orca-kayenta.gradle +++ b/orca-kayenta/orca-kayenta.gradle @@ -16,13 +16,11 @@ apply from: "$rootDir/gradle/kotlin.gradle" apply from: "$rootDir/gradle/spek.gradle" -apply from: "$rootDir/gradle/spock.gradle" dependencies { compile project(":orca-kotlin") compile project(":orca-retrofit") - testCompile project(":orca-test-groovy") testCompile project(":orca-test-kotlin") testCompile "com.github.tomakehurst:wiremock:2.15.0" } diff --git a/orca-kayenta/src/test/groovy/com/netflix/spinnaker/orca/kayenta/pipeline/KayentaCanaryStageSpec.groovy b/orca-kayenta/src/test/groovy/com/netflix/spinnaker/orca/kayenta/pipeline/KayentaCanaryStageSpec.groovy deleted file mode 100644 index 72880c72ad..0000000000 --- a/orca-kayenta/src/test/groovy/com/netflix/spinnaker/orca/kayenta/pipeline/KayentaCanaryStageSpec.groovy +++ /dev/null @@ -1,427 +0,0 @@ -/* - * Copyright 2017 Google, 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.kayenta.pipeline - -import java.time.Clock -import java.time.Duration -import java.time.Instant -import java.time.ZoneId -import java.time.temporal.ChronoUnit -import com.netflix.spinnaker.orca.pipeline.WaitStage -import com.netflix.spinnaker.orca.pipeline.model.Stage -import spock.lang.Shared -import spock.lang.Specification -import spock.lang.Unroll -import static com.netflix.spinnaker.orca.test.model.ExecutionBuilder.stage - -class KayentaCanaryStageSpec extends Specification { - - @Shared - WaitStage waitStage = new WaitStage() - - @Unroll - def "should not include any interval wait stages if start/end times are explicitly specified"() { - given: - def kayentaCanaryStage = stage { - type = "kayentaCanary" - name = "Run Kayenta Canary" - context = [ - canaryConfig: [ - canaryConfigId : "MySampleStackdriverCanaryConfig", - scopes : [[ - controlScope : "myapp-v010", - experimentScope: "myapp-v021", - startTimeIso : "2017-01-01T01:02:34.567Z", - endTimeIso : "2017-01-01T05:02:34.567Z", - ]], - beginCanaryAnalysisAfterMins: beginCanaryAnalysisAfterMins - ] - ] - } - def builder = new KayentaCanaryStage( - Clock.systemUTC(), - waitStage - ) - - when: - def aroundStages = builder.aroundStages(kayentaCanaryStage) - - then: - aroundStages*.type == expectedStageTypes - - where: - beginCanaryAnalysisAfterMins || expectedStageTypes - null || ["runCanary"] - "" || ["runCanary"] - "0" || ["runCanary"] - "30" || ["wait", "runCanary"] - } - - @Unroll - def "should still handle canary intervals properly even if start/end times are explicitly specified"() { - given: - def kayentaCanaryStage = stage { - type = "kayentaCanary" - name = "Run Kayenta Canary" - context = [ - canaryConfig: [ - canaryConfigId : "MySampleStackdriverCanaryConfig", - scopes : [[ - controlScope : "myapp-v010", - experimentScope: "myapp-v021", - startTimeIso : "2017-01-01T01:02:34.567Z", - endTimeIso : "2017-01-01T05:02:34.567Z" - ]], - beginCanaryAnalysisAfterMins: beginCanaryAnalysisAfterMins, - canaryAnalysisIntervalMins : canaryAnalysisIntervalMins, - lookbackMins : lookbackMins - ] - ] - } - def startTimeInstant = Instant.parse("2017-01-01T01:02:34.567Z") - def builder = new KayentaCanaryStage( - Clock.fixed(startTimeInstant, ZoneId.systemDefault()), - waitStage - ) - - when: - def aroundStages = builder.aroundStages(kayentaCanaryStage) - def summary = collectSummary(aroundStages, startTimeInstant) - - then: - summary == stageSummary - - where: - beginCanaryAnalysisAfterMins | canaryAnalysisIntervalMins | lookbackMins || stageSummary - null | null | null || [[minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - null | "" | "" || [[minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - null | "0" | "0" || [[minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - null | "60" | null || [[minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 60, step: 60], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 120, step: 60], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 180, step: 60], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - "15" | null | "" || [[wait: 900], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - "15" | "" | "0" || [[wait: 900], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - "15" | "0" | null || [[wait: 900], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - "15" | "60" | "" || [[wait: 900], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 60, step: 60], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 120, step: 60], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 180, step: 60], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - null | null | "120" || [[minutesFromInitialStartToCanaryStart: 120, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - null | "" | "60" || [[minutesFromInitialStartToCanaryStart: 180, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - null | "0" | "60" || [[minutesFromInitialStartToCanaryStart: 180, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - null | "60" | "60" || [[minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 60, step: 60], - [minutesFromInitialStartToCanaryStart: 60, minutesFromInitialStartToCanaryEnd: 120, step: 60], - [minutesFromInitialStartToCanaryStart: 120, minutesFromInitialStartToCanaryEnd: 180, step: 60], - [minutesFromInitialStartToCanaryStart: 180, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - "15" | null | "120" || [[wait: 900], - [minutesFromInitialStartToCanaryStart: 120, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - "15" | "" | "60" || [[wait: 900], - [minutesFromInitialStartToCanaryStart: 180, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - "15" | "0" | "60" || [[wait: 900], - [minutesFromInitialStartToCanaryStart: 180, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - "15" | "60" | "60" || [[wait: 900], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 60, step: 60], - [minutesFromInitialStartToCanaryStart: 60, minutesFromInitialStartToCanaryEnd: 120, step: 60], - [minutesFromInitialStartToCanaryStart: 120, minutesFromInitialStartToCanaryEnd: 180, step: 60], - [minutesFromInitialStartToCanaryStart: 180, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - null | "300" | null || [[minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - } - - @Unroll - def "should use the first scope's time boundaries for all scopes"() { - given: - def kayentaCanaryStage = stage { - type = "kayentaCanary" - name = "Run Kayenta Canary" - context = [ - canaryConfig: [ - canaryConfigId: "MySampleStackdriverCanaryConfig", - scopes : [ - [ - scopeName : "default", - controlScope : "myapp-v010", - experimentScope: "myapp-v021", - startTimeIso : "2017-01-01T01:02:34.567Z", - endTimeIso : "2017-01-01T05:02:34.567Z" - ], - [ - scopeName : "otherScope", - controlScope : "myapp-v016", - experimentScope: "myapp-v028", - startTimeIso : "2017-02-03T04:02:34.567Z", - endTimeIso : "2017-02-03T08:02:34.567Z" - ], - [ - scopeName : "yetAnotherScope", - controlScope : "myapp-v023", - experimentScope: "myapp-v025", - startTimeIso : "2017-03-04T06:02:34.567Z", - endTimeIso : "2017-03-04T10:02:34.567Z" - ] - ] - ] - ] - } - def startTimeInstant = Instant.parse("2017-01-01T01:02:34.567Z") - def builder = new KayentaCanaryStage( - Clock.fixed(startTimeInstant, ZoneId.systemDefault()), - waitStage - ) - def aroundStages = builder.aroundStages(kayentaCanaryStage) - - when: - def summary = collectSummary(aroundStages, startTimeInstant, scopeName) - - then: - summary == [[minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: 240, step: 60]] - - where: - scopeName << ["default", "otherScope", "yetAnotherScope"] - } - - @Unroll - def "should start now and include warmupWait stage if necessary"() { - given: - def kayentaCanaryStage = stage { - type = "kayentaCanary" - name = "Run Kayenta Canary" - context = [ - canaryConfig: [ - canaryConfigId : "MySampleStackdriverCanaryConfig", - scopes : [[ - controlScope : "myapp-v010", - experimentScope: "myapp-v021" - ]], - lifetimeHours : "1", - beginCanaryAnalysisAfterMins: beginCanaryAnalysisAfterMins - ] - ] - } - def startTimeInstant = Instant.parse("2017-01-01T01:02:34.567Z") - def builder = new KayentaCanaryStage( - Clock.fixed(startTimeInstant, ZoneId.systemDefault()), - waitStage - ) - - when: - def aroundStages = builder.aroundStages(kayentaCanaryStage) - - then: - aroundStages*.type == expectedStageTypes - !warmupWaitPeriodMinutes || aroundStages[0].context.waitTime == Duration.ofMinutes(warmupWaitPeriodMinutes).getSeconds() - aroundStages.find { - it.type == "runCanary" - }.context.scopes.default.controlScope.start == startTimeInstant.plus(warmupWaitPeriodMinutes, ChronoUnit.MINUTES).toString() - - where: - beginCanaryAnalysisAfterMins || expectedStageTypes | warmupWaitPeriodMinutes - null || ["wait", "runCanary"] | 0 - "" || ["wait", "runCanary"] | 0 - "0" || ["wait", "runCanary"] | 0 - "30" || ["wait", "wait", "runCanary"] | 30 - } - - @Unroll - def "should start now and properly schedule canary pipelines respecting intervals"() { - given: - def kayentaCanaryStage = stage { - type = "kayentaCanary" - name = "Run Kayenta Canary" - context = [ - canaryConfig: [ - canaryConfigId : "MySampleStackdriverCanaryConfig", - scopes : [[ - controlScope : "myapp-v010", - experimentScope: "myapp-v021" - ]], - beginCanaryAnalysisAfterMins: beginCanaryAnalysisAfterMins, - canaryAnalysisIntervalMins : canaryAnalysisIntervalMins, - lookbackMins : lookbackMins, - lifetimeHours : "48" - ] - ] - } - def startTimeInstant = Instant.parse("2017-01-01T01:02:34.567Z") - def builder = new KayentaCanaryStage( - Clock.fixed(startTimeInstant, ZoneId.systemDefault()), - waitStage - ) - - when: - def aroundStages = builder.aroundStages(kayentaCanaryStage) - def summary = collectSummary(aroundStages, startTimeInstant) - - then: - summary == stageSummary - - where: - beginCanaryAnalysisAfterMins | canaryAnalysisIntervalMins | lookbackMins || stageSummary - null | null | null || [[wait: Duration.ofHours(48).getSeconds()], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: Duration.ofHours(48).toMinutes(), step: 60]] - null | "" | "" || [[wait: Duration.ofHours(48).getSeconds()], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: Duration.ofHours(48).toMinutes(), step: 60]] - null | "0" | "0" || [[wait: Duration.ofHours(48).getSeconds()], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: Duration.ofHours(48).toMinutes(), step: 60]] - null | Duration.ofHours(8).toMinutes() + "" | null || [[wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: Duration.ofHours(8).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: Duration.ofHours(16).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: Duration.ofHours(24).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: Duration.ofHours(32).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: Duration.ofHours(40).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: Duration.ofHours(48).toMinutes(), step: 60]] - "45" | null | "" || [[wait: Duration.ofMinutes(45).getSeconds()], - [wait: Duration.ofHours(48).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45, minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(48).toMinutes(), step: 60]] - "45" | "" | "0" || [[wait: Duration.ofMinutes(45).getSeconds()], - [wait: Duration.ofHours(48).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45, minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(48).toMinutes(), step: 60]] - "45" | "0" | null || [[wait: Duration.ofMinutes(45).getSeconds()], - [wait: Duration.ofHours(48).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45, minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(48).toMinutes(), step: 60]] - "45" | Duration.ofHours(8).toMinutes() + "" | "" || [[wait: Duration.ofMinutes(45).getSeconds()], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45, minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(8).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45, minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(16).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45, minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(24).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45, minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(32).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45, minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(40).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45, minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(48).toMinutes(), step: 60]] - null | null | "60" || [[wait: Duration.ofHours(48).getSeconds()], - [minutesFromInitialStartToCanaryStart: Duration.ofHours(47).toMinutes(), minutesFromInitialStartToCanaryEnd: Duration.ofHours(48).toMinutes(), step: 60]] - null | "" | "60" || [[wait: Duration.ofHours(48).getSeconds()], - [minutesFromInitialStartToCanaryStart: Duration.ofHours(47).toMinutes(), minutesFromInitialStartToCanaryEnd: Duration.ofHours(48).toMinutes(), step: 60]] - null | "0" | "60" || [[wait: Duration.ofHours(48).getSeconds()], - [minutesFromInitialStartToCanaryStart: Duration.ofHours(47).toMinutes(), minutesFromInitialStartToCanaryEnd: Duration.ofHours(48).toMinutes(), step: 60]] - null | Duration.ofHours(8).toMinutes() + "" | "60" || [[wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: Duration.ofHours(7).toMinutes(), minutesFromInitialStartToCanaryEnd: Duration.ofHours(8).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: Duration.ofHours(15).toMinutes(), minutesFromInitialStartToCanaryEnd: Duration.ofHours(16).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: Duration.ofHours(23).toMinutes(), minutesFromInitialStartToCanaryEnd: Duration.ofHours(24).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: Duration.ofHours(31).toMinutes(), minutesFromInitialStartToCanaryEnd: Duration.ofHours(32).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: Duration.ofHours(39).toMinutes(), minutesFromInitialStartToCanaryEnd: Duration.ofHours(40).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: Duration.ofHours(47).toMinutes(), minutesFromInitialStartToCanaryEnd: Duration.ofHours(48).toMinutes(), step: 60]] - "45" | null | "60" || [[wait: Duration.ofMinutes(45).getSeconds()], - [wait: Duration.ofHours(48).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45 + Duration.ofHours(47).toMinutes(), minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(48).toMinutes(), step: 60]] - "45" | "" | "60" || [[wait: Duration.ofMinutes(45).getSeconds()], - [wait: Duration.ofHours(48).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45 + Duration.ofHours(47).toMinutes(), minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(48).toMinutes(), step: 60]] - "45" | "0" | "60" || [[wait: Duration.ofMinutes(45).getSeconds()], - [wait: Duration.ofHours(48).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45 + Duration.ofHours(47).toMinutes(), minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(48).toMinutes(), step: 60]] - "45" | Duration.ofHours(8).toMinutes() + "" | "60" || [[wait: Duration.ofMinutes(45).getSeconds()], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45 + Duration.ofHours(7).toMinutes(), minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(8).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45 + Duration.ofHours(15).toMinutes(), minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(16).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45 + Duration.ofHours(23).toMinutes(), minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(24).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45 + Duration.ofHours(31).toMinutes(), minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(32).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45 + Duration.ofHours(39).toMinutes(), minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(40).toMinutes(), step: 60], - [wait: Duration.ofHours(8).getSeconds()], - [minutesFromInitialStartToCanaryStart: 45 + Duration.ofHours(47).toMinutes(), minutesFromInitialStartToCanaryEnd: 45 + Duration.ofHours(48).toMinutes(), step: 60]] - } - - def "should propagate additional attributes"() { - given: - def kayentaCanaryStage = stage { - type = "kayentaCanary" - name = "Run Kayenta Canary" - context = [ - canaryConfig: [ - metricsAccountName : "atlas-acct-1", - canaryConfigId : "MySampleAtlasCanaryConfig", - scopes : [[ - controlScope : "some.host.node", - experimentScope : "some.other.host.node", - step : 60, - extendedScopeParams: [type: "node"] - ]], - canaryAnalysisIntervalMins: Duration.ofHours(6).toMinutes(), - lifetimeHours : "12" - ] - ] - } - def startTimeInstant = Instant.parse("2017-01-01T01:02:34.567Z") - def builder = new KayentaCanaryStage( - Clock.fixed(startTimeInstant, ZoneId.systemDefault()), - waitStage - ) - - when: - def aroundStages = builder.aroundStages(kayentaCanaryStage) - def summary = collectSummary(aroundStages, startTimeInstant) - - then: - summary == [[wait: Duration.ofHours(6).getSeconds()], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: Duration.ofHours(6).toMinutes(), step: 60, metricsAccountName: "atlas-acct-1", extendedScopeParams: [type: "node"]], - [wait: Duration.ofHours(6).getSeconds()], - [minutesFromInitialStartToCanaryStart: 0, minutesFromInitialStartToCanaryEnd: Duration.ofHours(12).toMinutes(), step: 60, metricsAccountName: "atlas-acct-1", extendedScopeParams: [type: "node"]]] - } - - def collectSummary(List aroundStages, Instant startTimeInstant, String scopeName = "default") { - return aroundStages.collect { - if (it.type == waitStage.type) { - return [wait: it.context.waitTime] - } else if (it.type == RunCanaryPipelineStage.STAGE_TYPE) { - Instant runCanaryPipelineStartInstant = Instant.parse(it.context.scopes[scopeName].controlScope.start) - Instant runCanaryPipelineEndInstant = Instant.parse(it.context.scopes[scopeName].controlScope.end) - Map ret = [ - minutesFromInitialStartToCanaryStart: startTimeInstant.until(runCanaryPipelineStartInstant, ChronoUnit.MINUTES), - minutesFromInitialStartToCanaryEnd : startTimeInstant.until(runCanaryPipelineEndInstant, ChronoUnit.MINUTES) - ] - - if (it.context.metricsAccountName) { - ret.metricsAccountName = it.context.metricsAccountName - } - - if (it.context.scopes[scopeName].controlScope.step) { - ret.step = it.context.scopes[scopeName].controlScope.step.toLong() - } - - if (it.context.scopes[scopeName].controlScope.extendedScopeParams) { - ret.extendedScopeParams = it.context.scopes[scopeName].controlScope.extendedScopeParams - } - - return ret - } else { - throw new IllegalArgumentException("Encountered unexpected stage of type '$it.type'.") - } - } - } -} diff --git a/orca-kayenta/src/test/groovy/com/netflix/spinnaker/orca/kayenta/tasks/AggregateCanaryResultsTaskSpec.groovy b/orca-kayenta/src/test/groovy/com/netflix/spinnaker/orca/kayenta/tasks/AggregateCanaryResultsTaskSpec.groovy deleted file mode 100644 index 9303efe2b0..0000000000 --- a/orca-kayenta/src/test/groovy/com/netflix/spinnaker/orca/kayenta/tasks/AggregateCanaryResultsTaskSpec.groovy +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2017 Google, 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.kayenta.tasks - -import com.netflix.spinnaker.orca.ExecutionStatus -import com.netflix.spinnaker.orca.pipeline.model.Stage -import spock.lang.Shared -import spock.lang.Specification -import spock.lang.Unroll -import static com.netflix.spinnaker.orca.test.model.ExecutionBuilder.pipeline -import static com.netflix.spinnaker.orca.test.model.ExecutionBuilder.stage - -class AggregateCanaryResultsTaskSpec extends Specification { - - @Shared - def task = new AggregateCanaryResultsTask() - - @Unroll - def "All canary scores should be collected and overall execution status determined with consideration given to configured score thresholds"() { - given: - Stage canaryStage - pipeline { - contextCanaryScores.each { score -> - stage { - type = "runCanary" - name = "runCanary" - context = [canaryScore: score] - } - } - canaryStage = stage { - type = "kayentaCanary" - name = "kayentaCanary" - context = [ - canaryConfig: [ - canaryConfigId : UUID.randomUUID().toString(), - scoreThresholds: scoreThresholds - ] - ] - } - } - - when: - def taskResult = task.execute(canaryStage) - - then: - taskResult.context.canaryScores == outputCanaryScores - taskResult.status == overallExecutionStatus - - where: - contextCanaryScores | scoreThresholds || outputCanaryScores | overallExecutionStatus - [10.5, 40, 60.5] | [:] || [10.5, 40, 60.5] | ExecutionStatus.SUCCEEDED - [10.5, 40, 60.5] | [pass: 60.5] || [10.5, 40, 60.5] | ExecutionStatus.SUCCEEDED - [10.5, 40, 60.5] | [pass: 55] || [10.5, 40, 60.5] | ExecutionStatus.SUCCEEDED - [10.5, 40, 60.5] | [marginal: 5, pass: 60.5] || [10.5, 40, 60.5] | ExecutionStatus.SUCCEEDED - [10.5, 40, 60.5] | [marginal: 5, pass: 55] || [10.5, 40, 60.5] | ExecutionStatus.SUCCEEDED - [10.5, 40, 60.5] | [marginal: 5] || [10.5, 40, 60.5] | ExecutionStatus.SUCCEEDED - [10.5, 40, 60.5] | [marginal: 5] || [10.5, 40, 60.5] | ExecutionStatus.SUCCEEDED - [65] | [:] || [65] | ExecutionStatus.SUCCEEDED - [65] | [pass: 60.5] || [65] | ExecutionStatus.SUCCEEDED - [65] | [pass: 55] || [65] | ExecutionStatus.SUCCEEDED - [65] | [marginal: 5, pass: 60.5] || [65] | ExecutionStatus.SUCCEEDED - [65] | [marginal: 5, pass: 55] || [65] | ExecutionStatus.SUCCEEDED - [65] | [marginal: 5] || [65] | ExecutionStatus.SUCCEEDED - [65] | [marginal: 5] || [65] | ExecutionStatus.SUCCEEDED - [10.5, 40, 60.5] | [pass: 70] || [10.5, 40, 60.5] | ExecutionStatus.TERMINAL - [10.5, 40, 60.5] | [pass: 70] || [10.5, 40, 60.5] | ExecutionStatus.TERMINAL - [10.5, 40, 60.5] | [marginal: 5, pass: 70] || [10.5, 40, 60.5] | ExecutionStatus.TERMINAL - [10.5, 40, 60.5] | [marginal: 5, pass: 70] || [10.5, 40, 60.5] | ExecutionStatus.TERMINAL - [65] | [:] || [65] | ExecutionStatus.SUCCEEDED - [65] | [pass: 70] || [65] | ExecutionStatus.TERMINAL - [65] | [pass: 70] || [65] | ExecutionStatus.TERMINAL - [65] | [marginal: 5, pass: 70] || [65] | ExecutionStatus.TERMINAL - [65] | [marginal: 5, pass: 70] || [65] | ExecutionStatus.TERMINAL - [65] | [marginal: 68, pass: 70] || [65] | ExecutionStatus.TERMINAL - [65] | [marginal: 68, pass: 70] || [65] | ExecutionStatus.TERMINAL - [65] | [marginal: 68] || [65] | ExecutionStatus.TERMINAL - [65] | [marginal: 68] || [65] | ExecutionStatus.TERMINAL - } -} diff --git a/orca-kayenta/src/test/kotlin/com/netflix/spinnaker/orca/kayenta/KayentaServiceTest.kt b/orca-kayenta/src/test/kotlin/com/netflix/spinnaker/orca/kayenta/KayentaServiceTest.kt index e1f22a44cb..dac3da7c10 100644 --- a/orca-kayenta/src/test/kotlin/com/netflix/spinnaker/orca/kayenta/KayentaServiceTest.kt +++ b/orca-kayenta/src/test/kotlin/com/netflix/spinnaker/orca/kayenta/KayentaServiceTest.kt @@ -21,6 +21,7 @@ import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.WireMock.* import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options import com.netflix.spinnaker.orca.kayenta.config.KayentaConfiguration +import com.netflix.spinnaker.time.fixedClock import org.assertj.core.api.Assertions.assertThat import org.jetbrains.spek.api.Spek import org.jetbrains.spek.api.dsl.describe @@ -58,7 +59,7 @@ object KayentaServiceTest : Spek({ describe("the Kayenta service") { - val clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()) + val clock = fixedClock() val canaryConfigId = randomUUID().toString() val mapper = jacksonObjectMapper() diff --git a/orca-kayenta/src/test/kotlin/com/netflix/spinnaker/orca/kayenta/pipeline/KayentaCanaryStageTest.kt b/orca-kayenta/src/test/kotlin/com/netflix/spinnaker/orca/kayenta/pipeline/KayentaCanaryStageTest.kt new file mode 100644 index 0000000000..afb8cc1703 --- /dev/null +++ b/orca-kayenta/src/test/kotlin/com/netflix/spinnaker/orca/kayenta/pipeline/KayentaCanaryStageTest.kt @@ -0,0 +1,353 @@ +/* + * Copyright 2018 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.kayenta.pipeline + +import com.netflix.spinnaker.orca.ext.mapTo +import com.netflix.spinnaker.orca.fixture.stage +import com.netflix.spinnaker.orca.kayenta.CanaryScope +import com.netflix.spinnaker.orca.pipeline.WaitStage +import com.netflix.spinnaker.orca.pipeline.model.Stage +import com.netflix.spinnaker.spek.and +import com.netflix.spinnaker.spek.values +import com.netflix.spinnaker.spek.where +import com.netflix.spinnaker.time.fixedClock +import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.spek.api.Spek +import org.jetbrains.spek.api.dsl.describe +import org.jetbrains.spek.api.dsl.given +import org.jetbrains.spek.api.dsl.it +import java.time.Duration +import java.time.temporal.ChronoUnit.HOURS +import java.time.temporal.ChronoUnit.MINUTES + +object KayentaCanaryStageTest : Spek({ + + val waitStage = WaitStage() + val clock = fixedClock() + val builder = KayentaCanaryStage(clock, waitStage) + + describe("planning a canary stage") { + given("start/end times are specified") { + where( + "canary analysis should start after %s minutes", + values(null, listOf("runCanary")), + values("", listOf("runCanary")), + values("0", listOf("runCanary")), + values("30", listOf("wait", "runCanary")) + ) { beginCanaryAnalysisAfterMins, expectedStageTypes -> + val kayentaCanaryStage = stage { + type = "kayentaCanary" + name = "Run Kayenta Canary" + context["canaryConfig"] = mapOf( + "canaryConfigId" to "MySampleStackdriverCanaryConfig", + "scopes" to listOf(mapOf( + "controlScope" to "myapp-v010", + "experimentScope" to "myapp-v021", + "startTimeIso" to clock.instant().toString(), + "endTimeIso" to clock.instant().plus(4, HOURS).toString() + )), + "scoreThresholds" to mapOf("marginal" to 75, "pass" to 90), + "beginCanaryAnalysisAfterMins" to beginCanaryAnalysisAfterMins + ) + } + + val aroundStages = builder.aroundStages(kayentaCanaryStage) + + it("should not introduce wait stages") { + assertThat(aroundStages).extracting("type").isEqualTo(expectedStageTypes) + } + } + + where( + "canary should start after %s minutes with an interval of %s minutes and lookback of %s minutes", + values(null, null, null, CanaryRanges(0 to 240)), + values(null, "", "", CanaryRanges(0 to 240)), + values(null, "0", "0", CanaryRanges(0 to 240)), + values(null, "60", null, CanaryRanges(0 to 60, 0 to 120, 0 to 180, 0 to 240)), + values("15", null, "", CanaryRanges(0 to 240)), + values("15", "", "0", CanaryRanges(0 to 240)), + values("15", "0", null, CanaryRanges(0 to 240)), + values("15", "60", "", CanaryRanges(0 to 60, 0 to 120, 0 to 180, 0 to 240)), + values(null, null, "120", CanaryRanges(120 to 240)), + values(null, "", "60", CanaryRanges(180 to 240)), + values(null, "0", "60", CanaryRanges(180 to 240)), + values(null, "60", "60", CanaryRanges(0 to 60, 60 to 120, 120 to 180, 180 to 240)), + values("15", null, "120", CanaryRanges(120 to 240)), + values("15", "", "60", CanaryRanges(180 to 240)), + values("15", "0", "60", CanaryRanges(180 to 240)), + values("15", "60", "60", CanaryRanges(0 to 60, 60 to 120, 120 to 180, 180 to 240)), + values(null, "300", null, CanaryRanges(0 to 240)) + ) { warmupMins, intervalMins, lookbackMins, canaryRanges -> + val kayentaCanaryStage = stage { + type = "kayentaCanary" + name = "Run Kayenta Canary" + context["canaryConfig"] = mapOf( + "canaryConfigId" to "MySampleStackdriverCanaryConfig", + "scopes" to listOf(mapOf( + "controlScope" to "myapp-v010", + "experimentScope" to "myapp-v021", + "startTimeIso" to clock.instant().toString(), + "endTimeIso" to clock.instant().plus(4, HOURS).toString() + )), + "scoreThresholds" to mapOf("marginal" to 75, "pass" to 90), + "beginCanaryAnalysisAfterMins" to warmupMins, + "canaryAnalysisIntervalMins" to intervalMins, + "lookbackMins" to lookbackMins + ) + } + + val aroundStages = builder.aroundStages(kayentaCanaryStage) + + it("still handles canary intervals properly") { + aroundStages + .controlScopes() + .apply { + assertThat(map { clock.instant().until(it.start, MINUTES) }).isEqualTo(canaryRanges.startAtMin.map { it.toLong() }) + assertThat(map { clock.instant().until(it.end, MINUTES) }).isEqualTo(canaryRanges.endAtMin.map { it.toLong() }) + assertThat(map { it.step }).allMatch { it == Duration.ofMinutes(1).seconds } + } + } + + if (warmupMins != null) { + val expectedWarmupWait = warmupMins.toInt() + it("inserts a warmup wait stage of $expectedWarmupWait minutes") { + aroundStages + .filter { it.name == "Warmup Wait" } + .apply { + assertThat(this).hasSize(1) + assertThat(first().context["waitTime"]) + .isEqualTo(expectedWarmupWait.minutesInSeconds.toLong()) + } + } + } else { + it("does not insert a warmup wait stage") { + assertThat(aroundStages).noneMatch { it.name == "Warmup Wait" } + } + } + } + } + + given("start and end times are not specified") { + where( + "canary analysis should begin after %s minutes", + values(null, listOf("wait", "runCanary"), 0L), + values("", listOf("wait", "runCanary"), 0L), + values("0", listOf("wait", "runCanary"), 0L), + values("30", listOf("wait", "wait", "runCanary"), 30L) + ) { warmupMins, expectedStageTypes, expectedWarmupMins -> + and("canary analysis should begin after $warmupMins minutes") { + val kayentaCanaryStage = stage { + type = "kayentaCanary" + name = "Run Kayenta Canary" + context["canaryConfig"] = mapOf( + "canaryConfigId" to "MySampleStackdriverCanaryConfig", + "scopes" to listOf(mapOf( + "controlScope" to "myapp-v010", + "experimentScope" to "myapp-v021" + )), + "scoreThresholds" to mapOf("marginal" to 75, "pass" to 90), + "lifetimeHours" to "1", + "beginCanaryAnalysisAfterMins" to warmupMins + ) + } + + val builder = KayentaCanaryStage(clock, waitStage) + val aroundStages = builder.aroundStages(kayentaCanaryStage) + + it("should start now") { + assertThat(aroundStages).extracting("type").isEqualTo(expectedStageTypes) + assertThat(aroundStages.controlScopes().map { it.start }) + .allMatch { it == clock.instant().plus(expectedWarmupMins, MINUTES) } + } + + if (expectedWarmupMins > 0L) { + it("inserts a warmup wait stage of $expectedWarmupMins minutes") { + aroundStages + .first() + .apply { + assertThat(type).isEqualTo("wait") + assertThat(name).isEqualTo("Warmup Wait") + assertThat(context["waitTime"]).isEqualTo(expectedWarmupMins.minutesInSeconds) + } + } + } else { + it("does not insert a leading wait stage") { + assertThat(aroundStages.filter { it.name == "Warmup Wait" }).isEmpty() + } + } + } + } + + where( + "canary should start after %s minutes with an interval of %s minutes and lookback of %s minutes", + values(null, null, null, CanaryRanges(0 to 48.hoursInMinutes)), + values(null, "", "", CanaryRanges(0 to 48.hoursInMinutes)), + values(null, "0", "0", CanaryRanges(0 to 48.hoursInMinutes)), + values(null, "${8.hoursInMinutes}", null, CanaryRanges(0 to 8.hoursInMinutes, 0 to 16.hoursInMinutes, 0 to 24.hoursInMinutes, 0 to 32.hoursInMinutes, 0 to 40.hoursInMinutes, 0 to 48.hoursInMinutes)), + values("45", null, "", CanaryRanges(45 to 45 + 48.hoursInMinutes)), + values("45", "", "0", CanaryRanges(45 to 45 + 48.hoursInMinutes)), + values("45", "0", null, CanaryRanges(45 to 45 + 48.hoursInMinutes)), + values("45", "${8.hoursInMinutes}", "", CanaryRanges(45 to 45 + 8.hoursInMinutes, 45 to 45 + 16.hoursInMinutes, 45 to (45 + 24.hoursInMinutes), 45 to 45 + 32.hoursInMinutes, 45 to 45 + 40.hoursInMinutes, 45 to 45 + 48.hoursInMinutes)), + values(null, null, "60", CanaryRanges(47.hoursInMinutes to 48.hoursInMinutes)), + values(null, "", "60", CanaryRanges(47.hoursInMinutes to 48.hoursInMinutes)), + values(null, "0", "60", CanaryRanges(47.hoursInMinutes to 48.hoursInMinutes)), + values(null, "${8.hoursInMinutes}", "60", CanaryRanges(7.hoursInMinutes to 8.hoursInMinutes, 15.hoursInMinutes to 16.hoursInMinutes, 23.hoursInMinutes to 24.hoursInMinutes, 31.hoursInMinutes to 32.hoursInMinutes, 39.hoursInMinutes to 40.hoursInMinutes, 47.hoursInMinutes to 48.hoursInMinutes)), + values("45", null, "60", CanaryRanges(45 + 47.hoursInMinutes to 45 + 48.hoursInMinutes)), + values("45", "", "60", CanaryRanges(45 + 47.hoursInMinutes to 45 + 48.hoursInMinutes)), + values("45", "0", "60", CanaryRanges(45 + 47.hoursInMinutes to 45 + 48.hoursInMinutes)), + values("45", "${8.hoursInMinutes}", "60", CanaryRanges(45 + 7.hoursInMinutes to 45 + 8.hoursInMinutes, 45 + 15.hoursInMinutes to 45 + 16.hoursInMinutes, 45 + 23.hoursInMinutes to 45 + 24.hoursInMinutes, 45 + 31.hoursInMinutes to 45 + 32.hoursInMinutes, 45 + 39.hoursInMinutes to 45 + 40.hoursInMinutes, 45 + 47.hoursInMinutes to 45 + 48.hoursInMinutes)) + ) { warmupMins, intervalMins, lookbackMins, canaryRanges -> + + val canaryDuration = Duration.ofHours(48) + + val kayentaCanaryStage = stage { + type = "kayentaCanary" + name = "Run Kayenta Canary" + context["canaryConfig"] = mapOf( + "canaryConfigId" to "MySampleStackdriverCanaryConfig", + "scopes" to listOf(mapOf( + "controlScope" to "myapp-v010", + "experimentScope" to "myapp-v021" + )), + "scoreThresholds" to mapOf("marginal" to 75, "pass" to 90), + "beginCanaryAnalysisAfterMins" to warmupMins, + "canaryAnalysisIntervalMins" to intervalMins, + "lookbackMins" to lookbackMins, + "lifetimeHours" to canaryDuration.toHours().toString() + ) + } + + val aroundStages = builder.aroundStages(kayentaCanaryStage) + + if (warmupMins != null) { + val expectedWarmupWait = warmupMins.toInt() + it("inserts a warmup wait stage of $expectedWarmupWait minutes") { + aroundStages + .first() + .apply { + assertThat(type).isEqualTo("wait") + assertThat(name).isEqualTo("Warmup Wait") + assertThat(context["waitTime"]).isEqualTo(expectedWarmupWait.minutesInSeconds.toLong()) + } + } + } else { + it("does not insert a leading wait stage") { + assertThat(aroundStages.filter { it.name == "Warmup Wait" }).isEmpty() + } + } + + it("generates the correct ranges for each canary analysis phase") { + aroundStages.controlScopes() + .apply { + assertThat(map { clock.instant().until(it.start, MINUTES) }).isEqualTo(canaryRanges.startAtMin.map { it.toLong() }) + assertThat(map { clock.instant().until(it.end, MINUTES) }).isEqualTo(canaryRanges.endAtMin.map { it.toLong() }) + assertThat(map { it.step }).allMatch { it == Duration.ofMinutes(1).seconds } + } + } + + if (intervalMins != null && intervalMins.isNotEmpty() && intervalMins != "0") { + val expectedIntervalWait = intervalMins.toInt() + it("interleaves wait stages of $expectedIntervalWait minutes") { + aroundStages + .filter { it.name.matches(Regex("Interval Wait #\\d+")) } + .waitTimes() + .apply { + assertThat(this).hasSize(canaryRanges.startAtMin.size) + assertThat(this).allMatch { it == expectedIntervalWait.minutesInSeconds.toLong() } + } + } + } else { + val expectedIntervalWait = canaryDuration + it("adds a single wait stage of the entire canary duration (${expectedIntervalWait.toHours()} hours)") { + aroundStages + .filter { it.name.matches(Regex("Interval Wait #\\d+")) } + .waitTimes() + .apply { + assertThat(this).hasSize(1) + assertThat(first()).isEqualTo(expectedIntervalWait.seconds) + } + } + } + } + } + + given("additional canary attributes") { + val attributes = mapOf("type" to "node") + + val kayentaCanaryStage = stage { + type = "kayentaCanary" + name = "Run Kayenta Canary" + context["canaryConfig"] = mapOf( + "metricsAccountName" to "atlas-acct-1", + "canaryConfigId" to "MySampleAtlasCanaryConfig", + "scopes" to listOf(mapOf( + "controlScope" to "some.host.node", + "experimentScope" to "some.other.host.node", + "step" to 60, + "extendedScopeParams" to attributes + )), + "scoreThresholds" to mapOf("marginal" to 75, "pass" to 90), + "canaryAnalysisIntervalMins" to 6.hoursInMinutes, + "lifetimeHours" to "12" + ) + } + + val aroundStages = builder.aroundStages(kayentaCanaryStage) + + it("propagates the additional attributes") { + assertThat(aroundStages.controlScopes()) + .extracting("extendedScopeParams") + .allMatch { it == attributes } + } + } + } +}) + +data class CanaryRanges( + val startAtMin: List, + val endAtMin: List +) { + constructor(vararg range: Pair) : + this(range.map { it.first }, range.map { it.second }) +} + +/** + * Get [scopeName] control scope from all [RunCanaryPipelineStage]s. + */ +fun List.controlScopes(scopeName: String = "default"): List = + filter { it.type == RunCanaryPipelineStage.STAGE_TYPE } + .map { it.mapTo("/scopes/$scopeName/controlScope") } + +/** + * Get wait time from any [WaitStage]s. + */ +fun List.waitTimes(): List = + filter { it.type == "wait" } + .map { it.mapTo("/waitTime") } + +val Int.hoursInMinutes: Int + get() = Duration.ofHours(this.toLong()).toMinutes().toInt() + +val Int.hoursInSeconds: Int + get() = Duration.ofHours(this.toLong()).seconds.toInt() + +val Int.minutesInSeconds: Int + get() = Duration.ofMinutes(this.toLong()).seconds.toInt() + +val Long.minutesInSeconds: Long + get() = Duration.ofMinutes(this).seconds + diff --git a/orca-kayenta/src/test/kotlin/com/netflix/spinnaker/orca/kayenta/tasks/AggregateCanaryResultsTaskTest.kt b/orca-kayenta/src/test/kotlin/com/netflix/spinnaker/orca/kayenta/tasks/AggregateCanaryResultsTaskTest.kt new file mode 100644 index 0000000000..0ac9fae8b9 --- /dev/null +++ b/orca-kayenta/src/test/kotlin/com/netflix/spinnaker/orca/kayenta/tasks/AggregateCanaryResultsTaskTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2018 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.kayenta.tasks + +import com.netflix.spinnaker.orca.ExecutionStatus.SUCCEEDED +import com.netflix.spinnaker.orca.ExecutionStatus.TERMINAL +import com.netflix.spinnaker.orca.fixture.pipeline +import com.netflix.spinnaker.orca.fixture.stage +import com.netflix.spinnaker.orca.kayenta.pipeline.KayentaCanaryStage +import com.netflix.spinnaker.orca.kayenta.pipeline.RunCanaryPipelineStage +import com.netflix.spinnaker.spek.values +import com.netflix.spinnaker.spek.where +import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.spek.api.Spek +import org.jetbrains.spek.api.dsl.describe +import org.jetbrains.spek.api.dsl.it +import java.util.* + +object AggregateCanaryResultsTaskSpec : Spek({ + + val task = AggregateCanaryResultsTask() + + describe("aggregating canary scores") { + where( + "canary scores of %s and thresholds of %s", + values(listOf(10.5, 40.0, 60.5), emptyMap(), SUCCEEDED), + values(listOf(10.5, 40.0, 60.5), mapOf("pass" to 60.5), SUCCEEDED), + values(listOf(10.5, 40.0, 60.5), mapOf("pass" to 55), SUCCEEDED), + values(listOf(10.5, 40.0, 60.5), mapOf("marginal" to 5, "pass" to 60.5), SUCCEEDED), + values(listOf(10.5, 40.0, 60.5), mapOf("marginal" to 5, "pass" to 55), SUCCEEDED), + values(listOf(10.5, 40.0, 60.5), mapOf("marginal" to 5), SUCCEEDED), + values(listOf(10.5, 40.0, 60.5), mapOf("marginal" to 5), SUCCEEDED), + values(listOf(65.0), emptyMap(), SUCCEEDED), + values(listOf(65.0), mapOf("pass" to 60.5), SUCCEEDED), + values(listOf(65.0), mapOf("pass" to 55), SUCCEEDED), + values(listOf(65.0), mapOf("marginal" to 5, "pass" to 60.5), SUCCEEDED), + values(listOf(65.0), mapOf("marginal" to 5, "pass" to 55), SUCCEEDED), + values(listOf(65.0), mapOf("marginal" to 5), SUCCEEDED), + values(listOf(65.0), mapOf("marginal" to 5), SUCCEEDED), + values(listOf(10.5, 40.0, 60.5), mapOf("pass" to 70), TERMINAL), + values(listOf(10.5, 40.0, 60.5), mapOf("pass" to 70), TERMINAL), + values(listOf(10.5, 40.0, 60.5), mapOf("marginal" to 5, "pass" to 70), TERMINAL), + values(listOf(10.5, 40.0, 60.5), mapOf("marginal" to 5, "pass" to 70), TERMINAL), + values(listOf(65.0), emptyMap(), SUCCEEDED), + values(listOf(65.0), mapOf("pass" to 70), TERMINAL), + values(listOf(65.0), mapOf("pass" to 70), TERMINAL), + values(listOf(65.0), mapOf("marginal" to 5, "pass" to 70), TERMINAL), + values(listOf(65.0), mapOf("marginal" to 5, "pass" to 70), TERMINAL), + values(listOf(65.0), mapOf("marginal" to 68, "pass" to 70), TERMINAL), + values(listOf(65.0), mapOf("marginal" to 68, "pass" to 70), TERMINAL), + values(listOf(65.0), mapOf("marginal" to 68), TERMINAL), + values(listOf(65.0), mapOf("marginal" to 68), TERMINAL) + ) { canaryScores, scoreThresholds, overallExecutionStatus -> + + val pipeline = pipeline { + canaryScores.forEachIndexed { i, score -> + stage { + refId = "1<$i" + type = RunCanaryPipelineStage.STAGE_TYPE + name = "runCanary" + context["canaryScore"] = score + } + } + stage { + refId = "1" + type = KayentaCanaryStage.STAGE_TYPE + name = "kayentaCanary" + context["canaryConfig"] = mapOf( + "canaryConfigId" to UUID.randomUUID().toString(), + "scopes" to listOf(mapOf( + "controlScope" to "myapp-v010", + "experimentScope" to "myapp-v021" + )), + "scoreThresholds" to scoreThresholds, + "lifetimeHours" to "1" + ) + } + } + val canaryStage = pipeline.stageByRef("1") + + val taskResult = task.execute(canaryStage) + + it("stores the aggregated scores") { + assertThat(taskResult.context["canaryScores"]).isEqualTo(canaryScores) + } + + it("returns a status of $overallExecutionStatus") { + assertThat(taskResult.status).isEqualTo(overallExecutionStatus) + } + } + } +}) diff --git a/orca-queue/orca-queue.gradle b/orca-queue/orca-queue.gradle index 696bdc3200..d6f2b20ad5 100644 --- a/orca-queue/orca-queue.gradle +++ b/orca-queue/orca-queue.gradle @@ -22,9 +22,10 @@ dependencies { compile project(":orca-kotlin") compile "com.netflix.spinnaker.keiko:keiko-spring:$keikoVersion" compile "org.threeten:threeten-extra:1.0" - compile "org.funktionale:funktionale-partials:1.1" + compile "org.funktionale:funktionale-partials:1.2" compile spinnaker.dependency("logstashEncoder") compile "com.netflix.spinnaker.keiko:keiko-spring:$keikoVersion" + testCompile project(":orca-test-kotlin") testCompile project(":orca-queue-tck") } diff --git a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/AbortStageHandlerTest.kt b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/AbortStageHandlerTest.kt index 23ef567611..5ba0a2414a 100644 --- a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/AbortStageHandlerTest.kt +++ b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/AbortStageHandlerTest.kt @@ -27,9 +27,9 @@ import com.netflix.spinnaker.orca.q.AbortStage import com.netflix.spinnaker.orca.q.CancelStage import com.netflix.spinnaker.orca.q.CompleteExecution import com.netflix.spinnaker.orca.q.CompleteStage -import com.netflix.spinnaker.orca.time.fixedClock import com.netflix.spinnaker.orca.time.toInstant import com.netflix.spinnaker.q.Queue +import com.netflix.spinnaker.time.fixedClock import com.nhaarman.mockito_kotlin.* import org.assertj.core.api.Assertions.assertThat import org.jetbrains.spek.api.dsl.describe diff --git a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/CompleteStageHandlerTest.kt b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/CompleteStageHandlerTest.kt index 6c05e8f5ff..41ef65583b 100644 --- a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/CompleteStageHandlerTest.kt +++ b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/CompleteStageHandlerTest.kt @@ -33,9 +33,9 @@ import com.netflix.spinnaker.orca.pipeline.model.SyntheticStageOwner.STAGE_BEFOR import com.netflix.spinnaker.orca.pipeline.persistence.ExecutionRepository import com.netflix.spinnaker.orca.pipeline.util.ContextParameterProcessor import com.netflix.spinnaker.orca.q.* -import com.netflix.spinnaker.orca.time.fixedClock import com.netflix.spinnaker.q.Queue import com.netflix.spinnaker.spek.and +import com.netflix.spinnaker.time.fixedClock import com.nhaarman.mockito_kotlin.* import org.assertj.core.api.Assertions.assertThat import org.jetbrains.spek.api.dsl.* diff --git a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/CompleteTaskHandlerTest.kt b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/CompleteTaskHandlerTest.kt index 46b3ab124f..6a9e867d35 100644 --- a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/CompleteTaskHandlerTest.kt +++ b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/CompleteTaskHandlerTest.kt @@ -25,9 +25,9 @@ import com.netflix.spinnaker.orca.pipeline.model.Task import com.netflix.spinnaker.orca.pipeline.persistence.ExecutionRepository import com.netflix.spinnaker.orca.pipeline.util.ContextParameterProcessor import com.netflix.spinnaker.orca.q.* -import com.netflix.spinnaker.orca.time.fixedClock import com.netflix.spinnaker.q.Queue import com.netflix.spinnaker.spek.and +import com.netflix.spinnaker.time.fixedClock import com.nhaarman.mockito_kotlin.* import org.assertj.core.api.Assertions.assertThat import org.jetbrains.spek.api.dsl.describe diff --git a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/RestartStageHandlerTest.kt b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/RestartStageHandlerTest.kt index 1d53700f68..bb50551f67 100644 --- a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/RestartStageHandlerTest.kt +++ b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/RestartStageHandlerTest.kt @@ -27,15 +27,13 @@ import com.netflix.spinnaker.orca.pipeline.model.Stage import com.netflix.spinnaker.orca.pipeline.persistence.ExecutionRepository import com.netflix.spinnaker.orca.q.* import com.netflix.spinnaker.q.Queue +import com.netflix.spinnaker.time.fixedClock import com.nhaarman.mockito_kotlin.* import org.assertj.core.api.Assertions.assertThat import org.jetbrains.spek.api.dsl.describe import org.jetbrains.spek.api.dsl.it import org.jetbrains.spek.api.lifecycle.CachingMode.GROUP import org.jetbrains.spek.subject.SubjectSpek -import java.time.Clock.fixed -import java.time.Instant.now -import java.time.ZoneId.systemDefault import java.time.temporal.ChronoUnit.HOURS import java.time.temporal.ChronoUnit.MINUTES @@ -43,7 +41,7 @@ object RestartStageHandlerTest : SubjectSpek({ val queue: Queue = mock() val repository: ExecutionRepository = mock() - val clock = fixed(now(), systemDefault()) + val clock = fixedClock() subject(GROUP) { RestartStageHandler( diff --git a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/RunTaskHandlerTest.kt b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/RunTaskHandlerTest.kt index dccfce0ca7..0e935b0297 100644 --- a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/RunTaskHandlerTest.kt +++ b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/RunTaskHandlerTest.kt @@ -30,9 +30,9 @@ import com.netflix.spinnaker.orca.pipeline.persistence.ExecutionRepository import com.netflix.spinnaker.orca.pipeline.util.ContextParameterProcessor import com.netflix.spinnaker.orca.pipeline.util.StageNavigator import com.netflix.spinnaker.orca.q.* -import com.netflix.spinnaker.orca.time.fixedClock import com.netflix.spinnaker.q.Queue import com.netflix.spinnaker.spek.and +import com.netflix.spinnaker.time.fixedClock import com.nhaarman.mockito_kotlin.* import org.assertj.core.api.Assertions.assertThat import org.jetbrains.spek.api.dsl.describe diff --git a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/SkipStageHandlerTest.kt b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/SkipStageHandlerTest.kt index 8542edf718..630fff7b3d 100644 --- a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/SkipStageHandlerTest.kt +++ b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/SkipStageHandlerTest.kt @@ -23,8 +23,8 @@ import com.netflix.spinnaker.orca.fixture.stage import com.netflix.spinnaker.orca.pipeline.model.Execution.ExecutionType.PIPELINE import com.netflix.spinnaker.orca.pipeline.persistence.ExecutionRepository import com.netflix.spinnaker.orca.q.* -import com.netflix.spinnaker.orca.time.fixedClock import com.netflix.spinnaker.q.Queue +import com.netflix.spinnaker.time.fixedClock import com.nhaarman.mockito_kotlin.* import org.assertj.core.api.Assertions.assertThat import org.jetbrains.spek.api.dsl.describe diff --git a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/StartStageHandlerTest.kt b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/StartStageHandlerTest.kt index 4644285793..7c9a232b67 100644 --- a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/StartStageHandlerTest.kt +++ b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/StartStageHandlerTest.kt @@ -35,9 +35,9 @@ import com.netflix.spinnaker.orca.pipeline.persistence.ExecutionRepository import com.netflix.spinnaker.orca.pipeline.util.ContextParameterProcessor import com.netflix.spinnaker.orca.pipeline.util.StageNavigator import com.netflix.spinnaker.orca.q.* -import com.netflix.spinnaker.orca.time.fixedClock import com.netflix.spinnaker.q.Queue import com.netflix.spinnaker.spek.and +import com.netflix.spinnaker.time.fixedClock import com.nhaarman.mockito_kotlin.* import org.assertj.core.api.Assertions.assertThat import org.jetbrains.spek.api.dsl.* diff --git a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/StartTaskHandlerTest.kt b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/StartTaskHandlerTest.kt index 57f9a292f4..ae294065b0 100644 --- a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/StartTaskHandlerTest.kt +++ b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/handler/StartTaskHandlerTest.kt @@ -24,8 +24,8 @@ import com.netflix.spinnaker.orca.pipeline.model.Execution.ExecutionType.PIPELIN import com.netflix.spinnaker.orca.pipeline.persistence.ExecutionRepository import com.netflix.spinnaker.orca.pipeline.util.ContextParameterProcessor import com.netflix.spinnaker.orca.q.* -import com.netflix.spinnaker.orca.time.fixedClock import com.netflix.spinnaker.q.Queue +import com.netflix.spinnaker.time.fixedClock import com.nhaarman.mockito_kotlin.* import org.assertj.core.api.Assertions.assertThat import org.jetbrains.spek.api.dsl.describe diff --git a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/metrics/AtlasQueueMonitorTest.kt b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/metrics/AtlasQueueMonitorTest.kt index fa278a1832..7f7f5b8dde 100644 --- a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/metrics/AtlasQueueMonitorTest.kt +++ b/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/q/metrics/AtlasQueueMonitorTest.kt @@ -21,8 +21,8 @@ import com.netflix.spectator.api.Registry import com.netflix.spectator.api.Timer import com.netflix.spinnaker.orca.pipeline.model.Execution.ExecutionType.PIPELINE import com.netflix.spinnaker.orca.q.StartExecution -import com.netflix.spinnaker.orca.time.fixedClock import com.netflix.spinnaker.q.metrics.* +import com.netflix.spinnaker.time.fixedClock import com.nhaarman.mockito_kotlin.* import org.assertj.core.api.Assertions.assertThat import org.jetbrains.spek.api.dsl.describe diff --git a/orca-test-kotlin/orca-test-kotlin.gradle b/orca-test-kotlin/orca-test-kotlin.gradle index 3c189f010a..ec135def42 100644 --- a/orca-test-kotlin/orca-test-kotlin.gradle +++ b/orca-test-kotlin/orca-test-kotlin.gradle @@ -18,5 +18,7 @@ apply from: "$rootDir/gradle/kotlin.gradle" dependencies { compile project(":orca-core") + compile "org.funktionale:funktionale-partials:1.2" + compile "org.jetbrains.spek:spek-api:$spekVersion" compile "org.assertj:assertj-core:3.9.0" } diff --git a/orca-test-kotlin/src/main/kotlin/com/netflix/spinnaker/spek/SpekExtensions.kt b/orca-test-kotlin/src/main/kotlin/com/netflix/spinnaker/spek/SpekExtensions.kt new file mode 100644 index 0000000000..45e96371b0 --- /dev/null +++ b/orca-test-kotlin/src/main/kotlin/com/netflix/spinnaker/spek/SpekExtensions.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2018 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.spek + +import org.funktionale.partials.partially2 +import org.jetbrains.spek.api.dsl.SpecBody + +/** + * Grammar for nesting inside [given]. + */ +fun SpecBody.and(description: String, body: SpecBody.() -> Unit) { + group("and $description", body = body) +} + +fun SpecBody.where(description: String, vararg parameterSets: Single, block: SpecBody.(A) -> Unit) { + parameterSets.forEach { + val body = block.partially2(it.first) + group(com.netflix.spinnaker.spek.describe(description, it.first), body = body) + } +} + +fun SpecBody.where(description: String, vararg parameterSets: Pair, block: SpecBody.(A, B) -> Unit) { + parameterSets.forEach { + val body = block.partially2(it.first).partially2(it.second) + group(com.netflix.spinnaker.spek.describe(description, it.first, it.second), body = body) + } +} + +fun SpecBody.where(description: String, vararg parameterSets: Triple, block: SpecBody.(A, B, C) -> Unit) { + parameterSets.forEach { + val body = block.partially2(it.first).partially2(it.second).partially2(it.third) + group(com.netflix.spinnaker.spek.describe(description, it.first, it.second, it.third), body = body) + } +} + +fun SpecBody.where(description: String, vararg parameterSets: Quad, block: SpecBody.(A, B, C, D) -> Unit) { + parameterSets.forEach { + val body = block.partially2(it.first).partially2(it.second).partially2(it.third).partially2(it.fourth) + group(com.netflix.spinnaker.spek.describe(description, it.first, it.second, it.third, it.fourth), body = body) + } +} + +private fun describe(description: String, vararg arguments: Any?) = + String.format("where $description", *arguments) + +data class Single(val first: A) { + override fun toString() = "($first)" +} + +data class Quad(val first: A, val second: B, val third: C, val fourth: D) { + override fun toString() = "($first, $second, $third, $fourth)" +} + +fun values(first: A) = + Single(first) + +fun values(first: A, second: B) = + Pair(first, second) + +fun values(first: A, second: B, third: C) = + Triple(first, second, third) + +fun values(first: A, second: B, third: C, fourth: D) = + Quad(first, second, third, fourth) diff --git a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/time/fixed_clock.kt b/orca-test-kotlin/src/main/kotlin/com/netflix/spinnaker/time/Time.kt similarity index 77% rename from orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/time/fixed_clock.kt rename to orca-test-kotlin/src/main/kotlin/com/netflix/spinnaker/time/Time.kt index 5807c35aa6..2ac8db301a 100644 --- a/orca-queue/src/test/kotlin/com/netflix/spinnaker/orca/time/fixed_clock.kt +++ b/orca-test-kotlin/src/main/kotlin/com/netflix/spinnaker/time/Time.kt @@ -1,5 +1,5 @@ /* - * Copyright 2017 Netflix, Inc. + * Copyright 2018 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License") * you may not use this file except in compliance with the License. @@ -14,14 +14,16 @@ * limitations under the License. */ -package com.netflix.spinnaker.orca.time +package com.netflix.spinnaker.time import java.time.Clock import java.time.Instant import java.time.ZoneId +/** + * [Clock#fixed] but with the typical defaults. + */ fun fixedClock( instant: Instant = Instant.now(), - zone: ZoneId = ZoneId.systemDefault() -): Clock = - Clock.fixed(instant, zone) + zoneId: ZoneId = ZoneId.systemDefault() +): Clock = Clock.fixed(instant, zoneId)