class Restore(ConfigCommand): def __init__(self, ssm_init: SsmDao, kms_init: KmsService, config_init: ConfigDao, repl_dao: ReplicationDao, audit_dao: AuditDao, cfg_view: RBACLimitedConfigView, colors_enabled: bool, context: ConfigContext, config_completer: WordCompleter, delete: Delete): super().__init__(restore, colors_enabled, context) self._config_context = context self._ssm = ssm_init self._kms = kms_init self._config = config_init self._repl = repl_dao self._audit = audit_dao self._cfg_view = cfg_view self._utils = Utils(colors_enabled) self._point_in_time = context.point_in_time self._config_completer = config_completer self._delete = delete self._out = Output(colors_enabled=colors_enabled) def _client_exception_msg(self, item: RestoreConfig, e: ClientError): if "AccessDeniedException" == e.response["Error"]["Code"]: self._out.error( f"\n\nYou do not have permissions to restore config at the path: [[{item.ps_name}]]" ) else: self._out.error( f"Error message: [[{e.response['Error']['Message']}]]") def get_parameter_arn(self, parameter_name: str): account_id = self._ssm.get_parameter(ACCOUNT_ID_PATH) return f"arn:aws:ssm:us-east-1:{account_id}:parameter{parameter_name}" def _restore_param(self) -> None: """ Allow the user to query a parameter store entry from dynamo, so we can query + restore it, if desired. """ table_entries = [] ps_name = prompt(f"Please input PS key to restore: ", completer=self._config_completer) if self._is_replication_destination(ps_name): repl_conf = self._repl.get_config_repl(ps_name) self._print_cannot_restore_msg(repl_conf) exit(0) self._out.notify( f"\n\nAttempting to retrieve all restorable values of [[{ps_name}]]" ) items: List[RestoreConfig] = self._audit.get_parameter_restore_details( ps_name) if len(items) == 0: self._out.warn( "No restorable values were found for this parameter.") return for i, item in enumerate(items): date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(item.ps_time / 1000)) # we need to decrypt the value, if encrypted, in order to show it to the user if item.ps_key_id: item.ps_value = self._kms.decrypt_with_context( item.ps_value, {"PARAMETER_ARN": self.get_parameter_arn(item.ps_name)}, ) table_entries.append([i, date, item.ps_value, item.ps_user]) self._out.print( tabulate( table_entries, headers=["Item #", "Time Created", "Value", "User"], tablefmt="grid", numalign="center", stralign="left", )) valid_options = [f'{x}' for x in range(0, len(items))] choice = int( Input.select("Select an item number to restore: ", valid_options=valid_options)) item = items[choice] if items[choice] else None restore = Input.y_n_input( f"Are you sure you want to restore item #{choice} and have it be the latest version? ", default_yes=False) if not restore: self._utils.warn_exit("Restore aborted.") key_id = None if item.ps_type == "String" else item.ps_key_id try: self._ssm.set_parameter(item.ps_name, item.ps_value, item.ps_description, item.ps_type, key_id=key_id) current_value = self._ssm.get_parameter(item.ps_name) if current_value == item.ps_value: self._out.success("Restore was successful") else: self._out.error( "Latest version in parameter store doesn't match what we restored." ) self._out.print( f"Current value: [[{current_value}]]. Expected value: [[{item.ps_value}]]" ) except ClientError as e: self._client_exception_msg(item, e) def _decrypt_if_applicable(self, entry: RestoreConfig) -> str: if entry.ps_type != "String": return self._kms.decrypt_with_context( entry.ps_value, {"PARAMETER_ARN": self.get_parameter_arn(entry.ps_name)}) else: return entry.ps_value def _is_replication_destination(self, ps_name: str): return self._repl.get_config_repl(ps_name) def _restore_params_to_point_in_time(self): """ Restores parameters as they were to a point-in-time as defined by the time provided by the users. Replays parameter history to that point-in-time so versioning remains intact. """ repl_destinations = [] ps_prefix = Input.input( f"Which parameter store prefix would you like to recursively restore? " f"(e.g., /app/demo-time): ", completer=self._config_completer) authed_nses = self._cfg_view.get_authorized_namespaces() valid_prefix = ( [True for ns in authed_nses if ps_prefix.startswith(ns)] or [False])[0] self._utils.validate( valid_prefix, f"Selected namespace must begin with a 'Fig Tree' you have access to. " f"Such as: {authed_nses}") time_selected, time_converted = None, None try: time_selected = Input.input( "Seconds since epoch to restore latest values from: ") time_converted = datetime.fromtimestamp(float(time_selected)) except ValueError as e: if "out of range" in e.args[0]: try: time_converted = datetime.fromtimestamp( float(time_selected) / 1000) except ValueError as e: self._utils.error_exit( "Make sure you're using a format of either seconds or milliseconds since epoch." ) elif "could not convert" in e.args[0]: self._utils.error_exit( f"The format of this input should be seconds since epoch. (e.g., 1547647091)\n" f"Try using: https://www.epochconverter.com/ to convert your date to this " f"specific format.") else: self._utils.error_exit( "An unexpected exception triggered: " f"'{e}' while trying to convert {time_selected} to 'datetime' format." ) self._utils.validate( time_converted is not None, f"`{CLI_NAME}` encountered an error parsing your input for " f"target rollback time.") keep_going = Input.y_n_input( f"Are you sure you want to restore all figs under {ps_prefix} values to their state at: " f"{time_converted}? ", default_yes=False) if not keep_going: self._utils.warn_exit("Aborting restore due to user selection") ps_history: PSHistory = self._audit.get_parameter_history_before_time( time_converted, ps_prefix) restore_count = len(ps_history.history.values()) if len(ps_history.history.values()) == 0: self._utils.warn_exit( "No results found for time range. Aborting.") last_item_name = 'Unknown' try: for item in ps_history.history.values(): last_item_name = item.name if self._is_replication_destination(item.name): repl_destinations.append(item.name) continue if item.cfg_at(time_converted).ps_action == SSM_PUT: cfgs_before: List[RestoreConfig] = item.cfgs_before( time_converted) cfg_at: RestoreConfig = item.cfg_at(time_converted) ssm_value = self._ssm.get_parameter(item.name) dynamo_value = self._decrypt_if_applicable(cfg_at) if ssm_value != dynamo_value: if ssm_value is not None: self._ssm.delete_parameter(item.name) for cfg in cfgs_before: decrypted_value = self._decrypt_if_applicable(cfg) self._out.print( f"\nRestoring: [[{cfg.ps_name}]] \nValue: [[{decrypted_value}]]" f"\nDescription: [[{cfg.ps_description}]]\nKMS Key: " f"[[{cfg.ps_key_id if cfg.ps_key_id else '[[No KMS Key Specified]]'}]]" ) self._out.notify( f"Replaying version: [[{cfg.ps_version}]] of [[{cfg.ps_name}]]" ) print() self._ssm.set_parameter(cfg.ps_name, decrypted_value, cfg.ps_description, cfg.ps_type, key_id=cfg.ps_key_id) else: self._out.success( f"Config: {item.name} is current. Skipping.") else: # This item must have been a delete, which means this config didn't exist at that time. self._out.print( f"Checking if [[{item.name}]] exists. It was previously deleted." ) self._prompt_delete(item.name) except ClientError as e: if "AccessDeniedException" == e.response["Error"]["Code"]: self._utils.error_exit( f"\n\nYou do not have permissions to restore config at the path:" f" [[{last_item_name}]]") else: self._utils.error_exit( f"Caught error when attempting restore. {e}") for item in repl_destinations: cfg = self._repl.get_config_repl(item) self._print_cannot_restore_msg(cfg) print("\n\n") if not repl_destinations: self._out.success_h2( f"[[{restore_count}]] configurations restored successfully!") else: self._out.warn( f"\n\n[[{len(repl_destinations)}]] configurations were not restored because they are shared " f"from other destinations. To restore them, restore their sources." ) self._out.success( f"{restore_count - len(repl_destinations)} configurations restored successfully." ) def _print_cannot_restore_msg(self, repl_conf: ReplicationConfig): self._out.print( f"Parameter: [[{repl_conf.destination}]] is a shared parameter. ") self._out.print(f"Shared From: [[{repl_conf.source}]]") self._out.print(f"Shared by: [[{repl_conf.user}]]") self._out.warn( f"To restore this parameter you should restore the source: {repl_conf.source} instead!" ) print() def _prompt_delete(self, name): param = self._ssm.get_parameter_encrypted(name) if param: selection = Input.y_n_input( f"PS Name: {name} did not exist at this restore time." f" Delete it? ", default_yes=False) if selection: self._delete.delete_param(name) @VersionTracker.notify_user @AnonymousUsageTracker.track_command_usage def execute(self): if self._point_in_time: self._restore_params_to_point_in_time() else: self._restore_param()
class Upgrade(MaintenanceCommand): """ Drives the --version command """ def __init__(self, maintenance_context: MaintenanceContext, config_service: Optional[ConfigService]): super().__init__(version, maintenance_context.defaults.colors_enabled, maintenance_context) self.tracker = VersionTracker(self.context.defaults, config_service) self.upgrade_mgr = UpgradeManager( maintenance_context.defaults.colors_enabled) self._utils = Utils( colors_enabled=maintenance_context.defaults.colors_enabled) self._out = Output( colors_enabled=maintenance_context.defaults.colors_enabled) def upgrade(self): latest_version: FiggyVersionDetails = self.tracker.get_version() install_success, upgrade_it = False, True if self.upgrade_mgr.is_pip_install(): self._out.error( f"Figgy appears to have been installed with pip. Please upgrade [[{CLI_NAME}]] with " f"`pip` instead.") self._out.print( f"\n\n[[Try this command]]: pip install figgy-cli --upgrade") self._out.print( f"\n\nPip based [[{CLI_NAME}]] installations do not support automatic upgrades and " f"instead require pip-managed upgrades; however, Homebrew, one-line, and manual " f"installations support auto-upgrade. Please consider installing figgy through one " f"of these other methods to take advantage of this feature. " f"It will save you time, help keep you up-to-date, and enable important features like " f"release-rollbacks and canary releases! " f"[[https://www.figgy.dev/docs/getting-started/install/]]") sys.exit(0) install_path = self.upgrade_mgr.install_path if not install_path: self._utils.error_exit( f"Unable to detect local figgy installation. Please reinstall figgy and follow one " f"of the recommended installation procedures.") if latest_version.version == VERSION: self._out.success( f'You are currently using the latest version of [[{CLI_NAME}]]: [[{VERSION}]]' ) upgrade_it = False elif self.tracker.upgrade_available(): self._out.notify_h2( f"New version: [[{latest_version.version}]] is more recent than your version: [[{VERSION}]]" ) upgrade_it = True elif not self.tracker.cloud_version_compatible_with_upgrade(): self._out.notify_h2( f"Version [[{self.tracker.get_version().version}]] of the Figgy CLI is available but your " f"current version of Figgy Cloud ([[{self.tracker.current_cloud_version()}]]) is not compatible." f" Your administrator must first update FiggyCloud to at least version: " f"[[{self.tracker.required_cloud_version()}]] before you can upgrade Figgy." ) upgrade_it = False else: self._out.notify_h2( f"Your version: [[{VERSION}]] is more recent then the current recommended version " f"of {CLI_NAME}: [[{latest_version.version}]]") upgrade_it = Input.y_n_input( f'Would you like to revert to the current recommended version ' f'of {CLI_NAME}?') if upgrade_it: if self._utils.is_mac(): self._out.print( f"\nMacOS auto-upgrade is supported. Performing auto-upgrade." ) install_success = self.install_mac(latest_version) elif self._utils.is_linux(): self._out.print( f"\nLinux auto-upgrade is supported. Performing auto-upgrade." ) install_success = self.install_linux(latest_version) elif self._utils.is_windows(): self._out.print( f"\nWindows auto-upgrade is supported. Performing auto-upgrade." ) install_success = self.install_windows(latest_version) if install_success: self._out.success( f"Installation successful! Exiting. Rerun `[[{CLI_NAME}]]` " f"to use the latest version!") else: self._out.warn( f"\nUpgrade may not have been successful. Check by re-running " f"[[`{CLI_NAME}` --version]] to see if it was. If it wasn't, please reinstall [[`{CLI_NAME}`]]. " f"See {INSTALL_URL}.") def install_mac(self, latest_version: FiggyVersionDetails) -> bool: install_path = '/usr/local/bin/figgy' if self.upgrade_mgr.is_brew_install(): self._out.notify_h2(f"Homebrew installation detected!") print( f"This upgrade process will not remove your brew installation but will instead unlink it. " f"Going forward you will no longer need homebrew to manage {CLI_NAME}. Continuing is recommended.\n" ) selection = Input.y_n_input(f"Continue? ", default_yes=True) else: selection = True if selection: self.upgrade_mgr.install_onedir(install_path, latest_version.version, MAC) return True else: self._out.print( f'\n[[Auto-upgrade aborted. To upgrade through brew run:]] \n' f'-> brew upgrade figtools/figgy/figgy') self._out.warn( f"\n\nYou may continue to manage [[{CLI_NAME}]] through Homebrew, but doing so will " f"limit some upcoming functionality around canary releases, rollbacks, and dynamic " f"version-swapping.") return False def install_linux(self, latest_version: FiggyVersionDetails) -> bool: install_path = self.upgrade_mgr.install_path self.upgrade_mgr.install_onedir(install_path, latest_version.version, LINUX) return True def install_windows(self, latest_version: FiggyVersionDetails) -> bool: install_path = self.upgrade_mgr.install_path self.upgrade_mgr.install_onedir(install_path, latest_version.version, WINDOWS) return True @AnonymousUsageTracker.track_command_usage def execute(self): self.upgrade()
class Promote(ConfigCommand): def __init__(self, source_ssm: SsmDao, config_completer_init: WordCompleter, colors_enabled: bool, config_context: ConfigContext, session_mgr: SessionManager): super().__init__(promote, colors_enabled, config_context) self.config_context = config_context self._source_ssm = source_ssm self._session_mgr = session_mgr self._config_completer = config_completer_init self._utils = Utils(colors_enabled) self._out = Output(colors_enabled) def _promote(self): repeat = True parameters: List[Dict] = [] while repeat: namespace = Input.input("Please input a namespace prefix to promote:" f" (i.e. {self.context.defaults.service_ns}/foo/): ", completer=self._config_completer) if not self._utils.is_valid_input(namespace, "namespace", notify=False): continue try: parameters: List[Dict] = self._source_ssm.get_all_parameters([namespace]) if not parameters and self._source_ssm.get_parameter(namespace): parameters, latest_version = self._source_ssm.get_parameter_details(namespace) parameters = list(parameters) if parameters: repeat = False else: self._out.warn("\nNo parameters found. Try again.\n") except ClientError as e: print(f"{self.c.fg_rd}ERROR: >> {e}{self.c.rs}") continue self._out.notify(f'\nFound [[{len(parameters)}]] parameter{"s" if len(parameters) > 1 else ""} to migrate.\n') assumable_roles = self.context.defaults.assumable_roles matching_roles = list(set([x for x in assumable_roles if x.role == self.config_context.role])) valid_envs = set([x.run_env.env for x in matching_roles]) valid_envs.remove(self.run_env.env) # Remove current env, we can't promote from dev -> dev next_env = Input.select(f'Please select the destination environment.', valid_options=list(valid_envs)) matching_role = [role for role in matching_roles if role.run_env == RunEnv(env=next_env)][0] env: GlobalEnvironment = GlobalEnvironment(role=matching_role, region=self.config_context.defaults.region) dest_ssm = SsmDao(self._session_mgr.get_session(env, prompt=False).client('ssm')) for param in parameters: if 'KeyId' in param: self._out.print(f"Skipping param: [[{param['Name']}]]. It is encrypted and cannot be migrated.") else: promote_it = Input.y_n_input(f"Would you like to promote: {param['Name']}?", default_yes=True) if promote_it: val = self._source_ssm.get_parameter(param['Name']) description = param.get('Description', "") dest_ssm.set_parameter(param['Name'], val, description, SSM_STRING) self._out.success(f"Successfully promoted [[{param['Name']}]] to [[{next_env}]].\r\n") @VersionTracker.notify_user @AnonymousUsageTracker.track_command_usage def execute(self): self._promote()
class Validate(ConfigCommand): def __init__(self, ssm_init: SsmDao, colors_enabled: bool, context: ConfigContext): super().__init__(validate, colors_enabled, context) self._ssm = ssm_init self._config_path = context.ci_config_path if context.ci_config_path else Utils.find_figgy_json( ) self._utils = Utils(colors_enabled) self._replication_only = context.replication_only self._errors_detected = False self.example = f"{self.c.fg_bl}{CLI_NAME} config {self.command_printable} " \ f"--env dev --config /path/to/config{self.c.rs}" self._FILE_PREFIX = "file://" self._out = Output(colors_enabled) def _validate(self): missing_key = False config = self._utils.get_ci_config(self._config_path) shared_names = set( self._utils.get_config_key_safe(SHARED_KEY, config, default=[])) repl_conf = self._utils.get_config_key_safe(REPLICATION_KEY, config, default={}) repl_from_conf = self._utils.get_config_key_safe(REPL_FROM_KEY, config, default={}) merge_conf = self._utils.get_config_key_safe(MERGE_KEY, config, default={}) config_keys = set( self._utils.get_config_key_safe(CONFIG_KEY, config, default=[])) namespace = self._utils.get_namespace(config) all_names = KeyUtils.find_all_expected_names(config_keys, shared_names, merge_conf, repl_conf, repl_from_conf, namespace) all_params = self._ssm.get_all_parameters([namespace]) all_param_names = [] for param in all_params: all_param_names.append(param['Name']) print() for name in all_names: if name not in all_param_names: self._out.warn( f"Fig missing from [[{self.run_env}]] environment Parameter Store: [[{name}]]" ) missing_key = True else: self._out.print(f"Fig found in ParameterStore: [[{name}]].") if missing_key: print("\n\n") self._utils.error_exit(f"{MISSING_PS_NAME_MESSAGE}") else: self._out.success( f"\nSuccess! All figs have been located in the [[{self.run_env}]] ParameterStore!" ) @VersionTracker.notify_user @AnonymousUsageTracker.track_command_usage def execute(self): self._validate()