From 830bc774a6bfc478a6d836c303dbaf3cba121248 Mon Sep 17 00:00:00 2001 From: sanopsmx Date: Wed, 27 Apr 2022 20:48:34 +0530 Subject: [PATCH] Added code for create server group operation for cloud run. --- .../cloudrun/CloudrunCloudProvider.groovy | 2 +- .../cloudrun/CloudrunJobExecutor.groovy | 42 ++ .../cloudrun/CloudrunOperation.groovy | 2 +- .../clouddriver/cloudrun/cache/Keys.groovy | 137 ++++++ .../CloudrunServerGroupNameResolver.groovy | 75 +++ .../cloudrun/deploy/CloudrunUtils.groovy | 83 ++++ ...udrunAtomicOperationConverterHelper.groovy | 47 ++ ...loyCloudrunAtomicOperationConverter.groovy | 74 +++ ...tractCloudrunCredentialsDescription.groovy | 26 ++ .../DeployCloudrunDescription.groovy | 46 ++ ...sertCloudrunLoadBalancerDescription.groovy | 39 ++ ...udrunDescriptionConversionException.groovy | 22 + .../ops/DeployCloudrunAtomicOperation.groovy | 346 ++++++++++++++ .../CloudrunGcsRepositoryClient.groovy | 101 ++++ .../CloudrunGitCredentialType.groovy | 24 + .../gitClient/CloudrunGitCredentials.groovy | 122 +++++ .../CloudrunGitRepositoryClient.groovy | 81 ++++ .../health/CloudrunHealthIndicator.groovy | 81 ++++ .../cloudrun/model/CloudrunHealth.groovy | 44 ++ .../cloudrun/model/CloudrunInstance.groovy | 89 ++++ .../model/CloudrunLoadBalancer.groovy | 105 +++++ .../cloudrun/model/CloudrunModelUtil.groovy | 96 ++++ .../model/CloudrunPlatformApplication.groovy | 53 +++ .../model/CloudrunRepositoryClient.groovy | 22 + .../model/CloudrunScalingPolicy.groovy | 137 ++++++ .../cloudrun/model/CloudrunServerGroup.groovy | 185 ++++++++ .../cloudrun/provider/CloudrunProvider.groovy | 52 +++ .../agent/AbstractCloudrunCachingAgent.groovy | 100 ++++ .../CloudrunLoadBalancerCachingAgent.groovy | 245 ++++++++++ ...drunPlatformApplicationCachingAgent.groovy | 72 +++ .../CloudrunServerGroupCachingAgent.groovy | 439 ++++++++++++++++++ .../callbacks/CloudrunCallback.groovy | 54 +++ .../provider/view/MutableCacheData.groovy | 45 ++ .../CloudrunNamedAccountCredentials.groovy | 301 ++++++++++++ .../config/CloudrunConfiguration.groovy | 70 +++ .../cloudrun/artifacts/ArtifactUtils.java | 83 ++++ .../cloudrun/artifacts/GcsStorageService.java | 182 ++++++++ .../StorageConfigurationProperties.java | 52 +++ .../CloudrunConfigurationProperties.java | 130 ++++++ .../CloudrunCredentialsConfiguration.java | 119 +++++ .../deploy/CloudrunMutexRepository.java | 37 ++ .../exception/CloudrunOperationException.java | 26 ++ .../security/CloudrunCredentials.java | 46 ++ .../CloudrunCredentialsLifecycleHandler.java | 66 +++ .../security/CloudrunJsonCredentials.java | 40 ++ 45 files changed, 4238 insertions(+), 2 deletions(-) create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/CloudrunJobExecutor.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/cache/Keys.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/CloudrunServerGroupNameResolver.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/CloudrunUtils.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/converters/CloudrunAtomicOperationConverterHelper.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/converters/DeployCloudrunAtomicOperationConverter.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/description/AbstractCloudrunCredentialsDescription.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/description/DeployCloudrunDescription.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/description/UpsertCloudrunLoadBalancerDescription.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/exception/CloudrunDescriptionConversionException.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/ops/DeployCloudrunAtomicOperation.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gcsClient/CloudrunGcsRepositoryClient.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gitClient/CloudrunGitCredentialType.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gitClient/CloudrunGitCredentials.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gitClient/CloudrunGitRepositoryClient.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/health/CloudrunHealthIndicator.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunHealth.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunInstance.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunLoadBalancer.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunModelUtil.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunPlatformApplication.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunRepositoryClient.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunScalingPolicy.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunServerGroup.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/CloudrunProvider.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/AbstractCloudrunCachingAgent.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/CloudrunLoadBalancerCachingAgent.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/CloudrunPlatformApplicationCachingAgent.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/CloudrunServerGroupCachingAgent.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/callbacks/CloudrunCallback.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/view/MutableCacheData.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunNamedAccountCredentials.groovy create mode 100644 clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/config/CloudrunConfiguration.groovy create mode 100644 clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/artifacts/ArtifactUtils.java create mode 100644 clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/artifacts/GcsStorageService.java create mode 100644 clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/artifacts/config/StorageConfigurationProperties.java create mode 100644 clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/config/CloudrunConfigurationProperties.java create mode 100644 clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/config/CloudrunCredentialsConfiguration.java create mode 100644 clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/deploy/CloudrunMutexRepository.java create mode 100644 clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/deploy/exception/CloudrunOperationException.java create mode 100644 clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunCredentials.java create mode 100644 clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunCredentialsLifecycleHandler.java create mode 100644 clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunJsonCredentials.java diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/CloudrunCloudProvider.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/CloudrunCloudProvider.groovy index f053a7d1375..a948f682e7b 100644 --- a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/CloudrunCloudProvider.groovy +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/CloudrunCloudProvider.groovy @@ -1,5 +1,5 @@ /* - * Copyright 2016 Google, Inc. + * Copyright 2022 OpsMx Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/CloudrunJobExecutor.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/CloudrunJobExecutor.groovy new file mode 100644 index 00000000000..d05e3a65f44 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/CloudrunJobExecutor.groovy @@ -0,0 +1,42 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun + +import com.netflix.spinnaker.clouddriver.jobs.JobExecutor +import com.netflix.spinnaker.clouddriver.jobs.JobRequest +import com.netflix.spinnaker.clouddriver.jobs.JobResult +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +@Component +class CloudrunJobExecutor { + @Value('${cloudrun.job-sleep-ms:1000}') + Long sleepMs + + @Autowired + JobExecutor jobExecutor + + void runCommand(List command) { + JobResult jobStatus = jobExecutor.runJob(new JobRequest(command)) + if (jobStatus.getResult() == JobResult.Result.FAILURE) { + String stdOut = jobStatus.getOutput() + String stdErr = jobStatus.getError() + throw new IllegalArgumentException("stdout: $stdOut, stderr: $stdErr") + } + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/CloudrunOperation.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/CloudrunOperation.groovy index a54963c06bb..9e2bb5edf19 100644 --- a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/CloudrunOperation.groovy +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/CloudrunOperation.groovy @@ -1,5 +1,5 @@ /* - * Copyright 2016 Google, Inc. + * Copyright 2022 OpsMx Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/cache/Keys.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/cache/Keys.groovy new file mode 100644 index 00000000000..3a7184039e0 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/cache/Keys.groovy @@ -0,0 +1,137 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.cache + +import com.netflix.frigga.Names +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunCloudProvider +import groovy.util.logging.Slf4j + +@Slf4j +class Keys { + static enum Namespace { + APPLICATIONS, + PLATFORM_APPLICATIONS, + CLUSTERS, + SERVER_GROUPS, + INSTANCES, + LOAD_BALANCERS, + ON_DEMAND + + static String provider = CloudrunCloudProvider.ID + + final String ns + + private Namespace() { + def parts = name().split('_') + + ns = parts.tail().inject(new StringBuilder(parts.head().toLowerCase())) { val, next -> + val.append(next.charAt(0)).append(next.substring(1).toLowerCase()) + } + } + + String toString() { + ns + } + } + + static Map parse(String key) { + def parts = key.split(':') + + if (parts.length < 2 || parts[0] != CloudrunCloudProvider.ID) { + return null + } + + def result = [provider: parts[0], type: parts[1]] + + switch (result.type) { + case Namespace.APPLICATIONS.ns: + result << [application: parts[2]] + break + case Namespace.PLATFORM_APPLICATIONS.ns: + result << [project: parts[2]] + break + case Namespace.CLUSTERS.ns: + def names = Names.parseName(parts[4]) + result << [ + account: parts[2], + application: parts[3], + name: parts[4], + cluster: parts[4], + stack: names.stack, + detail: names.detail + ] + break + case Namespace.INSTANCES.ns: + result << [ + account: parts[2], + name: parts[3], + instance: parts[3] + ] + break + case Namespace.LOAD_BALANCERS.ns: + result << [ + account: parts[2], + name: parts[3], + loadBalancer: parts[3] + ] + break + case Namespace.SERVER_GROUPS.ns: + def names = Names.parseName(parts[5]) + result << [ + application: names.app, + cluster: parts[2], + account: parts[3], + region: parts[4], + stack: names.stack, + detail: names.detail, + serverGroup: parts[5], + name: parts[5], + sequence: names.sequence as String, + ] + break + default: + return null + break + } + + result + } + + static String getApplicationKey(String application) { + "$CloudrunCloudProvider.ID:${Namespace.APPLICATIONS}:${application}" + } + + static String getPlatformApplicationKey(String project) { + "$CloudrunCloudProvider.ID:${Namespace.PLATFORM_APPLICATIONS}:${project}" + } + + static String getClusterKey(String account, String application, String clusterName) { + "$CloudrunCloudProvider.ID:${Namespace.CLUSTERS}:${account}:${application}:${clusterName}" + } + + static String getInstanceKey(String account, String instanceName) { + "$CloudrunCloudProvider.ID:${Namespace.INSTANCES}:${account}:${instanceName}" + } + static String getLoadBalancerKey(String account, String loadBalancerName) { + "$CloudrunCloudProvider.ID:${Namespace.LOAD_BALANCERS}:${account}:${loadBalancerName}" + } + + static String getServerGroupKey(String account, String serverGroupName, String region) { + Names names = Names.parseName(serverGroupName) + "$CloudrunCloudProvider.ID:${Namespace.SERVER_GROUPS}:${names.cluster}:${account}:${region}:${names.group}" + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/CloudrunServerGroupNameResolver.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/CloudrunServerGroupNameResolver.groovy new file mode 100644 index 00000000000..bec3d377d04 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/CloudrunServerGroupNameResolver.groovy @@ -0,0 +1,75 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.deploy + +import com.google.api.services.appengine.v1.model.Version +import com.netflix.frigga.Names +import com.netflix.spinnaker.clouddriver.cloudrun.model.CloudrunModelUtil +import com.netflix.spinnaker.clouddriver.cloudrun.security.CloudrunNamedAccountCredentials +import com.netflix.spinnaker.clouddriver.helpers.AbstractServerGroupNameResolver + +class CloudrunServerGroupNameResolver extends AbstractServerGroupNameResolver { + private static final String PHASE = "DEPLOY" + + private final String project + private final String region + private final CloudrunNamedAccountCredentials credentials + + CloudrunServerGroupNameResolver(String project, String region, CloudrunNamedAccountCredentials credentials) { + this.project = project + this.region = region + this.credentials = credentials + } + + @Override + String getPhase() { + PHASE + } + + @Override + String getRegion() { + region + } + + @Override + List getTakenSlots(String clusterName) { + def versions = CloudrunUtils.queryAllVersions(project, credentials, task, phase) + return findMatchingVersions(versions, clusterName) + } + + static List findMatchingVersions(List versions, String clusterName) { + if (!versions) { + return [] + } + + return versions.findResults { version -> + def versionName = version.getId() + def friggaNames = Names.parseName(versionName) + + if (friggaNames.cluster == clusterName) { + def timestamp = CloudrunModelUtil.translateTime(version.getCreateTime()) + return new AbstractServerGroupNameResolver.TakenSlot( + serverGroupName: versionName, + sequence: friggaNames.sequence, + createdTime: new Date(timestamp) + ) + } else { + return null + } + } + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/CloudrunUtils.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/CloudrunUtils.groovy new file mode 100644 index 00000000000..41dbe190c8d --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/CloudrunUtils.groovy @@ -0,0 +1,83 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.deploy + +import com.google.api.client.googleapis.batch.BatchRequest +import com.google.api.client.http.HttpHeaders +import com.google.api.services.appengine.v1.model.ListVersionsResponse +import com.google.api.services.appengine.v1.model.Service +import com.google.api.services.appengine.v1.model.Version +import com.netflix.spinnaker.clouddriver.cloudrun.provider.callbacks.CloudrunCallback +import com.netflix.spinnaker.clouddriver.cloudrun.security.CloudrunNamedAccountCredentials +import com.netflix.spinnaker.clouddriver.data.task.Task + +class CloudrunUtils { + static List queryAllVersions(String project, + CloudrunNamedAccountCredentials credentials, + Task task, + String phase) { + task.updateStatus phase, "Querying all versions for project $project..." + def services = queryAllServices(project, credentials, task, phase) + + BatchRequest batch = credentials.appengine.batch() + def allVersions = [] + + services.each { service -> + def callback = new CloudrunCallback() + .success { ListVersionsResponse versionsResponse, HttpHeaders responseHeaders -> + def versions = versionsResponse.getVersions() + if (versions) { + allVersions << versions + } + } + + credentials.appengine.apps().services().versions().list(project, service.getId()).queue(batch, callback) + } + + if (batch.size() > 0) { + batch.execute() + } + + return allVersions.flatten() + } + + static List queryAllServices(String project, + CloudrunNamedAccountCredentials credentials, + Task task, + String phase) { + task.updateStatus phase, "Querying services for project $project..." + return credentials.appengine.apps().services().list(project).execute().getServices() + } + + static List queryVersionsForService(String project, + String service, + CloudrunNamedAccountCredentials credentials, + Task task, + String phase) { + task.updateStatus phase, "Querying versions for project $project and service $service" + return credentials.appengine.apps().services().versions().list(project, service).execute().getVersions() + } + + static Service queryService(String project, + String service, + CloudrunNamedAccountCredentials credentials, + Task task, + String phase) { + task.updateStatus phase, "Querying service $service for project $project..." + return credentials.appengine.apps().services().get(project, service).execute() + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/converters/CloudrunAtomicOperationConverterHelper.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/converters/CloudrunAtomicOperationConverterHelper.groovy new file mode 100644 index 00000000000..ef7ea3c3421 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/converters/CloudrunAtomicOperationConverterHelper.groovy @@ -0,0 +1,47 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.deploy.converters + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.netflix.spinnaker.clouddriver.cloudrun.deploy.description.AbstractCloudrunCredentialsDescription +import com.netflix.spinnaker.clouddriver.cloudrun.security.CloudrunNamedAccountCredentials +import com.netflix.spinnaker.clouddriver.security.AbstractAtomicOperationsCredentialsConverter + +class CloudrunAtomicOperationConverterHelper { + static T convertDescription(Map input, + AbstractAtomicOperationsCredentialsConverter credentialsSupport, + Class targetDescriptionType) { + input.accountName = input.accountName ?: input.account ?: input.credentials + + if (input.accountName) { + input.credentials = credentialsSupport.getCredentialsObject(input.accountName as String) + input.account = input.accountName + } else { + throw new RuntimeException("Could not find Cloud Run account.") + } + + def credentials = input.remove("credentials") + + def converted = credentialsSupport.objectMapper + .copy() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .convertValue(input, targetDescriptionType) + + converted.credentials = credentials as CloudrunNamedAccountCredentials + converted + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/converters/DeployCloudrunAtomicOperationConverter.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/converters/DeployCloudrunAtomicOperationConverter.groovy new file mode 100644 index 00000000000..eecf2ea9907 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/converters/DeployCloudrunAtomicOperationConverter.groovy @@ -0,0 +1,74 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.deploy.converters + +import com.fasterxml.jackson.databind.ObjectMapper +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunOperation +import com.netflix.spinnaker.clouddriver.cloudrun.deploy.description.DeployCloudrunDescription +import com.netflix.spinnaker.clouddriver.cloudrun.deploy.exception.CloudrunDescriptionConversionException +import com.netflix.spinnaker.clouddriver.cloudrun.deploy.ops.DeployCloudrunAtomicOperation +import com.netflix.spinnaker.clouddriver.cloudrun.security.CloudrunNamedAccountCredentials +import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperation +import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperations +import com.netflix.spinnaker.clouddriver.security.AbstractAtomicOperationsCredentialsConverter +import com.netflix.spinnaker.kork.artifacts.model.Artifact +import groovy.util.logging.Slf4j +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component + +@CloudrunOperation(AtomicOperations.CREATE_SERVER_GROUP) +@Component +@Slf4j +class DeployCloudrunAtomicOperationConverter extends AbstractAtomicOperationsCredentialsConverter { + @Autowired + ObjectMapper objectMapper + + AtomicOperation convertOperation(Map input) { + new DeployCloudrunAtomicOperation(convertDescription(input)) + } + + DeployCloudrunDescription convertDescription(Map input) { + DeployCloudrunDescription description = CloudrunAtomicOperationConverterHelper.convertDescription(input, this, DeployCloudrunDescription) + + if (input.artifact) { + description.artifact = objectMapper.convertValue(input.artifact, Artifact) + switch (description.artifact.type) { + case 'gcs/object': + description.repositoryUrl = description.artifact.reference + if (!description.repositoryUrl.startsWith('gs://')) { + description.repositoryUrl = "gs://${description.repositoryUrl}" + } + break + case 'docker/image': + if (description.artifact.reference) { + description.containerImageUrl = description.artifact.reference + } else if (description.artifact.name) { + description.containerImageUrl = description.artifact.name + } + break + default: + throw new CloudrunDescriptionConversionException("Invalid artifact type for Cloudrun deploy: ${description.artifact.type}") + } + } + if (input.configArtifacts) { + def configArtifacts = input.configArtifacts + description.configArtifacts = configArtifacts.collect({ objectMapper.convertValue(it, Artifact) }) + } + + return description + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/description/AbstractCloudrunCredentialsDescription.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/description/AbstractCloudrunCredentialsDescription.groovy new file mode 100644 index 00000000000..7d4053eeaa8 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/description/AbstractCloudrunCredentialsDescription.groovy @@ -0,0 +1,26 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.deploy.description + + +import com.netflix.spinnaker.clouddriver.cloudrun.security.CloudrunNamedAccountCredentials +import com.netflix.spinnaker.clouddriver.security.resources.CredentialsNameable + +abstract class AbstractCloudrunCredentialsDescription implements CredentialsNameable { + String account + CloudrunNamedAccountCredentials credentials +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/description/DeployCloudrunDescription.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/description/DeployCloudrunDescription.groovy new file mode 100644 index 00000000000..24ab05b7863 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/description/DeployCloudrunDescription.groovy @@ -0,0 +1,46 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.deploy.description + + +import com.netflix.spinnaker.clouddriver.cloudrun.gitClient.CloudrunGitCredentialType +import com.netflix.spinnaker.clouddriver.deploy.DeployDescription +import com.netflix.spinnaker.kork.artifacts.model.Artifact +import groovy.transform.AutoClone +import groovy.transform.Canonical + +@AutoClone +@Canonical +class DeployCloudrunDescription extends AbstractCloudrunCredentialsDescription implements DeployDescription { + Artifact artifact + String accountName + String application + String stack + String freeFormDetails + String repositoryUrl + String storageAccountName // for GCS repositories only + CloudrunGitCredentialType gitCredentialType + String branch + String applicationDirectoryRoot + List configFilepaths + List configFiles + List configArtifacts + Boolean promote + Boolean stopPreviousVersion + Boolean suppressVersionString + String containerImageUrl // app engine flex only +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/description/UpsertCloudrunLoadBalancerDescription.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/description/UpsertCloudrunLoadBalancerDescription.groovy new file mode 100644 index 00000000000..8fc3261d5d5 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/description/UpsertCloudrunLoadBalancerDescription.groovy @@ -0,0 +1,39 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.deploy.description + + +import com.netflix.spinnaker.clouddriver.cloudrun.model.CloudrunTrafficSplit +import com.netflix.spinnaker.clouddriver.cloudrun.model.ShardBy + +class UpsertCloudrunLoadBalancerDescription extends AbstractCloudrunCredentialsDescription { + String accountName + String loadBalancerName + CloudrunTrafficSplit split + CloudrunTrafficSplitDescription splitDescription + Boolean migrateTraffic + + static class CloudrunTrafficSplitDescription { + ShardBy shardBy + List allocationDescriptions + } + + static class CloudrunAllocationDescription { + String serverGroupName + Double allocation + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/exception/CloudrunDescriptionConversionException.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/exception/CloudrunDescriptionConversionException.groovy new file mode 100644 index 00000000000..06c3f367aa7 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/exception/CloudrunDescriptionConversionException.groovy @@ -0,0 +1,22 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.deploy.exception + +import groovy.transform.InheritConstructors + +@InheritConstructors +class CloudrunDescriptionConversionException extends RuntimeException { } diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/ops/DeployCloudrunAtomicOperation.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/ops/DeployCloudrunAtomicOperation.groovy new file mode 100644 index 00000000000..7cbb3de8141 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/deploy/ops/DeployCloudrunAtomicOperation.groovy @@ -0,0 +1,346 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.deploy.ops + +import com.netflix.spectator.api.Registry +import com.netflix.spinnaker.clouddriver.artifacts.ArtifactDownloader +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunJobExecutor +import com.netflix.spinnaker.clouddriver.cloudrun.artifacts.GcsStorageService +import com.netflix.spinnaker.clouddriver.cloudrun.artifacts.config.StorageConfigurationProperties +import com.netflix.spinnaker.clouddriver.cloudrun.deploy.CloudrunMutexRepository +import com.netflix.spinnaker.clouddriver.cloudrun.deploy.CloudrunServerGroupNameResolver +import com.netflix.spinnaker.clouddriver.cloudrun.deploy.description.DeployCloudrunDescription +import com.netflix.spinnaker.clouddriver.cloudrun.deploy.exception.CloudrunOperationException +import com.netflix.spinnaker.clouddriver.cloudrun.gcsClient.CloudrunGcsRepositoryClient +import com.netflix.spinnaker.clouddriver.data.task.Task +import com.netflix.spinnaker.clouddriver.data.task.TaskRepository +import com.netflix.spinnaker.clouddriver.deploy.DeploymentResult +import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperation +import com.netflix.spinnaker.kork.artifacts.model.Artifact +import org.springframework.beans.factory.annotation.Autowired + +import java.nio.file.Path +import java.nio.file.Paths +import java.util.concurrent.TimeUnit + +import static com.netflix.spinnaker.clouddriver.cloudrun.config.CloudrunConfigurationProperties.ManagedAccount.GcloudReleaseTrack + +class DeployCloudrunAtomicOperation implements AtomicOperation { + private static final String BASE_PHASE = "DEPLOY" + + private static Task getTask() { + TaskRepository.threadLocalTask.get() + } + + @Autowired + Registry registry + + @Autowired + CloudrunJobExecutor jobExecutor + + @Autowired(required=false) + StorageConfigurationProperties storageConfiguration + + @Autowired(required=false) + GcsStorageService.Factory storageServiceFactory + + @Autowired + ArtifactDownloader artifactDownloader + + DeployCloudrunDescription description + boolean usesGcs + boolean containerDeployment + + DeployCloudrunAtomicOperation(DeployCloudrunDescription description) { + this.description = description + if (description.artifact) { + switch (description.artifact.type) { + case 'gcs/object': + String ref = description.artifact.reference + if (!ref) { + throw new CloudrunOperationException("Missing artifact reference for GCS deploy") + } + description.repositoryUrl = ref.startsWith("gs://") ? ref : "gs://${ref}" + usesGcs = true + break + case 'docker/image': + if (!description.artifact.name) { + throw new CloudrunOperationException("Missing artifact name for Flex Custom deploy") + } + containerDeployment = description.artifact.name + break + default: + throw new CloudrunOperationException("Unhandled artifact type in description") + break + } + } else { + containerDeployment = description.containerImageUrl?.trim() + usesGcs = !this.containerDeployment && description.repositoryUrl.startsWith("gs://") + } + } + + /** + * curl -X POST -H "Content-Type: application/json" -d '[ { "createServerGroup": { "application": "myapp", "stack": "stack", "freeFormDetails": "details", "repositoryUrl": "https://github.com/organization/project.git", "branch": "feature-branch", "credentials": "my-appengine-account", "configFilepaths": ["app.yaml"] } } ]' "http://localhost:7002/appengine/ops" + * curl -X POST -H "Content-Type: application/json" -d '[ { "createServerGroup": { "application": "myapp", "stack": "stack", "freeFormDetails": "details", "repositoryUrl": "https://github.com/organization/project.git", "branch": "feature-branch", "credentials": "my-appengine-account", "configFilepaths": ["app.yaml"], "promote": true, "stopPreviousVersion": true } } ]' "http://localhost:7002/appengine/ops" + * curl -X POST -H "Content-Type: application/json" -d '[ { "createServerGroup": { "application": "myapp", "stack": "stack", "freeFormDetails": "details", "repositoryUrl": "https://github.com/organization/project.git", "branch": "feature-branch", "credentials": "my-appengine-account", "configFilepaths": ["runtime: python27\napi_version: 1\nthreadsafe: true\nmanual_scaling:\n instances: 5\ninbound_services:\n - warmup\nhandlers:\n - url: /.*\n script: main.app"],} } ]' "http://localhost:7002/appengine/ops" + * curl -X POST -H "Content-Type: application/json" -d '[ { "createServerGroup": { "application": "myapp", "stack": "stack", "freeFormDetails": "details", "credentials": "my-appengine-account", "containerImageUrl": "gcr.io/my-project/my-image:my-tag", "configFiles": ["env: flex\nruntime: custom\nmanual_scaling:\n instances: 1\nresources:\n cpu: 1\n memory_gb: 0.5\n disk_size_gb: 10"] } } ]' "http://localhost:7002/appengine/ops" + * curl -X POST -H "Content-Type: application/json" -d '[ { "createServerGroup": { "application": "myapp", "stack": "stack", "freeFormDetails": "details", "credentials": "my-appengine-credential-name", "containerImageUrl": "gcr.io/my-gcr-repo/image:tag", "configArtifacts": [{ "type": "gcs/object", "name": "gs://path/to/app.yaml", "reference": "gs://path/to/app.yaml", "artifactAccount": "my-gcs-artifact-account-name" }] } } ]' "http://localhost:7002/appengine/ops" + */ + @Override + DeploymentResult operate(List priorOutputs) { + def baseDir = description.credentials.localRepositoryDirectory + + String directoryId + if (containerDeployment) { + directoryId = description.containerImageUrl + } else { + directoryId = description.repositoryUrl + } + + def directoryPath = getFullDirectoryPath(baseDir, directoryId) + def serviceAccount = description.credentials.serviceAccountEmail + def region = description.credentials.region + + /* + * We can't allow concurrent deploy operations on the same local repository. + * If operation A checks out a new branch before operation B has run 'gcloud app deploy', + * operation B will deploy using that new branch's source files. + * + * This means if one were to deploy to multiple regions, that the deployments + * will be serialized. If this is a problem, the mutex could be removed in + * favor of using temporary directories encapsulated to the request. However + * this means that the downloads would not be incremental without more work. + */ + registry.counter(registry.createId("appengine.deployStart", + "account", serviceAccount, + "region", region)) + .increment() + return CloudrunMutexRepository.atomicWrapper(directoryPath, { + task.updateStatus BASE_PHASE, "Initializing creation of version..." + String newVersionName + String deployPath + + if (containerDeployment) { + createEmptyDirectory(directoryPath) + deployPath = directoryPath + } else { + def startTime = registry.clock().monotonicTime() + def success = "false" + try { + deployPath = cloneOrUpdateLocalRepository(directoryPath, 1) + success = "true" + } finally { + def duration = registry.clock().monotonicTime() - startTime + registry.timer( + registry.createId("appengine.repositoryDownload", + "account", serviceAccount, + "repositoryType", usesGcs ? "gcs" : "git", + "success", success)) + .record(duration, TimeUnit.NANOSECONDS); + } + } + def startTime = registry.clock().monotonicTime() + def success = "false" + try { + newVersionName = deploy(deployPath) + success = "true" + } finally { + def duration = registry.clock().monotonicTime() - startTime + registry.timer(registry.createId("cloudrun.deploy", + "success", success, + "account", serviceAccount, + "region", region)) + .record(duration, TimeUnit.NANOSECONDS); + } + + def result = new DeploymentResult() + result.serverGroupNames = Arrays.asList("$region:$newVersionName".toString()) + result.serverGroupNameByRegion[region] = newVersionName + success = "true" + return result + }) + } + + void createEmptyDirectory(String path) { + File directory = new File(path) + if (directory.exists()) { + directory.deleteDir() + } + if (!directory.mkdirs()) { + throw new CloudrunOperationException("Failed to create directory: $path") + } + } + + String cloneOrUpdateLocalRepository(String directoryPath, Integer retryCount) { + def repositoryUrl = description.repositoryUrl + def directory = new File(directoryPath) + def branch = description.branch + def branchLogName = branch + def repositoryClient + + if (usesGcs) { + if (storageConfiguration == null) { + throw new IllegalStateException( + "GCS has been disabled. To enable it, configure storage.gcs.enabled=false and restart clouddriver.") + } + + def applicationDirectoryRoot = description.applicationDirectoryRoot + String credentialPath = "" + String storageAccountName = description.storageAccountName + if (storageAccountName != null && !storageAccountName.isEmpty()) { + credentialPath = storageConfiguration.getAccount(description.storageAccountName).jsonPath + } else { + storageAccountName = "ApplicationDefaultCredentials"; + } + GcsStorageService storage = storageServiceFactory.newForCredentials(credentialPath) + repositoryClient = new CloudrunGcsRepositoryClient(repositoryUrl, directoryPath, applicationDirectoryRoot, + storage, jobExecutor) + branchLogName = "(current)" + } else { + repositoryClient = description.credentials.gitCredentials.buildRepositoryClient( + repositoryUrl, + directoryPath, + description.gitCredentialType + ) + } + + try { + if (!directory.exists()) { + task.updateStatus BASE_PHASE, "Grabbing repository $repositoryUrl into local directory..." + directory.mkdir() + repositoryClient.initializeLocalDirectory() + } + task.updateStatus BASE_PHASE, "Fetching updates from $repositoryUrl for $branchLogName..." + repositoryClient.updateLocalDirectoryWithVersion(branch) + } catch (Exception e) { + directory.deleteDir() + if (retryCount > 0) { + return cloneOrUpdateLocalRepository(directoryPath, retryCount - 1) + } else { + throw e + } + } + return directoryPath + } + + String deploy(String repositoryPath) { + def project = description.credentials.project + def accountEmail = description.credentials.serviceAccountEmail + def region = description.credentials.region + def applicationDirectoryRoot = description.applicationDirectoryRoot + def gcloudReleaseTrack = description.credentials.gcloudReleaseTrack + def serverGroupNameResolver = new CloudrunServerGroupNameResolver(project, region, description.credentials) + def versionName = serverGroupNameResolver.resolveNextServerGroupName(description.application, + description.stack, + description.freeFormDetails, + description.suppressVersionString) + def imageUrl = description.containerImageUrl + def configFiles = description.configFiles + def writtenFullConfigFilePaths = writeConfigFiles(configFiles, repositoryPath, applicationDirectoryRoot) + def repositoryFullConfigFilePaths = + (description.configFilepaths?.collect { Paths.get(repositoryPath, applicationDirectoryRoot ?: '.', it).toString() } ?: []) as List + def configArtifactPaths = fetchConfigArtifacts(description.configArtifacts, repositoryPath, applicationDirectoryRoot) + + // runCommand expects a List and will fail if some of the arguments are GStrings (as that is not a subclass + // of String). It is thus important to only add Strings to deployCommand. For example, adding a flag "--test=$testvalue" + // below will cause deployments to fail unless you explicitly convert it to a String via "--test=$testvalue".toString() + def deployCommand = [description.credentials.gcloudPath] + if (gcloudReleaseTrack != null && gcloudReleaseTrack != GcloudReleaseTrack.STABLE) { + deployCommand << gcloudReleaseTrack.toString().toLowerCase() + } + deployCommand += ["app", "deploy", *(repositoryFullConfigFilePaths + writtenFullConfigFilePaths + configArtifactPaths)] + deployCommand << "--version=" + versionName + deployCommand << (description.promote ? "--promote" : "--no-promote") + deployCommand << (description.stopPreviousVersion ? "--stop-previous-version": "--no-stop-previous-version") + deployCommand << "--project=" + project + deployCommand << "--account=" + accountEmail + if (containerDeployment) { + deployCommand << "--image-url=" + imageUrl + } + + task.updateStatus BASE_PHASE, "Deploying version $versionName..." + def startTime = registry.clock().monotonicTime() + def success = "false" + try { + jobExecutor.runCommand(deployCommand) + success = "true" + } catch (e) { + throw new CloudrunOperationException("Failed to deploy to Cloud Run with command ${deployCommand.join(' ')}: ${e.getMessage()}") + } finally { + def duration = registry.clock().monotonicTime() - startTime + def id = registry.createId("cloudrun.deploy", + "account", description.credentials.serviceAccountEmail, + "region", description.credentials.region, + "success", success) + registry.timer(id).record(duration, TimeUnit.NANOSECONDS); + deleteFiles(writtenFullConfigFilePaths) + } + task.updateStatus BASE_PHASE, "Done deploying version $versionName..." + return versionName + } + + List fetchConfigArtifacts(List configArtifacts, String repositoryPath, String applicationDirectoryRoot) { + if (!configArtifacts) { + return []; + } else { + return configArtifacts.collect { artifact -> + def path = generateRandomRepositoryFilePath(repositoryPath, applicationDirectoryRoot) + try { + path.toFile() << artifactDownloader.download(artifact) + } catch(e) { + throw new CloudrunOperationException("Could not download artifact as config file: ${e.getMessage()}") + } + return path.toString() + } + } + } + + static List writeConfigFiles(List configFiles, String repositoryPath, String applicationDirectoryRoot) { + if (!configFiles) { + return [] + } else { + return configFiles.collect { configFile -> + def path = generateRandomRepositoryFilePath(repositoryPath, applicationDirectoryRoot) + try { + path.toFile() << configFile + } catch(e) { + throw new CloudrunOperationException("Could not write config file: ${e.getMessage()}") + } + return path.toString() + } + } + } + + static void deleteFiles(List paths) { + paths.each { path -> + try { + new File(path).delete() + } catch(e) { + throw new CloudrunOperationException("Could not delete config file: ${e.getMessage()}") + } + } + } + + static String getFullDirectoryPath(String localRepositoryDirectory, String repositoryUrl) { + return Paths.get(localRepositoryDirectory, repositoryUrl.replace('/', '-')).toString() + } + + static Path generateRandomRepositoryFilePath(String repositoryPath, String applicationDirectoryRoot) { + def name = UUID.randomUUID().toString() + return Paths.get(repositoryPath, applicationDirectoryRoot ?: ".", "${name}.yaml") + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gcsClient/CloudrunGcsRepositoryClient.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gcsClient/CloudrunGcsRepositoryClient.groovy new file mode 100644 index 00000000000..d05ab3c1b00 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gcsClient/CloudrunGcsRepositoryClient.groovy @@ -0,0 +1,101 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.gcsClient + + +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunJobExecutor +import com.netflix.spinnaker.clouddriver.cloudrun.artifacts.ArtifactUtils +import com.netflix.spinnaker.clouddriver.cloudrun.artifacts.GcsStorageService +import com.netflix.spinnaker.clouddriver.cloudrun.model.CloudrunRepositoryClient +import groovy.transform.CompileStatic +import groovy.transform.TupleConstructor +import groovy.util.logging.Slf4j +import org.apache.commons.io.FileUtils +import org.apache.commons.io.IOUtils + +@CompileStatic +@Slf4j +@TupleConstructor +class CloudrunGcsRepositoryClient implements CloudrunRepositoryClient { + String repositoryUrl + String targetDirectoryPath + String applicationDirectoryRoot + GcsStorageService storage + CloudrunJobExecutor jobExecutor + + void initializeLocalDirectory() { + downloadFiles() + } + + void updateLocalDirectoryWithVersion(String version) { + downloadFiles() + } + + void downloadFiles() { + def gsPrefix = "gs://" + if (!repositoryUrl.startsWith(gsPrefix)) { + throw new IllegalArgumentException("Repository is not a GCS bucket: " + repositoryUrl) + } + + def dest = applicationDirectoryRoot ? targetDirectoryPath + File.separator + applicationDirectoryRoot : targetDirectoryPath + + def fullPath = repositoryUrl.substring(gsPrefix.length()) + if (applicationDirectoryRoot) { + fullPath += "/${applicationDirectoryRoot}" + } + def slash = fullPath.indexOf("/") + def bucketName = fullPath.substring(0, slash) + def bucketPath = fullPath.substring(slash + 1) + Long version = null + + def versionSeparator = bucketPath.indexOf("#") + if (versionSeparator >= 0) { + String versionString = bucketPath.substring(versionSeparator + 1) + if (!versionString.isEmpty()) { + version = Long.parseLong(versionString) + } + bucketPath = bucketPath.substring(0, versionSeparator) + } + + // Start with a clean directory for each deployment. + File targetDirectory = new File(targetDirectoryPath) + if (targetDirectory.exists() && targetDirectory.isDirectory()) { + FileUtils.forceDelete(targetDirectory) + } else if (targetDirectory.exists() && targetDirectory.isFile()) { + log.error("GAE staging directory resolved to a file: ${}, failing...") + throw new IllegalArgumentException("GAE staging directory resolved to a file: ${}, failing...") + } + + if (bucketPath.endsWith(".tar")) { + InputStream tas = storage.openObjectStream(bucketName, bucketPath, version) + + // NOTE: We write the tar file out to an intermediate temp file because the tar input stream + // directly from openObjectStream() closes unexpectedly when accessed from untarStreamToPath() + // for some reason. + File tempFile = File.createTempFile("app", "tar") + FileOutputStream fos = new FileOutputStream(tempFile) + IOUtils.copy(tas, fos) + tas.close() + fos.close() + + ArtifactUtils.untarStreamToPath(new FileInputStream(tempFile), dest) + tempFile.delete() + } else { + storage.visitObjects(bucketName, bucketPath, { obj -> storage.downloadStorageObject(obj, dest) }) + } + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gitClient/CloudrunGitCredentialType.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gitClient/CloudrunGitCredentialType.groovy new file mode 100644 index 00000000000..6f73b45cc54 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gitClient/CloudrunGitCredentialType.groovy @@ -0,0 +1,24 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.gitClient + +enum CloudrunGitCredentialType { + NONE, + HTTPS_USERNAME_PASSWORD, + HTTPS_GITHUB_OAUTH_TOKEN, + SSH, +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gitClient/CloudrunGitCredentials.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gitClient/CloudrunGitCredentials.groovy new file mode 100644 index 00000000000..f85632a126e --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gitClient/CloudrunGitCredentials.groovy @@ -0,0 +1,122 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.gitClient + +import com.jcraft.jsch.JSch +import com.jcraft.jsch.JSchException +import com.jcraft.jsch.Session +import groovy.util.logging.Slf4j +import org.eclipse.jgit.api.TransportConfigCallback +import org.eclipse.jgit.transport.* +import org.eclipse.jgit.util.FS + +// Taken from http://www.codeaffine.com/2014/12/09/jgit-authentication/ +@Slf4j +class CloudrunGitCredentials { + UsernamePasswordCredentialsProvider httpsUsernamePasswordCredentialsProvider + UsernamePasswordCredentialsProvider httpsOAuthCredentialsProvider + TransportConfigCallback sshTransportConfigCallback + + CloudrunGitCredentials() {} + + CloudrunGitCredentials(String gitHttpsUsername, + String gitHttpsPassword, + String githubOAuthAccessToken, + String sshPrivateKeyFilePath, + String sshPrivateKeyPassphrase, + String sshKnownHostsFilePath, + boolean sshTrustUnknownHosts) { + setHttpsUsernamePasswordCredentialsProvider(gitHttpsUsername, gitHttpsPassword) + setHttpsOAuthCredentialsProvider(githubOAuthAccessToken) + setSshPrivateKeyTransportConfigCallback(sshPrivateKeyFilePath, sshPrivateKeyPassphrase, sshKnownHostsFilePath, sshTrustUnknownHosts) + } + + CloudrunGitRepositoryClient buildRepositoryClient(String repositoryUrl, + String targetDirectory, + CloudrunGitCredentialType credentialType) { + new CloudrunGitRepositoryClient(repositoryUrl, targetDirectory, credentialType, this) + } + + List getSupportedCredentialTypes() { + def supportedTypes = [CloudrunGitCredentialType.NONE] + + if (httpsUsernamePasswordCredentialsProvider) { + supportedTypes << CloudrunGitCredentialType.HTTPS_USERNAME_PASSWORD + } + + if (httpsOAuthCredentialsProvider) { + supportedTypes << CloudrunGitCredentialType.HTTPS_GITHUB_OAUTH_TOKEN + } + + if (sshTransportConfigCallback) { + supportedTypes << CloudrunGitCredentialType.SSH + } + + return supportedTypes + } + + void setHttpsUsernamePasswordCredentialsProvider(String gitHttpsUsername, String gitHttpsPassword) { + if (gitHttpsUsername && gitHttpsPassword) { + httpsUsernamePasswordCredentialsProvider = new UsernamePasswordCredentialsProvider(gitHttpsUsername, gitHttpsPassword) + } + } + + void setHttpsOAuthCredentialsProvider(String githubOAuthAccessToken) { + if (githubOAuthAccessToken) { + httpsOAuthCredentialsProvider = new UsernamePasswordCredentialsProvider(githubOAuthAccessToken, "") + } + } + + void setSshPrivateKeyTransportConfigCallback(String sshPrivateKeyFilePath, + String sshPrivateKeyPassphrase, + String sshKnownHostsFilePath, + boolean sshTrustUnknownHosts) { + if (sshPrivateKeyFilePath && sshPrivateKeyPassphrase) { + SshSessionFactory sshSessionFactory = new JschConfigSessionFactory() { + @Override + protected void configure(OpenSshConfig.Host hc, Session session) { + if (sshKnownHostsFilePath == null && sshTrustUnknownHosts) { + session.setConfig("StrictHostKeyChecking", "no") + } + } + + @Override + protected JSch createDefaultJSch(FS fs) throws JSchException { + JSch defaultJSch = super.createDefaultJSch(fs) + defaultJSch.addIdentity(sshPrivateKeyFilePath, sshPrivateKeyPassphrase) + + if (sshKnownHostsFilePath != null && sshTrustUnknownHosts) { + log.warn("SSH known_hosts file path supplied, ignoring 'sshTrustUnknownHosts' option") + } + if (sshKnownHostsFilePath != null) { + defaultJSch.setKnownHosts(sshKnownHostsFilePath) + } + + return defaultJSch + } + } + + sshTransportConfigCallback = new TransportConfigCallback() { + @Override + void configure(Transport transport) { + SshTransport sshTransport = (SshTransport) transport + sshTransport.setSshSessionFactory(sshSessionFactory) + } + } + } + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gitClient/CloudrunGitRepositoryClient.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gitClient/CloudrunGitRepositoryClient.groovy new file mode 100644 index 00000000000..7805a345c87 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/gitClient/CloudrunGitRepositoryClient.groovy @@ -0,0 +1,81 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.gitClient + + +import com.netflix.spinnaker.clouddriver.cloudrun.model.CloudrunRepositoryClient +import groovy.transform.TupleConstructor +import org.eclipse.jgit.api.CloneCommand +import org.eclipse.jgit.api.FetchCommand +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.TransportCommand +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.storage.file.FileRepositoryBuilder + +@TupleConstructor +class CloudrunGitRepositoryClient implements CloudrunRepositoryClient { + String repositoryUrl + String targetDirectory + CloudrunGitCredentialType credentialType + CloudrunGitCredentials config + + void initializeLocalDirectory() { + CloneCommand command = Git.cloneRepository() + .setURI(repositoryUrl) + .setDirectory(new File(targetDirectory)) + + attachCredentials(command) + + command.call() + } + + void updateLocalDirectoryWithVersion(String version) { + fetch(); + checkout(version); + } + + void fetch() { + Repository repo = FileRepositoryBuilder.create(new File(targetDirectory, ".git")) + FetchCommand command = new Git(repo).fetch() + + attachCredentials(command) + + command.call() + } + + void checkout(String branch) { + Repository repo = FileRepositoryBuilder.create(new File(targetDirectory, ".git")) + new Git(repo).checkout().setName("origin/$branch").call() + } + + private void attachCredentials(T command) { + switch (credentialType) { + case CloudrunGitCredentialType.HTTPS_USERNAME_PASSWORD: + command.setCredentialsProvider(config.httpsUsernamePasswordCredentialsProvider) + break + case CloudrunGitCredentialType.HTTPS_GITHUB_OAUTH_TOKEN: + command.setCredentialsProvider(config.httpsOAuthCredentialsProvider) + break + case CloudrunGitCredentialType.SSH: + command.setTransportConfigCallback(config.sshTransportConfigCallback) + break + case CloudrunGitCredentialType.NONE: + default: + break + } + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/health/CloudrunHealthIndicator.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/health/CloudrunHealthIndicator.groovy new file mode 100644 index 00000000000..8d7252387b9 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/health/CloudrunHealthIndicator.groovy @@ -0,0 +1,81 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.health + + +import com.netflix.spinnaker.clouddriver.cloudrun.security.CloudrunNamedAccountCredentials +import com.netflix.spinnaker.credentials.CredentialsTypeBaseConfiguration +import groovy.transform.InheritConstructors +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.actuate.health.Health +import org.springframework.boot.actuate.health.HealthIndicator +import org.springframework.http.HttpStatus +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.web.bind.annotation.ResponseStatus + +import java.util.concurrent.atomic.AtomicReference + +@Component +class CloudrunHealthIndicator implements HealthIndicator { + private static final Logger LOG = LoggerFactory.getLogger(CloudrunHealthIndicator) + + @Autowired + CredentialsTypeBaseConfiguration credentialsTypeBaseConfiguration + + private final AtomicReference lastException = new AtomicReference<>(null) + + @Override + Health health() { + def ex = lastException.get() + + if (ex) { + throw ex + } + + new Health.Builder().up().build() + } + + @Scheduled(fixedDelay = 300000L) + void checkHealth() { + try { + credentialsTypeBaseConfiguration.credentialsRepository?.all?.forEach({ + try { + /* + Location is the only App Engine resource guaranteed to exist. + The API only accepts '-' here, rather than project name. To paraphrase the provided error, + the list of locations is static and not a property of an individual project. + */ + it.appengine.apps().locations().list('-').execute() + } catch (IOException e) { + throw new AppengineIOException(e) + } + }) + lastException.set(null) + } catch (Exception ex) { + LOG.warn "Unhealthy", ex + + lastException.set(ex) + } + } + + @ResponseStatus(value = HttpStatus.SERVICE_UNAVAILABLE, reason = "Problem communicating with App Engine") + @InheritConstructors + static class AppengineIOException extends RuntimeException {} +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunHealth.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunHealth.groovy new file mode 100644 index 00000000000..9b033a9eac5 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunHealth.groovy @@ -0,0 +1,44 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.model + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.google.api.services.appengine.v1.model.Service +import com.google.api.services.appengine.v1.model.Version +import com.netflix.spinnaker.clouddriver.model.Health +import com.netflix.spinnaker.clouddriver.model.HealthState + +class CloudrunHealth implements Health { + HealthState state + String source + String type + String healthClass = "platform" + String description + + CloudrunHealth(Version version, Service service) { + source = "Service ${service.getId()}" + type = "Cloud Run Service" + + def allocations = service.getSplit()?.getAllocations() + state = allocations?.containsKey(version.getId()) ? HealthState.Up : HealthState.OutOfService + } + + Map toMap() { + new ObjectMapper().convertValue(this, new TypeReference>() {}) + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunInstance.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunInstance.groovy new file mode 100644 index 00000000000..a51e63e5d61 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunInstance.groovy @@ -0,0 +1,89 @@ +/* + * Copyright 2022 OpsMx, 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.clouddriver.cloudrun.model + +import com.google.api.services.appengine.v1.model.Instance as AppengineApiInstance +import com.google.api.services.appengine.v1.model.Service +import com.google.api.services.appengine.v1.model.Version +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunCloudProvider +import com.netflix.spinnaker.clouddriver.model.HealthState +import com.netflix.spinnaker.clouddriver.model.Instance + +class CloudrunInstance implements Instance, Serializable { + String name + String id + Long launchTime + AppengineInstanceStatus instanceStatus + String zone + String serverGroup + List loadBalancers + final String providerType = CloudrunCloudProvider.ID + final String cloudProvider = CloudrunCloudProvider.ID + String vmName + String vmZoneName + Integer requests + Integer errors + Float qps + Integer averageLatency + String memoryUsage + String vmStatus + String vmDebugEnabled + List> health + + CloudrunInstance() {} + + CloudrunInstance(AppengineApiInstance instance, Version version, Service service, String region) { + this.health = [new CloudrunHealth(version, service).toMap()] + this.instanceStatus = instance.getAvailability() ? + AppengineInstanceStatus.valueOf(instance.getAvailability()) : + null + + /* + * The instance controller takes three coordinates to locate an instance: account, region, and instance name. + * App Engine Flexible instances do not have unique ids, but do have unique vmNames, so we'll use vmName as instance name. + * App Engine Standard instances have unique ids, but do not have vmNames, so we'll use id as instance name. + * We'll keep a separate "id" property, which is the identifier the API needs for an instance delete operation. + * */ + this.name = instance.getVmName() ?: instance.getId() + this.id = instance.getId() + + this.launchTime = AppengineModelUtil.translateTime(instance.getStartTime()) + this.vmName = instance.getVmName() + this.vmZoneName = instance.getVmZoneName() + this.zone = instance.getVmZoneName() ?: region + this.requests = instance.getRequests() + this.errors = instance.getErrors() + this.qps = instance.getQps() + this.averageLatency = instance.getAverageLatency() + this.memoryUsage = instance.getMemoryUsage() + this.vmStatus = instance.getVmStatus() + this.vmDebugEnabled = instance.getVmDebugEnabled() + } + + HealthState getHealthState() { + this.health[0].state as HealthState + } + + enum AppengineInstanceStatus { + /* + * See https://cloud.google.com/appengine/docs/java/how-instances-are-managed + * */ + DYNAMIC, + RESIDENT, + UNKNOWN + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunLoadBalancer.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunLoadBalancer.groovy new file mode 100644 index 00000000000..c133eb8cf70 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunLoadBalancer.groovy @@ -0,0 +1,105 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.model + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.ObjectMapper +import com.google.api.services.appengine.v1.model.Service +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunCloudProvider +import com.netflix.spinnaker.clouddriver.model.LoadBalancer +import com.netflix.spinnaker.clouddriver.model.LoadBalancerInstance +import com.netflix.spinnaker.clouddriver.model.LoadBalancerServerGroup +import com.netflix.spinnaker.moniker.Moniker +import groovy.transform.AutoClone +import groovy.transform.CompileStatic +import groovy.transform.EqualsAndHashCode + +@CompileStatic +@EqualsAndHashCode(includes = ["name", "account"]) +@JsonInclude(JsonInclude.Include.NON_NULL) +class CloudrunLoadBalancer implements LoadBalancer, Serializable { + String name + String selfLink + String region + final String type = CloudrunCloudProvider.ID + final String cloudProvider = CloudrunCloudProvider.ID + String account + Set serverGroups = new HashSet<>() + CloudrunTrafficSplit split + String httpUrl + String httpsUrl + String project + List dispatchRules + + void setMoniker(Moniker _ignored) {} + + CloudrunLoadBalancer() { } + + CloudrunLoadBalancer(Service service, String account, String region) { + this.name = service.getId() + this.selfLink = service.getName() + this.account = account + this.region = region + this.split = new ObjectMapper().convertValue(service.getSplit(), CloudrunTrafficSplit) + this.httpUrl = CloudrunModelUtil.getHttpUrl(service.getName()) + this.httpsUrl = CloudrunModelUtil.getHttpsUrl(service.getName()) + // Self link has the form apps/{project}/services/{service}. + this.project = this.selfLink.split('/')[1] + } + + Void setLoadBalancerServerGroups(Set serverGroups) { + this.serverGroups = serverGroups?.collect { serverGroup -> + def instances = serverGroup.isDisabled() ? [] : serverGroup.instances?.collect { instance -> + new LoadBalancerInstance(id: instance.name, health: [state: instance.healthState.toString() as Object]) + } ?: [] + + def detachedInstances = serverGroup.isDisabled() ? serverGroup.instances?.collect { it.name } ?: [] : [] + + new CloudrunLoadBalancerServerGroup( + name: serverGroup.name, + region: serverGroup.region, + isDisabled: serverGroup.isDisabled(), + allowsGradualTrafficMigration: serverGroup.allowsGradualTrafficMigration, + instances: instances as Set, + detachedInstances: detachedInstances as Set, + cloudProvider: CloudrunCloudProvider.ID + ) + } as Set + null + } + + static class CloudrunLoadBalancerServerGroup extends LoadBalancerServerGroup { + Boolean allowsGradualTrafficMigration + } +} + +@AutoClone +@EqualsAndHashCode(includes = ["allocations", "shardBy"]) +class CloudrunTrafficSplit { + Map allocations + ShardBy shardBy +} + +enum ShardBy { + /* + * See https://cloud.google.com/appengine/docs/admin-api/reference/rest/v1/apps.services#ShardBy + * */ + UNSPECIFIED, + COOKIE, + IP, + RANDOM, +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunModelUtil.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunModelUtil.groovy new file mode 100644 index 00000000000..e3f0520d0ff --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunModelUtil.groovy @@ -0,0 +1,96 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.model + +import com.google.api.services.appengine.v1.model.Version +import com.google.common.annotations.VisibleForTesting +import com.netflix.spinnaker.clouddriver.cloudrun.deploy.description.UpsertCloudrunLoadBalancerDescription.CloudrunTrafficSplitDescription + +import java.text.SimpleDateFormat + +class CloudrunModelUtil { + // https://cloud.google.com/appengine/docs/admin-api/reference/rest/v1/apps.services#TrafficSplit + static final COOKIE_SPLIT_DECIMAL_PLACES = 3 + static final IP_SPLIT_DECIMAL_PLACES = 2 + + private static final dateFormats = ["yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", "yyyy-MM-dd'T'HH:mm:ss'Z'"] + .collect { new SimpleDateFormat(it) } + + static Long translateTime(String time) { + for (SimpleDateFormat dateFormat: dateFormats) { + try { + return dateFormat.parse(time).getTime() + } catch (e) { } + } + + null + } + + static CloudrunScalingPolicy getScalingPolicy(Version version) { + if (version.getAutomaticScaling()) { + return new CloudrunScalingPolicy(version.getAutomaticScaling()) + } else if (version.getBasicScaling()) { + return new CloudrunScalingPolicy(version.getBasicScaling()) + } else if (version.getManualScaling()) { + return new CloudrunScalingPolicy(version.getManualScaling()) + } else { + return new CloudrunScalingPolicy() + } + } + + static CloudrunTrafficSplit convertTrafficSplitDescriptionToTrafficSplit(CloudrunTrafficSplitDescription splitDescription) { + Map allocations = splitDescription.allocationDescriptions?.collectEntries { description -> + [(description.serverGroupName): description.allocation] + } ?: [:] + + return new CloudrunTrafficSplit( + allocations: allocations, + shardBy: splitDescription.shardBy + ) + } + + static String getHttpUrl(String selfLink) { + "http://${getUrl(selfLink, ".")}" + } + + static String getHttpsUrl(String selfLink) { + "https://${getUrl(selfLink, "-dot-")}" + } + + private static final String baseUrl = ".appspot.com" + + /* + Self link has the form apps/{project}/services/{service}/versions/{version}. + HTTPS: myversion-dot-myservice-dot-myapp.appspot.com + HTTP: myversion.myservice.myapp.appspot.com + + This should work for services and versions, and for + the default service and its versions ("default" can be omitted from their URLs). + */ + @VisibleForTesting + static String getUrl(String selfLink, String delimiter) { + def parts = selfLink.split("/").reverse() + def componentNames = [] + parts.eachWithIndex { String entry, int i -> + if (i % 2 == 0 && entry != "default") { + componentNames << entry + } + } + + return componentNames.join(delimiter) + baseUrl + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunPlatformApplication.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunPlatformApplication.groovy new file mode 100644 index 00000000000..ba1f698c7d7 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunPlatformApplication.groovy @@ -0,0 +1,53 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.model + +import com.google.api.services.appengine.v1.model.Application +import groovy.transform.TupleConstructor + +class CloudrunPlatformApplication { + List dispatchRules + String authDomain + String codeBucket + String defaultBucket + String defaultCookieExpiration + String defaultHostname + String id + String locationId + + CloudrunPlatformApplication() {} + + CloudrunPlatformApplication(Application application) { + this.dispatchRules = application.getDispatchRules()?.collect { rule -> + new CloudrunDispatchRule(rule.getDomain(), rule.getPath(), rule.getService()) + } ?: [] + this.authDomain = application.getAuthDomain() + this.codeBucket = application.getCodeBucket() + this.defaultBucket = application.getDefaultBucket() + this.defaultCookieExpiration = application.getDefaultCookieExpiration() + this.defaultHostname = application.getDefaultHostname() + this.id = application.getId() + this.locationId = application.getLocationId() + } + + @TupleConstructor + static class CloudrunDispatchRule { + String domain + String path + String service + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunRepositoryClient.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunRepositoryClient.groovy new file mode 100644 index 00000000000..4addb931af6 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunRepositoryClient.groovy @@ -0,0 +1,22 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.model + +interface CloudrunRepositoryClient { + void initializeLocalDirectory() + void updateLocalDirectoryWithVersion(String version) +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunScalingPolicy.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunScalingPolicy.groovy new file mode 100644 index 00000000000..f799988cdbb --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunScalingPolicy.groovy @@ -0,0 +1,137 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.model + +import com.fasterxml.jackson.annotation.JsonInclude +import com.google.api.services.appengine.v1.model.AutomaticScaling +import com.google.api.services.appengine.v1.model.BasicScaling +import com.google.api.services.appengine.v1.model.ManualScaling +import groovy.transform.TupleConstructor + +@JsonInclude(JsonInclude.Include.NON_NULL) +class CloudrunScalingPolicy implements Serializable { + ScalingPolicyType type + + // Automatic scaling + String coolDownPeriod + Integer maxConcurrentRequests + Integer maxIdleInstances + String maxPendingLatency + Integer maxTotalInstances + Integer minIdleInstances + String minPendingLatency + Integer minTotalInstances + CpuUtilization cpuUtilization + DiskUtilization diskUtilization + NetworkUtilization networkUtilization + RequestUtilization requestUtilization + + // Basic scaling + String idleTimeout + Integer maxInstances + + // Manual scaling + Integer instances + + CloudrunScalingPolicy() { + type = ScalingPolicyType.AUTOMATIC + } + + CloudrunScalingPolicy(AutomaticScaling automaticScaling) { + type = ScalingPolicyType.AUTOMATIC + + automaticScaling?.with { + this.coolDownPeriod = getCoolDownPeriod() + this.maxConcurrentRequests = getMaxConcurrentRequests() + this.maxIdleInstances = getMaxIdleInstances() + this.maxPendingLatency = getMaxPendingLatency() + this.maxTotalInstances = getMaxTotalInstances() + this.minIdleInstances = getMinIdleInstances() + this.minPendingLatency = getMinPendingLatency() + this.minTotalInstances = getMinTotalInstances() + + getCpuUtilization()?.with { + this.cpuUtilization = new CpuUtilization(getAggregationWindowLength(), + getTargetUtilization()) + } + + getNetworkUtilization()?.with { + this.networkUtilization = new NetworkUtilization(getTargetReceivedBytesPerSecond(), + getTargetReceivedPacketsPerSecond(), + getTargetSentBytesPerSecond(), + getTargetSentPacketsPerSecond()) + } + + getDiskUtilization()?.with { + this.diskUtilization = new DiskUtilization(getTargetReadBytesPerSecond(), + getTargetReadOpsPerSecond(), + getTargetWriteBytesPerSecond(), + getTargetWriteOpsPerSecond()) + } + + getRequestUtilization()?.with { + this.requestUtilization = new RequestUtilization(getTargetConcurrentRequests(), + getTargetRequestCountPerSecond()) + } + } + } + + CloudrunScalingPolicy(BasicScaling basicScaling) { + type = ScalingPolicyType.BASIC + idleTimeout = basicScaling.getIdleTimeout() + maxInstances = basicScaling.getMaxInstances() + } + + CloudrunScalingPolicy(ManualScaling manualScaling) { + type = ScalingPolicyType.MANUAL + instances = manualScaling.getInstances() + } +} + +enum ScalingPolicyType { + AUTOMATIC, + BASIC, + MANUAL, +} + +@TupleConstructor +class CpuUtilization { + String aggregationWindowLength + Double targetUtilization +} + +@TupleConstructor +class DiskUtilization { + Integer targetReadBytesPerSecond + Integer targetReadOpsPerSecond + Integer targetWriteBytesPerSecond + Integer targetWriteOpsPerSecond +} + +@TupleConstructor +class NetworkUtilization { + Integer targetReceivedBytesPerSecond + Integer targetReceivedPacketsPerSecond + Integer targetSentBytesPerSecond + Integer targetSentPacketsPerSecond +} + +@TupleConstructor +class RequestUtilization { + Integer targetConcurrentRequests + Integer targetRequestCountPerSecond +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunServerGroup.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunServerGroup.groovy new file mode 100644 index 00000000000..0763f88e66d --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/model/CloudrunServerGroup.groovy @@ -0,0 +1,185 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.model + +import com.google.api.services.appengine.v1.model.Version +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunCloudProvider +import com.netflix.spinnaker.clouddriver.model.HealthState +import com.netflix.spinnaker.clouddriver.model.ServerGroup +import groovy.transform.CompileStatic +import groovy.transform.EqualsAndHashCode + +@CompileStatic +@EqualsAndHashCode(includes = ["name", "account"]) +class CloudrunServerGroup implements ServerGroup, Serializable { + String name + final String type = CloudrunCloudProvider.ID + final String cloudProvider = CloudrunCloudProvider.ID + String account + String region + Set zones = [] + Set instances + Set loadBalancers = [] + Long createdTime + Map launchConfig = [:] + Set securityGroups = [] + Boolean disabled = true + CloudrunScalingPolicy scalingPolicy + ServingStatus servingStatus + Environment env + String httpUrl + String httpsUrl + String instanceClass + Boolean allowsGradualTrafficMigration + + CloudrunServerGroup() {} + + CloudrunServerGroup(Version version, String account, String region, String loadBalancerName, Boolean isDisabled) { + this.account = account + this.region = region + this.name = version.getId() + this.loadBalancers = [loadBalancerName] as Set + this.createdTime = CloudrunModelUtil.translateTime(version.getCreateTime()) + this.disabled = isDisabled + this.scalingPolicy = CloudrunModelUtil.getScalingPolicy(version) + this.servingStatus = version.getServingStatus() ? ServingStatus.valueOf(version.getServingStatus()) : null + this.env = version.getEnv() ? Environment.valueOf(version.getEnv().toUpperCase()) : null + this.httpUrl = CloudrunModelUtil.getHttpUrl(version.getName()) + this.httpsUrl = CloudrunModelUtil.getHttpsUrl(version.getName()) + this.instanceClass = version.getInstanceClass() + this.launchConfig.instanceType = this.instanceClass + this.zones = [region] as Set + this.allowsGradualTrafficMigration = versionAllowsGradualTrafficMigration(version) + } + + @Override + ServerGroup.InstanceCounts getInstanceCounts() { + new ServerGroup.InstanceCounts( + down: 0, + outOfService: (Integer) instances?.count { it.healthState == HealthState.OutOfService } ?: 0, + up: (Integer) instances?.count { it.healthState == HealthState.Up } ?: 0, + starting: 0, + unknown: 0, + total: (Integer) instances?.size(), + ) + } + + def update(Version version, String account, String region, String loadBalancerName, Boolean isDisabled) { + this.account = account + this.region = region + this.name = version.getId() + this.loadBalancers.add(loadBalancerName) + this.createdTime = CloudrunModelUtil.translateTime(version.getCreateTime()) + this.disabled = isDisabled + this.scalingPolicy = CloudrunModelUtil.getScalingPolicy(version) + this.servingStatus = version.getServingStatus() ? ServingStatus.valueOf(version.getServingStatus()) : null + this.env = version.getEnv() ? Environment.valueOf(version.getEnv().toUpperCase()) : null + this.httpUrl = CloudrunModelUtil.getHttpUrl(version.getName()) + this.httpsUrl = CloudrunModelUtil.getHttpsUrl(version.getName()) + this.instanceClass = version.getInstanceClass() + this.launchConfig.instanceType = this.instanceClass + this.zones = [region] as Set + this.allowsGradualTrafficMigration = versionAllowsGradualTrafficMigration(version) + } + + @Override + ServerGroup.Capacity getCapacity() { + Integer instanceCount = instances?.size() ?: 0 + + switch (scalingPolicy?.type) { + case ScalingPolicyType.AUTOMATIC: + /* + * For the flexible environment, a version using automatic scaling can be stopped. + * A stopped version scales down to zero instances and ignores its scaling policy. + * */ + def min = computeMinForAutomaticScaling(scalingPolicy) + def max = computeMaxForAutomaticScaling(scalingPolicy) + return new ServerGroup.Capacity(min: min, + max: max, + desired: min) + break + case ScalingPolicyType.BASIC: + def desired = servingStatus == ServingStatus.SERVING ? instanceCount : 0 + return new ServerGroup.Capacity(min: 0, + max: scalingPolicy.maxInstances, + desired: desired) + break + case ScalingPolicyType.MANUAL: + def desired = servingStatus == ServingStatus.SERVING ? scalingPolicy.instances : 0 + return new ServerGroup.Capacity(min: 0, + max: scalingPolicy.instances, + desired: desired) + break + default: + return new ServerGroup.Capacity(min: instanceCount, max: instanceCount, desired: instanceCount) + break + } + } + + Integer computeMinForAutomaticScaling(CloudrunScalingPolicy scalingPolicy) { + Integer instanceCount = instances?.size() ?: 0 + if (servingStatus == ServingStatus.SERVING) { + def candidateMinValues = [scalingPolicy.minIdleInstances, scalingPolicy.minTotalInstances, instanceCount].findAll { it != null } + return candidateMinValues.min() + } else { + return 0 + } + } + + Integer computeMaxForAutomaticScaling(CloudrunScalingPolicy scalingPolicy) { + Integer instanceCount = instances?.size() ?: 0 + if (servingStatus == ServingStatus.SERVING) { + def candidateMaxValues = [scalingPolicy.maxIdleInstances, scalingPolicy.maxTotalInstances, instanceCount].findAll { it != null } + return candidateMaxValues.max() + } else { + return instanceCount + } + } + + static Boolean versionAllowsGradualTrafficMigration(Version version) { + // Versions do not always have an env property if they are in the standard environment. + def inStandardEnvironment = version.getEnv()?.toUpperCase() != "FLEXIBLE" + def warmupRequestsConfigured = (version.getInboundServices() ?: []).contains("INBOUND_SERVICE_WARMUP") + def usesAutomaticScaling = CloudrunModelUtil.getScalingPolicy(version).type == ScalingPolicyType.AUTOMATIC + return inStandardEnvironment && warmupRequestsConfigured && usesAutomaticScaling + } + + @Override + ServerGroup.ImageSummary getImageSummary() { + null + } + + @Override + ServerGroup.ImagesSummary getImagesSummary() { + null + } + + @Override + Boolean isDisabled() { + disabled + } + + enum ServingStatus { + SERVING, + STOPPED, + } + + enum Environment { + STANDARD, + FLEXIBLE, + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/CloudrunProvider.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/CloudrunProvider.groovy new file mode 100644 index 00000000000..062a7dde93b --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/CloudrunProvider.groovy @@ -0,0 +1,52 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.provider + + +import com.netflix.spinnaker.clouddriver.cloudrun.cache.Keys +import com.netflix.spinnaker.clouddriver.cache.SearchableProvider +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunCloudProvider +import com.netflix.spinnaker.clouddriver.security.BaseProvider + +class CloudrunProvider extends BaseProvider implements SearchableProvider { + public static final String PROVIDER_NAME = CloudrunProvider.name + + final Map urlMappingTemplates = Collections.emptyMap() + final Map searchResultHydrators = Collections.emptyMap() + final CloudrunCloudProvider cloudProvider + final Set defaultCaches = [ + Keys.Namespace.APPLICATIONS.ns, + Keys.Namespace.CLUSTERS.ns, + Keys.Namespace.SERVER_GROUPS.ns, + Keys.Namespace.INSTANCES.ns, + Keys.Namespace.LOAD_BALANCERS.ns, + ].asImmutable() + + CloudrunProvider(CloudrunCloudProvider cloudProvider) { + this.cloudProvider = cloudProvider + } + + @Override + String getProviderName() { + return PROVIDER_NAME + } + + @Override + Map parseKey(String key) { + return Keys.parse(key) + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/AbstractCloudrunCachingAgent.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/AbstractCloudrunCachingAgent.groovy new file mode 100644 index 00000000000..a16c19087b8 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/AbstractCloudrunCachingAgent.groovy @@ -0,0 +1,100 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.provider.agent + +import com.fasterxml.jackson.databind.ObjectMapper +import com.google.api.client.googleapis.batch.BatchRequest +import com.netflix.spinnaker.cats.agent.AccountAware +import com.netflix.spinnaker.cats.agent.AgentIntervalAware +import com.netflix.spinnaker.cats.agent.CachingAgent +import com.netflix.spinnaker.cats.cache.CacheData +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunCloudProvider +import com.netflix.spinnaker.clouddriver.cloudrun.provider.CloudrunProvider +import com.netflix.spinnaker.clouddriver.cloudrun.security.CloudrunNamedAccountCredentials + +import java.util.concurrent.TimeUnit + +abstract class AbstractCloudrunCachingAgent implements CachingAgent, AccountAware, AgentIntervalAware { + final String accountName + final String providerName = CloudrunProvider.PROVIDER_NAME + final CloudrunCloudProvider cloudrunCloudProvider = new CloudrunCloudProvider() + final ObjectMapper objectMapper + final CloudrunNamedAccountCredentials credentials + + AbstractCloudrunCachingAgent(String accountName, + ObjectMapper objectMapper, + CloudrunNamedAccountCredentials credentials) { + this.accountName = accountName + this.objectMapper = objectMapper + this.credentials = credentials + } + + boolean shouldIgnoreLoadBalancer(String loadBalancerName) { + if (credentials.services != null && !credentials.services.isEmpty() && + credentials.services.every { !loadBalancerName.matches(it) }) { + return true + } + if (credentials.omitServices != null && !credentials.omitServices.isEmpty() && + credentials.omitServices.any { loadBalancerName.matches(it) }) { + return true + } + return false + } + + boolean shouldIgnoreServerGroup(String serverGroupName) { + if (credentials.versions != null && !credentials.versions.isEmpty() && + credentials.versions.every { !serverGroupName.matches(it) }) { + return true + } + if (credentials.omitVersions != null && !credentials.omitVersions.isEmpty() + && credentials.omitVersions.any { serverGroupName.matches(it) }) { + return true + } + return false + } + + static void cache(Map> cacheResults, + String cacheNamespace, + Map cacheDataById) { + cacheResults[cacheNamespace].each { + def existingCacheData = cacheDataById[it.id] + if (existingCacheData) { + existingCacheData.attributes.putAll(it.attributes) + it.relationships.each { String relationshipName, Collection relationships -> + existingCacheData.relationships[relationshipName].addAll(relationships) + } + } else { + cacheDataById[it.id] = it + } + } + } + + static void executeIfRequestsAreQueued(BatchRequest batch) { + if (batch.size()) { + batch.execute() + } + } + + Long getAgentInterval() { + if (this.credentials.cachingIntervalSeconds == null) { + return TimeUnit.SECONDS.toMillis(60) + } + return TimeUnit.SECONDS.toMillis(this.credentials.cachingIntervalSeconds) + } + + abstract String getSimpleName() +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/CloudrunLoadBalancerCachingAgent.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/CloudrunLoadBalancerCachingAgent.groovy new file mode 100644 index 00000000000..c379a9d73a7 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/CloudrunLoadBalancerCachingAgent.groovy @@ -0,0 +1,245 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.provider.agent + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.google.api.services.appengine.v1.model.Service +import com.netflix.spectator.api.Registry +import com.netflix.spinnaker.cats.agent.AgentDataType +import com.netflix.spinnaker.cats.agent.CacheResult +import com.netflix.spinnaker.cats.agent.DefaultCacheResult +import com.netflix.spinnaker.cats.cache.CacheData +import com.netflix.spinnaker.cats.cache.DefaultCacheData +import com.netflix.spinnaker.cats.provider.ProviderCache +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunCloudProvider +import com.netflix.spinnaker.clouddriver.cloudrun.cache.Keys +import com.netflix.spinnaker.clouddriver.cloudrun.model.CloudrunLoadBalancer +import com.netflix.spinnaker.clouddriver.cloudrun.security.CloudrunNamedAccountCredentials +import com.netflix.spinnaker.clouddriver.cache.OnDemandAgent +import com.netflix.spinnaker.clouddriver.cache.OnDemandMetricsSupport +import com.netflix.spinnaker.clouddriver.cache.OnDemandType +import com.netflix.spinnaker.clouddriver.cloudrun.provider.view.MutableCacheData +import groovy.util.logging.Slf4j + +import java.util.concurrent.TimeUnit +import java.util.stream.Collectors + +import static com.netflix.spinnaker.cats.agent.AgentDataType.Authority.AUTHORITATIVE +import static com.netflix.spinnaker.clouddriver.cloudrun.cache.Keys.Namespace.LOAD_BALANCERS +import static com.netflix.spinnaker.clouddriver.cloudrun.cache.Keys.Namespace.ON_DEMAND + +@Slf4j +class CloudrunLoadBalancerCachingAgent extends AbstractCloudrunCachingAgent implements OnDemandAgent { + final String category = "loadBalancer" + + final OnDemandMetricsSupport metricsSupport + + static final Set types = Collections.unmodifiableSet([ + AUTHORITATIVE.forType(LOAD_BALANCERS.ns), + ] as Set) + + String agentType = "${accountName}/${CloudrunLoadBalancerCachingAgent.simpleName}" + + @Override + String getSimpleName() { + CloudrunLoadBalancerCachingAgent.simpleName + } + + @Override + String getOnDemandAgentType() { + "${agentType}-OnDemand" + } + + @Override + Collection getProvidedDataTypes() { + types + } + + CloudrunLoadBalancerCachingAgent(String accountName, + CloudrunNamedAccountCredentials credentials, + ObjectMapper objectMapper, + Registry registry) { + super(accountName, objectMapper, credentials) + metricsSupport = new OnDemandMetricsSupport( + registry, + this, + "$CloudrunCloudProvider.ID:$OnDemandType.LoadBalancer") + } + + @Override + boolean handles(OnDemandType type, String cloudProvider) { + type == OnDemandType.LoadBalancer && cloudProvider == CloudrunCloudProvider.ID + } + + @Override + OnDemandResult handle(ProviderCache providerCache, Map data) { + if (!data.containsKey("loadBalancerName") || data.account != accountName) { + return null + } + + def loadBalancerName = data.loadBalancerName.toString() + + if (shouldIgnoreLoadBalancer(loadBalancerName)) { + return null + } + + Service service = metricsSupport.readData { + loadService(loadBalancerName) + } + + CacheResult result = metricsSupport.transformData { + buildCacheResult([service], [:], [], Long.MAX_VALUE) + } + + def jsonResult = objectMapper.writeValueAsString(result.cacheResults) + def loadBalancerKey = Keys.getLoadBalancerKey(accountName, loadBalancerName) + if (result.cacheResults.values().flatten().isEmpty()) { + providerCache.evictDeletedItems(ON_DEMAND.ns, [loadBalancerKey]) + } else { + metricsSupport.onDemandStore { + def cacheData = new DefaultCacheData( + loadBalancerKey, + TimeUnit.MINUTES.toSeconds(10) as int, + [ + cacheTime: System.currentTimeMillis(), + cacheResults: jsonResult, + processedCount: 0, + processedTime: 0 + ], + [:] + ) + + providerCache.putCacheData(ON_DEMAND.ns, cacheData) + } + } + + Map> evictions = service ? [:] : [(LOAD_BALANCERS.ns): [loadBalancerKey]] + + log.info "On demand cache refresh (data: ${data}) succeeded." + + return new OnDemandAgent.OnDemandResult( + sourceAgentType: getOnDemandAgentType(), + cacheResult: result, + evictions: evictions + ) + } + + @Override + CacheResult loadData(ProviderCache providerCache) { + Long start = System.currentTimeMillis() + List services = loadServices().stream().filter { !shouldIgnoreLoadBalancer(it.getId()) }.collect(Collectors.toList()) + List evictFromOnDemand = [] + List keepInOnDemand = [] + + def loadBalancerKeys = services.collect { + Keys.getLoadBalancerKey(credentials.name, it.getId()) + } + + providerCache.getAll(ON_DEMAND.ns, loadBalancerKeys).each { CacheData onDemandEntry -> + if (onDemandEntry.attributes.cacheTime < start && onDemandEntry.attributes.processedCount > 0) { + evictFromOnDemand << onDemandEntry + } else { + keepInOnDemand << onDemandEntry + } + } + + def onDemandMap = keepInOnDemand.collectEntries { CacheData onDemandEntry -> + [(onDemandEntry.id): onDemandEntry] + } + def result = buildCacheResult(services, onDemandMap, evictFromOnDemand*.id, start) + result.cacheResults[ON_DEMAND.ns].each { CacheData onDemandEntry -> + onDemandEntry.attributes.processedTime = System.currentTimeMillis() + onDemandEntry.attributes.processedCount = (onDemandEntry.attributes.processedCount ?: 0) + 1 + } + + return result + } + + CacheResult buildCacheResult(List services, + Map onDemandKeep, + List onDemandEvict, + Long start) { + log.info "Describing items in $agentType" + + Map cachedLoadBalancers = MutableCacheData.mutableCacheMap() + + services.each { Service service -> + def loadBalancerKey = Keys.getLoadBalancerKey(accountName, service.getId()) + def loadBalancerName = service.getId() + def onDemandData = onDemandKeep ? onDemandKeep[loadBalancerKey] : null + + if (onDemandData && onDemandData.attributes.cacheTime >= start) { + Map> cacheResults = objectMapper + .readValue(onDemandData.attributes.cacheResults as String, + new TypeReference>>() {}) + + cache(cacheResults, LOAD_BALANCERS.ns, cachedLoadBalancers) + } else { + cachedLoadBalancers[loadBalancerKey].with { + attributes.name = loadBalancerName + attributes.loadBalancer = new CloudrunLoadBalancer(service, + accountName, + credentials.region) + } + } + } + + log.info("Caching ${cachedLoadBalancers.size()} load balancers in ${agentType}") + + return new DefaultCacheResult([ + (LOAD_BALANCERS.ns): cachedLoadBalancers.values(), + (ON_DEMAND.ns): onDemandKeep.values() + ], [ + (ON_DEMAND.ns): onDemandEvict + ]) + } + + @Override + Collection> pendingOnDemandRequests(ProviderCache providerCache) { + def keys = providerCache.getIdentifiers(ON_DEMAND.ns) + keys = keys.findResults { + def parse = Keys.parse(it) + if (parse && parse.account == accountName) { + return it + } else { + return null + } + } + + return providerCache.getAll(ON_DEMAND.ns, keys).collect { + def details = Keys.parse(it.id) + return [ + details : details, + moniker : convertOnDemandDetails(details), + cacheTime : it.attributes.cacheTime, + processedCount: it.attributes.processedCount, + processedTime : it.attributes.processedTime + ] + } + } + + Service loadService(String loadBalancerName) { + def project = credentials.project + return credentials.appengine.apps().services().get(project, loadBalancerName).execute() + } + + List loadServices() { + def project = credentials.project + return credentials.appengine.apps().services().list(project).execute().getServices() + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/CloudrunPlatformApplicationCachingAgent.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/CloudrunPlatformApplicationCachingAgent.groovy new file mode 100644 index 00000000000..093376ccbb6 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/CloudrunPlatformApplicationCachingAgent.groovy @@ -0,0 +1,72 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.provider.agent + +import com.fasterxml.jackson.databind.ObjectMapper +import com.google.api.services.appengine.v1.model.Application +import com.netflix.spinnaker.cats.agent.AgentDataType +import com.netflix.spinnaker.cats.agent.CacheResult +import com.netflix.spinnaker.cats.agent.DefaultCacheResult +import com.netflix.spinnaker.cats.provider.ProviderCache +import com.netflix.spinnaker.clouddriver.cloudrun.model.CloudrunPlatformApplication +import com.netflix.spinnaker.clouddriver.cloudrun.provider.view.MutableCacheData +import com.netflix.spinnaker.clouddriver.cloudrun.cache.Keys +import com.netflix.spinnaker.clouddriver.cloudrun.security.CloudrunNamedAccountCredentials +import groovy.util.logging.Slf4j + +import static com.netflix.spinnaker.cats.agent.AgentDataType.Authority.AUTHORITATIVE + +@Slf4j +class CloudrunPlatformApplicationCachingAgent extends AbstractCloudrunCachingAgent { + String agentType = "${accountName}/${CloudrunPlatformApplicationCachingAgent.simpleName}" + + static final Set types = Collections.unmodifiableSet([ + AUTHORITATIVE.forType(Keys.Namespace.PLATFORM_APPLICATIONS.ns)] as Set + ) + + CloudrunPlatformApplicationCachingAgent(String accountName, + CloudrunNamedAccountCredentials credentials, + ObjectMapper objectMapper) { + super(accountName, objectMapper, credentials) + } + + @Override + String getSimpleName() { + CloudrunPlatformApplicationCachingAgent.simpleName + } + + @Override + Collection getProvidedDataTypes() { + types + } + + @Override + CacheResult loadData(ProviderCache providerCache) { + def platformApplicationName = credentials.project + Application platformApplication = credentials.appengine.apps().get(platformApplicationName).execute() + Map cachedPlatformApplications = MutableCacheData.mutableCacheMap() + def apiApplicationKey = Keys.getPlatformApplicationKey(platformApplicationName) + + cachedPlatformApplications[apiApplicationKey].with { + attributes.name = platformApplicationName + attributes.platformApplication = new CloudrunPlatformApplication(platformApplication) + } + + log.info("Caching ${cachedPlatformApplications.size()} platform applications in ${agentType}") + return new DefaultCacheResult([(Keys.Namespace.PLATFORM_APPLICATIONS.ns): cachedPlatformApplications.values()], [:]) + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/CloudrunServerGroupCachingAgent.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/CloudrunServerGroupCachingAgent.groovy new file mode 100644 index 00000000000..57ecd165906 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/agent/CloudrunServerGroupCachingAgent.groovy @@ -0,0 +1,439 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.provider.agent + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.google.api.client.googleapis.batch.BatchRequest +import com.google.api.client.googleapis.json.GoogleJsonError +import com.google.api.client.http.HttpHeaders +import com.google.api.services.appengine.v1.model.* +import com.netflix.frigga.Names +import com.netflix.spectator.api.Registry +import com.netflix.spinnaker.cats.agent.AgentDataType +import com.netflix.spinnaker.cats.agent.CacheResult +import com.netflix.spinnaker.cats.agent.DefaultCacheResult +import com.netflix.spinnaker.cats.cache.CacheData +import com.netflix.spinnaker.cats.cache.DefaultCacheData +import com.netflix.spinnaker.cats.provider.ProviderCache +import com.netflix.spinnaker.clouddriver.cache.OnDemandAgent +import com.netflix.spinnaker.clouddriver.cache.OnDemandMetricsSupport +import com.netflix.spinnaker.clouddriver.cache.OnDemandType +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunCloudProvider +import com.netflix.spinnaker.clouddriver.cloudrun.cache.Keys +import com.netflix.spinnaker.clouddriver.cloudrun.model.CloudrunInstance +import com.netflix.spinnaker.clouddriver.cloudrun.model.CloudrunLoadBalancer +import com.netflix.spinnaker.clouddriver.cloudrun.model.CloudrunServerGroup +import com.netflix.spinnaker.clouddriver.cloudrun.provider.callbacks.CloudrunCallback +import com.netflix.spinnaker.clouddriver.cloudrun.provider.view.MutableCacheData +import com.netflix.spinnaker.clouddriver.cloudrun.security.CloudrunNamedAccountCredentials +import groovy.util.logging.Slf4j +import static com.netflix.spinnaker.cats.agent.AgentDataType.Authority.AUTHORITATIVE +import static com.netflix.spinnaker.cats.agent.AgentDataType.Authority.INFORMATIVE + +@Slf4j +class CloudrunServerGroupCachingAgent extends AbstractCloudrunCachingAgent implements OnDemandAgent { + final String category = "serverGroup" + + final OnDemandMetricsSupport metricsSupport + + static final Set types = Collections.unmodifiableSet([ + AUTHORITATIVE.forType(Keys.Namespace.APPLICATIONS.ns), + AUTHORITATIVE.forType(Keys.Namespace.CLUSTERS.ns), + AUTHORITATIVE.forType(Keys.Namespace.SERVER_GROUPS.ns), + AUTHORITATIVE.forType(Keys.Namespace.INSTANCES.ns), + INFORMATIVE.forType(Keys.Namespace.LOAD_BALANCERS.ns), + ] as Set) + + String agentType = "${accountName}/${CloudrunServerGroupCachingAgent.simpleName}" + + CloudrunServerGroupCachingAgent(String accountName, + CloudrunNamedAccountCredentials credentials, + ObjectMapper objectMapper, + Registry registry) { + super(accountName, objectMapper, credentials) + this.metricsSupport = new OnDemandMetricsSupport( + registry, + this, + "$CloudrunCloudProvider.ID:$OnDemandType.ServerGroup") + } + + @Override + String getSimpleName() { + CloudrunServerGroupCachingAgent.simpleName + } + + @Override + Collection getProvidedDataTypes() { + types + } + + @Override + String getOnDemandAgentType() { + "${getAgentType()}-OnDemand" + } + + @Override + boolean handles(OnDemandType type, String cloudProvider) { + type == OnDemandType.ServerGroup && cloudProvider == CloudrunCloudProvider.ID + } + + @Override + OnDemandAgent.OnDemandResult handle(ProviderCache providerCache, Map data) { + if (!data.containsKey("serverGroupName") || data.account != accountName) { + return null + } + + def serverGroupName = data.serverGroupName.toString() + if (shouldIgnoreServerGroup(serverGroupName)) { + return null + } + def matchingServerGroupAndLoadBalancer = metricsSupport.readData { + loadServerGroupAndLoadBalancer(serverGroupName) + } + Version serverGroup = matchingServerGroupAndLoadBalancer.serverGroup + Service loadBalancer = matchingServerGroupAndLoadBalancer.loadBalancer + def serverGroupsByLoadBalancer = loadBalancer ? [(loadBalancer): [serverGroup]] : [:].withDefault { [] } + Map> instances = (serverGroup && loadBalancer) ? + loadInstances(serverGroupsByLoadBalancer) : + [:].withDefault { [] } + + CacheResult result = metricsSupport.transformData { + buildCacheResult( + serverGroupsByLoadBalancer, + instances, + [:], + [], + Long.MAX_VALUE + ) + } + + def jsonResult = objectMapper.writeValueAsString(result.cacheResults) + def serverGroupKey = Keys.getServerGroupKey(accountName, serverGroupName, credentials.region) + if (result.cacheResults.values().flatten().isEmpty()) { + providerCache.evictDeletedItems( + Keys.Namespace.ON_DEMAND.ns, + [serverGroupKey] + ) + } else { + metricsSupport.onDemandStore { + def cacheData = new DefaultCacheData( + serverGroupKey, + 10 * 60, // ttl is 10 minutes. + [ + cacheTime: System.currentTimeMillis(), + cacheResults: jsonResult, + processedCount: 0, + processedTime: null + ], + [:] + ) + + providerCache.putCacheData(Keys.Namespace.ON_DEMAND.ns, cacheData) + } + } + + Map> evictions = serverGroup ? [:] : [(Keys.Namespace.SERVER_GROUPS.ns): [serverGroupKey]] + + log.info "On demand cache refresh (data: ${data}) succeeded." + + new OnDemandAgent.OnDemandResult( + sourceAgentType: getOnDemandAgentType(), + cacheResult: result, + evictions: evictions + ) + } + + @Override + CacheResult loadData(ProviderCache providerCache) { + Long start = System.currentTimeMillis() + Map> serverGroupsByLoadBalancer = loadServerGroups() + Map> instancesByServerGroup = loadInstances(serverGroupsByLoadBalancer) + List evictFromOnDemand = [] + List keepInOnDemand = [] + + def serverGroupKeys = serverGroupsByLoadBalancer.collectMany([], { Service loadBalancer, List versions -> + versions.collect { version -> Keys.getServerGroupKey(accountName, version.getId(), credentials.region) } + }) + + providerCache.getAll(Keys.Namespace.ON_DEMAND.ns, serverGroupKeys).each { CacheData onDemandEntry -> + if (onDemandEntry.attributes.cacheTime < start && onDemandEntry.attributes.processedCount > 0) { + evictFromOnDemand << onDemandEntry + } else { + keepInOnDemand << onDemandEntry + } + } + + def onDemandMap = keepInOnDemand.collectEntries { CacheData onDemandEntry -> [(onDemandEntry.id): onDemandEntry] } + def result = buildCacheResult(serverGroupsByLoadBalancer, + instancesByServerGroup, + onDemandMap, + evictFromOnDemand*.id, + start) + + result.cacheResults[Keys.Namespace.ON_DEMAND.ns].each { CacheData onDemandEntry -> + onDemandEntry.attributes.processedTime = System.currentTimeMillis() + onDemandEntry.attributes.processedCount = (onDemandEntry.attributes.processedCount ?: 0) + 1 + } + + result + } + + CacheResult buildCacheResult(Map> serverGroupsByLoadBalancer, + Map> instancesByServerGroup, + Map onDemandKeep, + List onDemandEvict, + Long start) { + log.info "Describing items in $agentType" + + Map cachedApplications = MutableCacheData.mutableCacheMap() + Map cachedClusters = MutableCacheData.mutableCacheMap() + Map cachedServerGroups = MutableCacheData.mutableCacheMap() + Map cachedLoadBalancers = MutableCacheData.mutableCacheMap() + Map cachedInstances = MutableCacheData.mutableCacheMap() + + serverGroupsByLoadBalancer.each { Service loadBalancer, List serverGroups -> + def loadBalancerName = loadBalancer.getId() + serverGroups.each { Version serverGroup -> + def onDemandData = onDemandKeep ? + onDemandKeep[Keys.getServerGroupKey(accountName, serverGroup.getId(), credentials.region)] : + null + + if (onDemandData && onDemandData.attributes.cacheTime >= start) { + Map> cacheResults = objectMapper.readValue(onDemandData.attributes.cacheResults as String, + new TypeReference>>() {}) + cache(cacheResults, Keys.Namespace.APPLICATIONS.ns, cachedApplications) + cache(cacheResults, Keys.Namespace.CLUSTERS.ns, cachedClusters) + cache(cacheResults, Keys.Namespace.SERVER_GROUPS.ns, cachedServerGroups) + cache(cacheResults, Keys.Namespace.INSTANCES.ns, cachedInstances) + cache(cacheResults, Keys.Namespace.LOAD_BALANCERS.ns, cachedLoadBalancers) + } else { + def serverGroupName = serverGroup.getId() + def names = Names.parseName(serverGroupName) + def applicationName = names.app + def clusterName = names.cluster + def instances = instancesByServerGroup[serverGroup] ?: [] + + def serverGroupKey = Keys.getServerGroupKey(accountName, serverGroupName, credentials.region) + def applicationKey = Keys.getApplicationKey(applicationName) + def clusterKey = Keys.getClusterKey(accountName, applicationName, clusterName) + def loadBalancerKey = Keys.getLoadBalancerKey(accountName, loadBalancerName) + + cachedApplications[applicationKey].with { + attributes.name = applicationName + relationships[Keys.Namespace.CLUSTERS.ns].add(clusterKey) + relationships[Keys.Namespace.SERVER_GROUPS.ns].add(serverGroupKey) + relationships[Keys.Namespace.LOAD_BALANCERS.ns].add(loadBalancerKey) + } + + cachedClusters[clusterKey].with { + attributes.name = clusterName + relationships[Keys.Namespace.APPLICATIONS.ns].add(applicationKey) + relationships[Keys.Namespace.SERVER_GROUPS.ns].add(serverGroupKey) + relationships[Keys.Namespace.LOAD_BALANCERS.ns].add(loadBalancerKey) + } + + def instanceKeys = instances.inject([], { ArrayList keys, instance -> + def instanceName = instance.getVmName() ?: instance.getId() + def key = Keys.getInstanceKey(accountName, instanceName) + cachedInstances[key].with { + attributes.name = instanceName + attributes.instance = new CloudrunInstance(instance, serverGroup, loadBalancer, credentials.region) + relationships[Keys.Namespace.APPLICATIONS.ns].add(applicationKey) + relationships[Keys.Namespace.CLUSTERS.ns].add(clusterKey) + relationships[Keys.Namespace.SERVER_GROUPS.ns].add(serverGroupKey) + relationships[Keys.Namespace.LOAD_BALANCERS.ns].add(loadBalancerKey) + } + keys << key + keys + }) + + cachedServerGroups[serverGroupKey].with { + attributes.name = serverGroupName + def isDisabled = !loadBalancer.getSplit().getAllocations().containsKey(serverGroupName); + if (attributes.serverGroup == null) { + attributes.serverGroup = new CloudrunServerGroup(serverGroup, + accountName, + credentials.region, + loadBalancerName, + isDisabled) + } else { + attributes.serverGroup.update(serverGroup, + accountName, + credentials.region, + loadBalancerName, + isDisabled) + } + relationships[Keys.Namespace.APPLICATIONS.ns].add(applicationKey) + relationships[Keys.Namespace.CLUSTERS.ns].add(clusterKey) + relationships[Keys.Namespace.INSTANCES.ns].addAll(instanceKeys) + } + + cachedLoadBalancers[loadBalancerKey].with { + attributes.name = loadBalancerName + attributes.loadBalancer = new CloudrunLoadBalancer(loadBalancer, accountName, credentials.region) + relationships[Keys.Namespace.SERVER_GROUPS.ns].add(serverGroupKey) + relationships[Keys.Namespace.INSTANCES.ns].addAll(instanceKeys) + } + } + } + } + + log.info("Caching ${cachedApplications.size()} applications in ${agentType}") + log.info("Caching ${cachedClusters.size()} clusters in ${agentType}") + log.info("Caching ${cachedServerGroups.size()} server groups in ${agentType}") + log.info("Caching ${cachedLoadBalancers.size()} load balancers in ${agentType}") + log.info("Caching ${cachedInstances.size()} instances in ${agentType}") + + new DefaultCacheResult([ + (Keys.Namespace.APPLICATIONS.ns) : cachedApplications.values(), + (Keys.Namespace.CLUSTERS.ns) : cachedClusters.values(), + (Keys.Namespace.SERVER_GROUPS.ns) : cachedServerGroups.values(), + (Keys.Namespace.LOAD_BALANCERS.ns): cachedLoadBalancers.values(), + (Keys.Namespace.INSTANCES.ns) : cachedInstances.values(), + (Keys.Namespace.ON_DEMAND.ns) : onDemandKeep.values() + ], [ + (Keys.Namespace.ON_DEMAND.ns): onDemandEvict + ]) + } + + Map> loadServerGroups() { + def project = credentials.project + def loadBalancers = credentials.appengine.apps().services().list(project).execute().getServices() ?: [] + BatchRequest batch = credentials.appengine.batch() // TODO(jacobkiefer): Consider limiting batch sizes. https://github.com/spinnaker/spinnaker/issues/3564. + Map> serverGroupsByLoadBalancer = [:].withDefault { [] } + + loadBalancers.each { loadBalancer -> + def loadBalancerName = loadBalancer.getId() + if (shouldIgnoreLoadBalancer(loadBalancerName)) { + return + } + def callback = new CloudrunCallback() + .success { ListVersionsResponse versionsResponse, HttpHeaders responseHeaders -> + def versions = versionsResponse.getVersions() + if (versions) { + versions.removeIf { shouldIgnoreServerGroup(it.getId()) } + if(versions) { + serverGroupsByLoadBalancer[loadBalancer].addAll(versions) + } + } + } + + credentials.appengine.apps().services().versions().list(project, loadBalancerName).queue(batch, callback) + } + + executeIfRequestsAreQueued(batch) + return serverGroupsByLoadBalancer + } + + Map loadServerGroupAndLoadBalancer(String serverGroupName) { + if (shouldIgnoreServerGroup(serverGroupName)) { + return [:] + } + def project = credentials.project + def loadBalancers = credentials.appengine.apps().services().list(project).execute().getServices() ?: [] + BatchRequest batch = credentials.appengine.batch() + Service loadBalancer + Version serverGroup + + loadBalancers.removeIf { shouldIgnoreLoadBalancer(it.getName()) } + + // We don't know where our server group is, so we have to check all of the load balancers. + loadBalancers.each { Service lb -> + def loadBalancerName = lb.getId() + def callback = new CloudrunCallback() + .success { Version version, HttpHeaders responseHeaders -> + if (version) { + serverGroup = version + loadBalancer = lb + } + } + .failure { GoogleJsonError e, HttpHeaders responseHeaders -> + if (e.code != 404) { + def errorJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(e) + log.error errorJson + } + } + + credentials + .appengine + .apps() + .services() + .versions() + .get(credentials.project, loadBalancerName, serverGroupName) + .queue(batch, callback) + } + + executeIfRequestsAreQueued(batch) + return [serverGroup: serverGroup, loadBalancer: loadBalancer] + } + + Map> loadInstances(Map> serverGroupsByLoadBalancer) { + BatchRequest batch = credentials.appengine.batch() + Map> instancesByServerGroup = [:].withDefault { [] } + + serverGroupsByLoadBalancer.each { Service loadBalancer, List serverGroups -> + serverGroups.each { Version serverGroup -> + def serverGroupName = serverGroup.getId() + def callback = new CloudrunCallback() + .success { ListInstancesResponse instancesResponse, HttpHeaders httpHeaders -> + def instances = instancesResponse.getInstances() + if (instances) { + instancesByServerGroup[serverGroup].addAll(instances) + } + } + + credentials + .appengine + .apps() + .services() + .versions() + .instances() + .list(credentials.project, loadBalancer.getId(), serverGroupName) + .queue(batch, callback) + } + } + + executeIfRequestsAreQueued(batch) + return instancesByServerGroup + } + + @Override + Collection> pendingOnDemandRequests(ProviderCache providerCache) { + def keys = providerCache.getIdentifiers(Keys.Namespace.ON_DEMAND.ns) + keys = keys.findResults { + def parse = Keys.parse(it) + if (parse && parse.account == accountName) { + return it + } else { + return null + } + } + + providerCache.getAll(Keys.Namespace.ON_DEMAND.ns, keys).collect { + def details = Keys.parse(it.id) + + return [ + details : details, + moniker : convertOnDemandDetails(details), + cacheTime : it.attributes.cacheTime, + processedCount: it.attributes.processedCount, + processedTime : it.attributes.processedTime + ] + } + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/callbacks/CloudrunCallback.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/callbacks/CloudrunCallback.groovy new file mode 100644 index 00000000000..47e0e4e526d --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/callbacks/CloudrunCallback.groovy @@ -0,0 +1,54 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.provider.callbacks + +import com.fasterxml.jackson.databind.ObjectMapper +import com.google.api.client.googleapis.batch.json.JsonBatchCallback +import com.google.api.client.googleapis.json.GoogleJsonError +import com.google.api.client.http.HttpHeaders +import groovy.util.logging.Slf4j + +@Slf4j +class CloudrunCallback extends JsonBatchCallback { + Closure successCb + Closure failureCb + + CloudrunCallback success(Closure successCb) { + this.successCb = successCb + return this + } + + CloudrunCallback failure(Closure failureCb) { + this.failureCb = failureCb + return this + } + + @Override + void onSuccess(T response, HttpHeaders httpHeaders) throws IOException { + successCb(response, httpHeaders) + } + + @Override + void onFailure(GoogleJsonError e, HttpHeaders httpHeaders) throws IOException { + if (failureCb) { + failureCb(e, httpHeaders) + } else { + def errorJson = new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(e) + log.error errorJson + } + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/view/MutableCacheData.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/view/MutableCacheData.groovy new file mode 100644 index 00000000000..9bd1936e681 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/provider/view/MutableCacheData.groovy @@ -0,0 +1,45 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.provider.view + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import com.netflix.spinnaker.cats.cache.CacheData + +class MutableCacheData implements CacheData { + final String id + int ttlSeconds = -1 + final Map attributes = [:] + final Map> relationships = [:].withDefault { [] as Set } + + MutableCacheData(String id) { + this.id = id + } + + @JsonCreator + MutableCacheData(@JsonProperty("id") String id, + @JsonProperty("attributes") Map attributes, + @JsonProperty("relationships") Map> relationships) { + this(id) + this.attributes.putAll(attributes) + this.relationships.putAll(relationships) + } + + static Map mutableCacheMap() { + return [:].withDefault { String id -> new MutableCacheData(id) } + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunNamedAccountCredentials.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunNamedAccountCredentials.groovy new file mode 100644 index 00000000000..c260d08e6f5 --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunNamedAccountCredentials.groovy @@ -0,0 +1,301 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.security + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.google.api.services.appengine.v1.Appengine +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunCloudProvider +import com.netflix.spinnaker.clouddriver.cloudrun.gitClient.CloudrunGitCredentialType +import com.netflix.spinnaker.clouddriver.cloudrun.gitClient.CloudrunGitCredentials +import com.netflix.spinnaker.clouddriver.security.AbstractAccountCredentials +import com.netflix.spinnaker.fiat.model.resources.Permissions +import groovy.transform.TupleConstructor + +import static com.netflix.spinnaker.clouddriver.cloudrun.config.CloudrunConfigurationProperties.ManagedAccount.GcloudReleaseTrack + +@TupleConstructor +class CloudrunNamedAccountCredentials extends AbstractAccountCredentials { + public final static String CREDENTIALS_TYPE = "cloudrun"; + final String name + final String environment + final String accountType + final String project + final String cloudProvider = CloudrunCloudProvider.ID + final String region + final List regions + final List requiredGroupMembership + final Permissions permissions + + @JsonIgnore + final String jsonPath + @JsonIgnore + final String gcloudPath + final CloudrunCredentials credentials + final String applicationName + @JsonIgnore + final Appengine appengine + @JsonIgnore + final String serviceAccountEmail + @JsonIgnore + final String localRepositoryDirectory + @JsonIgnore + final CloudrunGitCredentials gitCredentials + final GcloudReleaseTrack gcloudReleaseTrack + final List supportedGitCredentialTypes + + final List services + final List versions + final List omitServices + final List omitVersions + + final Long cachingIntervalSeconds + + static class Builder { + String name + String environment + String accountType + String project + String region + List requiredGroupMembership + Permissions permissions = Permissions.EMPTY + CloudrunCredentials credentials + + String jsonKey + String gcloudPath + String jsonPath + String applicationName + Appengine appengine + String serviceAccountEmail + String localRepositoryDirectory + String gitHttpsUsername + String gitHttpsPassword + String githubOAuthAccessToken + String sshPrivateKeyFilePath + String sshPrivateKeyPassphrase + String sshKnownHostsFilePath + boolean sshTrustUnknownHosts + GcloudReleaseTrack gcloudReleaseTrack + CloudrunGitCredentials gitCredentials + List services + List versions + List omitServices + List omitVersions + Long cachingIntervalSeconds + + /* + * If true, the builder will overwrite region with a value from the platform. + * */ + Boolean liveLookupsEnabled = true + + Builder name(String name) { + this.name = name + return this + } + + Builder environment(String environment) { + this.environment = environment + return this + } + + Builder accountType(String accountType) { + this.accountType = accountType + return this + } + + Builder project(String project) { + this.project = project + return this + } + + Builder region(String region) { + this.region = region + this.liveLookupsEnabled = false + return this + } + + Builder requiredGroupMembership(List requiredGroupMembership) { + this.requiredGroupMembership = requiredGroupMembership + return this + } + + Builder permissions(Permissions permissions) { + if (permissions.isRestricted()) { + this.requiredGroupMembership = [] + this.permissions = permissions + } + return this + } + + Builder jsonPath(String jsonPath) { + this.jsonPath = jsonPath + return this + } + + Builder gcloudPath(String gcloudPath) { + this.gcloudPath = gcloudPath + return this + } + + Builder jsonKey(String jsonKey) { + this.jsonKey = jsonKey + return this + } + + Builder applicationName(String applicationName) { + this.applicationName = applicationName + return this + } + + Builder credentials(CloudrunCredentials credentials) { + this.credentials = credentials + return this + } + + Builder appengine(Appengine appengine) { + this.appengine = appengine + return this + } + + Builder serviceAccountEmail(String serviceAccountEmail) { + this.serviceAccountEmail = serviceAccountEmail + return this + } + + Builder localRepositoryDirectory(String localRepositoryDirectory) { + this.localRepositoryDirectory = localRepositoryDirectory + return this + } + + Builder gitHttpsUsername(String gitHttpsUsername) { + this.gitHttpsUsername = gitHttpsUsername + return this + } + + Builder gitHttpsPassword(String gitHttpsPassword) { + this.gitHttpsPassword = gitHttpsPassword + return this + } + + Builder githubOAuthAccessToken(String githubOAuthAccessToken) { + this.githubOAuthAccessToken = githubOAuthAccessToken + return this + } + + Builder sshPrivateKeyFilePath(String sshPrivateKeyFilePath) { + this.sshPrivateKeyFilePath = sshPrivateKeyFilePath + return this + } + + Builder sshPrivateKeyPassphrase(String sshPrivateKeyPassphrase) { + this.sshPrivateKeyPassphrase = sshPrivateKeyPassphrase + return this + } + + Builder sshKnownHostsFilePath(String sshKnownHostsFilePath) { + this.sshKnownHostsFilePath = sshKnownHostsFilePath + return this + } + + Builder sshTrustUnknownHosts(boolean sshTrustUnknownHosts) { + this.sshTrustUnknownHosts = sshTrustUnknownHosts + return this + } + + Builder gitCredentials(CloudrunGitCredentials gitCredentials) { + this.gitCredentials = gitCredentials + return this + } + + Builder gcloudReleaseTrack(GcloudReleaseTrack gcloudReleaseTrack) { + this.gcloudReleaseTrack = gcloudReleaseTrack + return this + } + + Builder services(List serviceNames) { + this.services = serviceNames + return this + } + + Builder versions(List versionNames) { + this.versions = versionNames + return this + } + + Builder omitServices(List serviceNames) { + this.omitServices = serviceNames + return this + } + + Builder omitVersions(List versionNames) { + this.omitVersions = versionNames + return this + } + + Builder cachingIntervalSeconds(Long interval) { + this.cachingIntervalSeconds = interval + return this + } + + CloudrunNamedAccountCredentials build() { + credentials = credentials ?: + jsonKey ? + new CloudrunJsonCredentials(project, jsonKey) : + new CloudrunCredentials(project) + + appengine = appengine ?: credentials.getAppengine(applicationName) + + if (liveLookupsEnabled) { + region = appengine.apps().get(project).execute().getLocationId() + } + + gitCredentials = gitCredentials ?: new CloudrunGitCredentials( + gitHttpsUsername, + gitHttpsPassword, + githubOAuthAccessToken, + sshPrivateKeyFilePath, + sshPrivateKeyPassphrase, + sshKnownHostsFilePath, + sshTrustUnknownHosts + ) + + return new CloudrunNamedAccountCredentials(name, + environment, + accountType, + project, + CloudrunCloudProvider.ID, + region, + [region], + requiredGroupMembership, + permissions, + jsonPath, + gcloudPath, + credentials, + applicationName, + appengine, + serviceAccountEmail, + localRepositoryDirectory, + gitCredentials, + gcloudReleaseTrack, + gitCredentials.getSupportedCredentialTypes(), + services, + versions, + omitServices, + omitVersions, + cachingIntervalSeconds) + } + } +} diff --git a/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/config/CloudrunConfiguration.groovy b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/config/CloudrunConfiguration.groovy new file mode 100644 index 00000000000..cfefb8b63bc --- /dev/null +++ b/clouddriver-cloudrun/src/main/groovy/com/netflix/spinnaker/config/CloudrunConfiguration.groovy @@ -0,0 +1,70 @@ +/* + * Copyright 2022 OpsMx 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.config + + +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunCloudProvider +import com.netflix.spinnaker.clouddriver.cloudrun.config.CloudrunConfigurationProperties +import com.netflix.spinnaker.clouddriver.cloudrun.config.CloudrunCredentialsConfiguration +import com.netflix.spinnaker.clouddriver.cloudrun.health.CloudrunHealthIndicator +import com.netflix.spinnaker.clouddriver.cloudrun.provider.CloudrunProvider +import com.netflix.spinnaker.clouddriver.cloudrun.security.CloudrunNamedAccountCredentials +import com.netflix.spinnaker.credentials.CredentialsLifecycleHandler +import com.netflix.spinnaker.credentials.CredentialsRepository +import com.netflix.spinnaker.credentials.MapBackedCredentialsRepository +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Import +import org.springframework.scheduling.annotation.EnableScheduling + +@Configuration +@EnableConfigurationProperties +@EnableScheduling +@ConditionalOnProperty("cloudrun.enabled") +@ComponentScan(["com.netflix.spinnaker.clouddriver.cloudrun"]) +@Import([CloudrunCredentialsConfiguration]) +class CloudrunConfiguration { + @Bean + @ConfigurationProperties("cloudrun") + CloudrunConfigurationProperties appengineConfigurationProperties() { + new CloudrunConfigurationProperties() + } + + @Bean + CloudrunHealthIndicator cloudrunHealthIndicator() { + new CloudrunHealthIndicator() + } + + @Bean + CloudrunProvider cloudrunProvider(CloudrunCloudProvider cloudProvider) { + new CloudrunProvider(cloudProvider) + } + + @Bean + @ConditionalOnMissingBean( + value = CloudrunNamedAccountCredentials.class, + parameterizedContainer = CredentialsRepository.class) + public CredentialsRepository credentialsRepository( + CredentialsLifecycleHandler eventHandler) { + return new MapBackedCredentialsRepository<>(CloudrunCloudProvider.ID, eventHandler); + } +} diff --git a/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/artifacts/ArtifactUtils.java b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/artifacts/ArtifactUtils.java new file mode 100644 index 00000000000..86fbf2ecf0b --- /dev/null +++ b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/artifacts/ArtifactUtils.java @@ -0,0 +1,83 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.artifacts; + +import java.io.*; +import java.util.Stack; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.utils.IOUtils; + +public class ArtifactUtils { + public static void untarStreamToPath(InputStream inputStream, String basePath) + throws IOException { + class DirectoryTimestamp { + public DirectoryTimestamp(File d, long m) { + directory = d; + millis = m; + } + + public File directory; + public long millis; + } + // Directories come in hierarchical order within the stream, but + // we need to set their timestamps after their children have been written. + Stack directoryStack = new Stack<>(); + + File baseDirectory = new File(basePath); + baseDirectory.mkdir(); + + TarArchiveInputStream tarStream = new TarArchiveInputStream(inputStream); + for (TarArchiveEntry entry = tarStream.getNextTarEntry(); + entry != null; + entry = tarStream.getNextTarEntry()) { + File target = new File(baseDirectory, entry.getName()); + + String canonicalTargetPath = target.getCanonicalPath(); + String canonicalBaseDirPath = baseDirectory.getCanonicalPath(); + + if (!canonicalTargetPath.startsWith(canonicalBaseDirPath)) { + throw new RuntimeException( + "Entry is outside of the target directory (" + entry.getName() + ")"); + } + + if (entry.isDirectory()) { + directoryStack.push(new DirectoryTimestamp(target, entry.getModTime().getTime())); + continue; + } + writeStreamToFile(tarStream, target); + target.setLastModified(entry.getModTime().getTime()); + } + + while (!directoryStack.empty()) { + DirectoryTimestamp info = directoryStack.pop(); + info.directory.setLastModified(info.millis); + } + tarStream.close(); + } + + public static void writeStreamToFile(InputStream sourceStream, File target) throws IOException { + File parent = target.getParentFile(); + if (!parent.exists()) { + parent.mkdirs(); + } + OutputStream targetStream = new FileOutputStream(target); + IOUtils.copy(sourceStream, targetStream); + targetStream.close(); + } +} diff --git a/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/artifacts/GcsStorageService.java b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/artifacts/GcsStorageService.java new file mode 100644 index 00000000000..396d3d64d71 --- /dev/null +++ b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/artifacts/GcsStorageService.java @@ -0,0 +1,182 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.artifacts; + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.storage.Storage; +import com.google.api.services.storage.StorageScopes; +import com.google.api.services.storage.model.Objects; +import com.google.api.services.storage.model.StorageObject; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class GcsStorageService { + private static final Logger log = LoggerFactory.getLogger(GcsStorageService.class); + + public static interface VisitorOperation { + void visit(StorageObject storageObj) throws IOException; + } + + public static class Factory { + private String applicationName_; + private HttpTransport transport_; + private JsonFactory jsonFactory_; + + public Factory(String applicationName) throws IOException, GeneralSecurityException { + applicationName_ = applicationName; + transport_ = GoogleNetHttpTransport.newTrustedTransport(); + jsonFactory_ = JacksonFactory.getDefaultInstance(); + } + + public Factory(String applicationName, HttpTransport transport, JsonFactory jsonFactory) { + applicationName_ = applicationName; + transport_ = transport; + jsonFactory_ = jsonFactory; + } + + public GcsStorageService newForCredentials(String credentialsPath) throws IOException { + GoogleCredentials credentials = loadCredentials(credentialsPath); + HttpRequestInitializer requestInitializer = new HttpCredentialsAdapter(credentials); + Storage storage = + new Storage.Builder(transport_, jsonFactory_, requestInitializer) + .setApplicationName(applicationName_) + .build(); + + return new GcsStorageService(storage); + } + + private GoogleCredentials loadCredentials(String credentialsPath) throws IOException { + GoogleCredentials credentials; + if (credentialsPath != null && !credentialsPath.isEmpty()) { + FileInputStream stream = new FileInputStream(credentialsPath); + credentials = + GoogleCredentials.fromStream(stream) + .createScoped(Collections.singleton(StorageScopes.DEVSTORAGE_READ_ONLY)); + log.info("Loaded credentials from " + credentialsPath); + } else { + log.info( + "spinnaker.gcs.enabled without spinnaker.gcs.jsonPath. " + + "Using default application credentials. Using default credentials."); + credentials = GoogleCredentials.getApplicationDefault(); + } + return credentials; + } + } + + private Storage storage_; + + public GcsStorageService(Storage storage) { + storage_ = storage; + } + + public InputStream openObjectStream(String bucketName, String path, Long generation) + throws IOException { + Storage.Objects.Get get = storage_.objects().get(bucketName, path); + if (generation != null) { + get.setGeneration(generation); + } + return get.executeMediaAsInputStream(); + } + + public void visitObjects(String bucketName, String pathPrefix, VisitorOperation op) + throws IOException { + Storage.Objects.List listMethod = storage_.objects().list(bucketName); + listMethod.setPrefix(pathPrefix); + Objects objects; + ExecutorService executor = + Executors.newFixedThreadPool( + 8, + new ThreadFactoryBuilder() + .setNameFormat(GcsStorageService.class.getSimpleName() + "-%d") + .build()); + + do { + objects = listMethod.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject obj : items) { + executor.submit( + () -> { + try { + op.visit(obj); + } catch (IOException ioex) { + throw new IllegalStateException(ioex); + } + }); + } + } + listMethod.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + executor.shutdown(); + + try { + if (!executor.awaitTermination(10, TimeUnit.MINUTES)) { + throw new IllegalStateException("Timed out waiting to process StorageObjects."); + } + } catch (InterruptedException intex) { + throw new IllegalStateException(intex); + } + } + + public void visitObjects(String bucketName, VisitorOperation op) throws IOException { + visitObjects(bucketName, "", op); + } + + public void downloadStorageObjectRelative( + StorageObject obj, String ignorePrefix, String baseDirectory) throws IOException { + String objPath = obj.getName(); + if (!ignorePrefix.isEmpty()) { + ignorePrefix += File.separator; + if (!objPath.startsWith(ignorePrefix)) { + throw new IllegalArgumentException(objPath + " does not start with '" + ignorePrefix + "'"); + } + objPath = objPath.substring(ignorePrefix.length()); + } + + // Ignore folder placeholder objects created by Google Console UI + if (objPath.endsWith("/")) { + return; + } + File target = new File(baseDirectory, objPath); + try (InputStream stream = + openObjectStream(obj.getBucket(), obj.getName(), obj.getGeneration())) { + ArtifactUtils.writeStreamToFile(stream, target); + } + target.setLastModified(obj.getUpdated().getValue()); + } + + public void downloadStorageObject(StorageObject obj, String baseDirectory) throws IOException { + downloadStorageObjectRelative(obj, "", baseDirectory); + } +} diff --git a/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/artifacts/config/StorageConfigurationProperties.java b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/artifacts/config/StorageConfigurationProperties.java new file mode 100644 index 00000000000..af5d8f467dc --- /dev/null +++ b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/artifacts/config/StorageConfigurationProperties.java @@ -0,0 +1,52 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.artifacts.config; + +import groovy.transform.ToString; +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import retrofit.client.Response; +import retrofit.mime.TypedByteArray; + +@Data +@ConfigurationProperties("artifacts.gcs") +public class StorageConfigurationProperties { + @Data + @ToString(includeNames = true) + public static class ManagedAccount { + String name; + String jsonPath; + + public static String responseToString(Response response) { + return new String(((TypedByteArray) response.getBody()).getBytes()); + } + } + + ManagedAccount getAccount(String name) { + for (ManagedAccount account : accounts) { + if (account.getName().equals(name)) { + return account; + } + } + throw new NoSuchElementException("Unknown storage account: " + name); + } + + List accounts = new ArrayList(); +} diff --git a/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/config/CloudrunConfigurationProperties.java b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/config/CloudrunConfigurationProperties.java new file mode 100644 index 00000000000..8f005c3edee --- /dev/null +++ b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/config/CloudrunConfigurationProperties.java @@ -0,0 +1,130 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunJobExecutor; +import com.netflix.spinnaker.clouddriver.googlecommon.config.GoogleCommonManagedAccount; +import com.squareup.okhttp.OkHttpClient; +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.util.StringUtils; +import retrofit.RestAdapter; +import retrofit.client.OkClient; +import retrofit.client.Response; +import retrofit.http.GET; +import retrofit.http.Headers; +import retrofit.mime.TypedByteArray; + +@Data +public class CloudrunConfigurationProperties { + private List accounts = new ArrayList<>(); + private String gcloudPath; + + @Data + @EqualsAndHashCode(callSuper = true) + public static class ManagedAccount extends GoogleCommonManagedAccount { + public static final String metadataUrl = "http://metadata.google.internal/computeMetadata/v1"; + + private String serviceAccountEmail; + @EqualsAndHashCode.Exclude private String computedServiceAccountEmail; + private String localRepositoryDirectory = "/var/tmp/clouddriver"; + private String gitHttpsUsername; + private String gitHttpsPassword; + private String githubOAuthAccessToken; + private String sshPrivateKeyFilePath; + private String sshPrivateKeyPassphrase; + private String sshKnownHostsFilePath; + private boolean sshTrustUnknownHosts; + private GcloudReleaseTrack gcloudReleaseTrack; + private List services; + private List versions; + private List omitServices; + private List omitVersions; + private Long cachingIntervalSeconds; + + public void initialize(CloudrunJobExecutor jobExecutor, String gcloudPath) { + if (!StringUtils.isEmpty(getJsonPath())) { + jobExecutor.runCommand( + List.of(gcloudPath, "auth", "activate-service-account", "--key-file", getJsonPath())); + ObjectMapper mapper = new ObjectMapper(); + try { + JsonNode node = mapper.readTree(new File(getJsonPath())); + if (StringUtils.isEmpty(getProject())) { + setProject(node.get("project_id").asText()); + } + if (StringUtils.isEmpty(serviceAccountEmail)) { + this.computedServiceAccountEmail = node.get("client_email").asText(); + } else { + this.computedServiceAccountEmail = serviceAccountEmail; + } + + } catch (Exception e) { + throw new RuntimeException("Could not find read JSON configuration file.", e); + } + } else { + MetadataService metadataService = createMetadataService(); + + try { + if (StringUtils.isEmpty(getProject())) { + setProject(responseToString(metadataService.getProject())); + } + this.computedServiceAccountEmail = + responseToString(metadataService.getApplicationDefaultServiceAccountEmail()); + } catch (Exception e) { + throw new RuntimeException( + "Could not find application default credentials for App Engine.", e); + } + } + } + + static MetadataService createMetadataService() { + OkHttpClient client = new OkHttpClient(); + client.setRetryOnConnectionFailure(true); + RestAdapter restAdapter = + new RestAdapter.Builder() + .setEndpoint(metadataUrl) + .setClient(new OkClient(client)) + .build(); + return restAdapter.create(MetadataService.class); + } + + interface MetadataService { + @Headers("Metadata-Flavor: Google") + @GET("/project/project-id") + Response getProject(); + + @Headers("Metadata-Flavor: Google") + @GET("/instance/service-accounts/default/email") + Response getApplicationDefaultServiceAccountEmail(); + } + + static String responseToString(Response response) { + return new String(((TypedByteArray) response.getBody()).getBytes()); + } + + public enum GcloudReleaseTrack { + ALPHA, + BETA, + STABLE, + } + } +} diff --git a/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/config/CloudrunCredentialsConfiguration.java b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/config/CloudrunCredentialsConfiguration.java new file mode 100644 index 00000000000..d3823a5a37d --- /dev/null +++ b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/config/CloudrunCredentialsConfiguration.java @@ -0,0 +1,119 @@ +/* + * Copyright 2022 OpsMx + * + * 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.clouddriver.cloudrun.config; + +import com.netflix.spinnaker.clouddriver.cloudrun.CloudrunJobExecutor; +import com.netflix.spinnaker.clouddriver.cloudrun.security.CloudrunNamedAccountCredentials; +import com.netflix.spinnaker.clouddriver.security.CredentialsInitializerSynchronizable; +import com.netflix.spinnaker.credentials.CredentialsTypeBaseConfiguration; +import com.netflix.spinnaker.credentials.CredentialsTypeProperties; +import com.netflix.spinnaker.credentials.definition.AbstractCredentialsLoader; +import com.netflix.spinnaker.credentials.poller.Poller; +import com.netflix.spinnaker.kork.configserver.ConfigFileService; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CloudrunCredentialsConfiguration { + private static final Logger log = LoggerFactory.getLogger(CloudrunCredentialsConfiguration.class); + + @Bean + public CredentialsTypeBaseConfiguration< + CloudrunNamedAccountCredentials, CloudrunConfigurationProperties.ManagedAccount> + cloudrunCredentialsProperties( + ApplicationContext applicationContext, + CloudrunConfigurationProperties configurationProperties, + CloudrunJobExecutor jobExecutor, + ConfigFileService configFileService, + String clouddriverUserAgentApplicationName) { + return new CredentialsTypeBaseConfiguration( + applicationContext, + CredentialsTypeProperties + . + builder() + .type(CloudrunNamedAccountCredentials.CREDENTIALS_TYPE) + .credentialsDefinitionClass(CloudrunConfigurationProperties.ManagedAccount.class) + .credentialsClass(CloudrunNamedAccountCredentials.class) + .credentialsParser( + a -> { + try { + String gcloudPath = configurationProperties.getGcloudPath(); + if (StringUtils.isEmpty(gcloudPath)) { + gcloudPath = "gcloud"; + } + a.initialize(jobExecutor, gcloudPath); + + String jsonKey = configFileService.getContents(a.getJsonPath()); + return new CloudrunNamedAccountCredentials.Builder() + .name(a.getName()) + .environment( + StringUtils.isEmpty(a.getEnvironment()) + ? a.getName() + : a.getEnvironment()) + .accountType( + StringUtils.isEmpty(a.getAccountType()) + ? a.getName() + : a.getAccountType()) + .project(a.getProject()) + .jsonKey(jsonKey) + .applicationName(clouddriverUserAgentApplicationName) + .gcloudPath(gcloudPath) + .jsonPath(a.getJsonPath()) + .requiredGroupMembership(a.getRequiredGroupMembership()) + .permissions(a.getPermissions().build()) + .serviceAccountEmail(a.getComputedServiceAccountEmail()) + .localRepositoryDirectory(a.getLocalRepositoryDirectory()) + .gitHttpsUsername(a.getGitHttpsUsername()) + .gitHttpsPassword(a.getGitHttpsPassword()) + .githubOAuthAccessToken(a.getGithubOAuthAccessToken()) + .sshPrivateKeyFilePath(a.getSshPrivateKeyFilePath()) + .sshPrivateKeyPassphrase(a.getSshPrivateKeyPassphrase()) + .sshKnownHostsFilePath(a.getSshKnownHostsFilePath()) + .sshTrustUnknownHosts(a.isSshTrustUnknownHosts()) + .gcloudReleaseTrack(a.getGcloudReleaseTrack()) + .services(a.getServices()) + .versions(a.getVersions()) + .omitServices(a.getOmitServices()) + .omitVersions(a.getOmitVersions()) + .cachingIntervalSeconds(a.getCachingIntervalSeconds()) + .build(); + } catch (Exception e) { + log.info( + String.format("Could not load account %s for App Engine", a.getName()), e); + return null; + } + }) + .defaultCredentialsSource(configurationProperties::getAccounts) + .build()); + } + + @Bean + public CredentialsInitializerSynchronizable cloudrunCredentialsInitializerSynchronizable( + AbstractCredentialsLoader loader) { + final Poller poller = new Poller<>(loader); + return new CredentialsInitializerSynchronizable() { + @Override + public void synchronize() { + poller.run(); + } + }; + } +} diff --git a/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/deploy/CloudrunMutexRepository.java b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/deploy/CloudrunMutexRepository.java new file mode 100644 index 00000000000..dd874ec5c0c --- /dev/null +++ b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/deploy/CloudrunMutexRepository.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.deploy; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; + +public class CloudrunMutexRepository { + private static final ConcurrentHashMap mutexRepository = new ConcurrentHashMap<>(); + + public static T atomicWrapper(String mutexKey, Supplier doOperation) + throws InterruptedException { + final Lock lock = mutexRepository.computeIfAbsent(mutexKey, k -> new ReentrantLock()); + + lock.lockInterruptibly(); + try { + return doOperation.get(); + } finally { + lock.unlock(); + } + } +} diff --git a/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/deploy/exception/CloudrunOperationException.java b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/deploy/exception/CloudrunOperationException.java new file mode 100644 index 00000000000..9f8301a39a2 --- /dev/null +++ b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/deploy/exception/CloudrunOperationException.java @@ -0,0 +1,26 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.deploy.exception; + +public class CloudrunOperationException extends RuntimeException { + public CloudrunOperationException(String message) { + super(message); + } + + public CloudrunOperationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunCredentials.java b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunCredentials.java new file mode 100644 index 00000000000..dd6839da48f --- /dev/null +++ b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunCredentials.java @@ -0,0 +1,46 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.security; + +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.appengine.v1.Appengine; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; +import com.netflix.spinnaker.clouddriver.googlecommon.security.GoogleCommonCredentials; + +public class CloudrunCredentials extends GoogleCommonCredentials { + + private final String project; + + public CloudrunCredentials(String project) { + this.project = project; + } + + public Appengine getAppengine(String applicationName) { + HttpTransport httpTransport = buildHttpTransport(); + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + GoogleCredentials credentials = getCredentials(); + HttpRequestInitializer requestInitializer = new HttpCredentialsAdapter(credentials); + + return new Appengine.Builder(httpTransport, jsonFactory, requestInitializer) + .setApplicationName(applicationName) + .build(); + } +} diff --git a/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunCredentialsLifecycleHandler.java b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunCredentialsLifecycleHandler.java new file mode 100644 index 00000000000..e85a506748d --- /dev/null +++ b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunCredentialsLifecycleHandler.java @@ -0,0 +1,66 @@ +/* + * Copyright 2022 OpsMx + * + * 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.clouddriver.cloudrun.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.netflix.spectator.api.Registry; +import com.netflix.spinnaker.clouddriver.cloudrun.provider.CloudrunProvider; +import com.netflix.spinnaker.clouddriver.cloudrun.provider.agent.CloudrunLoadBalancerCachingAgent; +import com.netflix.spinnaker.clouddriver.cloudrun.provider.agent.CloudrunPlatformApplicationCachingAgent; +import com.netflix.spinnaker.clouddriver.cloudrun.provider.agent.CloudrunServerGroupCachingAgent; +import com.netflix.spinnaker.credentials.CredentialsLifecycleHandler; +import java.util.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CloudrunCredentialsLifecycleHandler + implements CredentialsLifecycleHandler { + + private final CloudrunProvider cloudrunCloudProvider; + private final ObjectMapper objectMapper; + private final Registry registry; + + @Override + public void credentialsAdded(CloudrunNamedAccountCredentials credentials) { + addAgentFor(credentials); + } + + @Override + public void credentialsUpdated(CloudrunNamedAccountCredentials credentials) { + cloudrunCloudProvider.removeAgentsForAccounts(Collections.singleton(credentials.getName())); + addAgentFor(credentials); + } + + @Override + public void credentialsDeleted(CloudrunNamedAccountCredentials credentials) { + cloudrunCloudProvider.removeAgentsForAccounts(Collections.singleton(credentials.getName())); + } + + private void addAgentFor(CloudrunNamedAccountCredentials credentials) { + cloudrunCloudProvider.addAgents( + List.of( + new CloudrunServerGroupCachingAgent( + credentials.getName(), credentials, objectMapper, registry), + new CloudrunLoadBalancerCachingAgent( + credentials.getName(), credentials, objectMapper, registry), + new CloudrunPlatformApplicationCachingAgent( + credentials.getName(), credentials, objectMapper))); + } +} diff --git a/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunJsonCredentials.java b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunJsonCredentials.java new file mode 100644 index 00000000000..99c81f2679c --- /dev/null +++ b/clouddriver-cloudrun/src/main/java/com/netflix/spinnaker/clouddriver/cloudrun/security/CloudrunJsonCredentials.java @@ -0,0 +1,40 @@ +/* + * Copyright 2022 OpsMx 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.clouddriver.cloudrun.security; + +import com.google.api.services.appengine.v1.AppengineScopes; +import com.google.auth.oauth2.GoogleCredentials; +import com.netflix.spinnaker.clouddriver.googlecommon.security.GoogleCommonCredentialUtils; + +public class CloudrunJsonCredentials extends CloudrunCredentials { + + private final String jsonKey; + + public CloudrunJsonCredentials(String project, String jsonKey) { + super(project); + this.jsonKey = jsonKey; + } + + @Override + public GoogleCredentials getCredentials() { + return GoogleCommonCredentialUtils.getCredentials(jsonKey, AppengineScopes.CLOUD_PLATFORM); + } + + public final String getJsonKey() { + return jsonKey; + } +}