# Copyright 2020 The Nomulus Authors. All Rights Reserved. # # 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. """Definition of rollback steps and factory methods to create them.""" import dataclasses import subprocess import textwrap from typing import Tuple import appengine import common @dataclasses.dataclass(frozen=True) class RollbackStep: """One rollback step. Most steps are implemented using commandline tools, e.g., gcloud and gsutil, and execute their commands by forking a subprocess. Each step also has a info method that returns its command with a description. Two steps are handled differently. The _UpdateDeployTag step gets a piped shell command, which needs to be handled differently. The _SetManualScalingNumInstances step uses the AppEngine Admin API client in this package to set the number of instances. """ description: str command: Tuple[str, ...] def info(self) -> str: return f'# {self.description}\n' f'{" ".join(self.command)}' def execute(self) -> None: """Executes the step. Raises: CannotRollbackError if command fails. """ if subprocess.call(self.command) != 0: raise common.CannotRollbackError(f'Failed: {self.description}') def check_schema_compatibility(dev_project: str, nom_tag: str, sql_tag: str) -> RollbackStep: return RollbackStep(description='Check compatibility with SQL schema.', command=(f'{common.get_nomulus_root()}/nom_build', ':integration:sqlIntegrationTest', f'--schema_version={sql_tag}', f'--nomulus_version={nom_tag}', '--publish_repo=' f'gcs://{dev_project}-deployed-tags/maven')) @dataclasses.dataclass(frozen=True) class _SetManualScalingNumInstances(RollbackStep): """Sets the number of instances for a manual scaling version. The Nomulus set_num_instances command is currently broken. This step uses the AppEngine REST API to update the version. """ appengine_admin: appengine.AppEngineAdmin version: common.VersionKey num_instance: int def execute(self) -> None: self.appengine_admin.set_manual_scaling_num_instance( self.version.service_id, self.version.version_id, self.num_instance) def set_manual_scaling_instances(appengine_admin: appengine.AppEngineAdmin, version: common.VersionConfig, num_instances: int) -> RollbackStep: cmd_description = textwrap.dedent("""\ Nomulus set_num_instances command is currently broken. This script uses the AppEngine REST API to update the version. To set this value without using this tool, you may use the REST API at https://cloud.google.com/appengine/docs/admin-api/reference/rest/v1beta/apps.services.versions/patch """) return _SetManualScalingNumInstances( f'Set number of instance for manual-scaling version ' f'{version.version_id} in {version.service_id} to {num_instances}.', (cmd_description, ''), appengine_admin, version, num_instances) def start_or_stop_version(project: str, action: str, version: common.VersionKey) -> RollbackStep: """Creates a rollback step that starts or stops an AppEngine version. Args: project: The GCP project of the AppEngine application. action: Start or Stop. version: The version being managed. """ return RollbackStep( f'{action.title()} {version.version_id} in {version.service_id}', ('gcloud', 'app', 'versions', action, version.version_id, '--quiet', '--service', version.service_id, '--project', project)) def direct_service_traffic_to_version( project: str, version: common.VersionKey) -> RollbackStep: return RollbackStep( f'Direct all traffic to {version.version_id} in {version.service_id}.', ('gcloud', 'app', 'services', 'set-traffic', version.service_id, '--quiet', f'--splits={version.version_id}=1', '--project', project)) @dataclasses.dataclass(frozen=True) class KillNomulusInstance(RollbackStep): """Step that kills a Nomulus VM instance.""" instance_name: str # yapf: disable def kill_nomulus_instance(project: str, version: common.VersionKey, instance_name: str) -> KillNomulusInstance: # yapf: enable return KillNomulusInstance( 'Delete one VM instance.', ('gcloud', 'app', 'instances', 'delete', instance_name, '--quiet', '--user-output-enabled=false', '--service', version.service_id, '--version', version.version_id, '--project', project), instance_name) @dataclasses.dataclass(frozen=True) class _UpdateDeployTag(RollbackStep): """Updates the deployment tag on GCS.""" nom_tag: str destination: str def execute(self) -> None: with subprocess.Popen(('gsutil', 'cp', '-', self.destination), stdin=subprocess.PIPE) as p: try: p.communicate(self.nom_tag.encode('utf-8')) if p.wait() != 0: raise common.CannotRollbackError( f'Failed: {self.description}') except: p.kill() raise def update_deploy_tags(dev_project: str, env: str, nom_tag: str) -> RollbackStep: destination = f'gs://{dev_project}-deployed-tags/nomulus.{env}.tag' return _UpdateDeployTag( f'Update Nomulus tag in {env}', (f'echo {nom_tag} | gsutil cp - {destination}', ''), nom_tag, destination) def sync_live_release(dev_project: str, nom_tag: str) -> RollbackStep: """Syncs the target release artifacts to the live folder. By convention the gs://{dev_project}-deploy/live folder should contain the artifacts from the currently serving release. For Domain Registry team members, this step updates the nomulus tool installed on corp desktops. """ artifacts_folder = f'gs://{dev_project}-deploy/{nom_tag}' live_folder = f'gs://{dev_project}-deploy/live' return RollbackStep( f'Syncing {artifacts_folder} to {live_folder}.', ('gsutil', '-m', 'rsync', '-d', artifacts_folder, live_folder))