From 75ff60752af8f33771664dec0e206632953c12cc Mon Sep 17 00:00:00 2001 From: Thomas E Lackey Date: Wed, 21 Aug 2024 14:39:20 +0000 Subject: [PATCH] Require payment for app deployment requests. (#928) Adds three new options for deployment/undeployment: ``` "--min-required-payment", help="Requests must have a minimum payment to be processed", "--payment-address", help="The address to which payments should be made. Default is the current laconic account.", "--all-requests", help="Handle requests addressed to anyone (by default only requests to my payment address are examined).", ``` In this mode, requests should be designated for a particular address with the attribute `to` and include a `payment` attribute which is the tx hash for the payment. The deployer will confirm the payment (to the right account, right amount, not used before, etc.) and then proceed with the deployment or undeployment. Reviewed-on: https://git.vdb.to/cerc-io/stack-orchestrator/pulls/928 Reviewed-by: David Boreham Co-authored-by: Thomas E Lackey Co-committed-by: Thomas E Lackey --- .../webapp/deploy_webapp_from_registry.py | 65 ++- .../webapp/undeploy_webapp_from_registry.py | 242 ++++++++--- stack_orchestrator/deploy/webapp/util.py | 399 +++++++++++++++--- 3 files changed, 584 insertions(+), 122 deletions(-) diff --git a/stack_orchestrator/deploy/webapp/deploy_webapp_from_registry.py b/stack_orchestrator/deploy/webapp/deploy_webapp_from_registry.py index 6948e13b..f8dd796f 100644 --- a/stack_orchestrator/deploy/webapp/deploy_webapp_from_registry.py +++ b/stack_orchestrator/deploy/webapp/deploy_webapp_from_registry.py @@ -38,6 +38,7 @@ from stack_orchestrator.deploy.webapp.util import ( generate_hostname_for_app, match_owner, skip_by_tag, + confirm_payment, ) @@ -54,6 +55,7 @@ def process_app_deployment_request( force_rebuild, fqdn_policy, recreate_on_deploy, + payment_address, logger, ): logger.log("BEGIN - process_app_deployment_request") @@ -78,6 +80,9 @@ def process_app_deployment_request( else: fqdn = f"{requested_name}.{default_dns_suffix}" + # Normalize case (just in case) + fqdn = fqdn.lower() + # 3. check ownership of existing dnsrecord vs this request dns_lrn = f"{dns_record_namespace}/{fqdn}" dns_record = laconic.get_record(dns_lrn) @@ -119,7 +124,7 @@ def process_app_deployment_request( app_deployment_lrn = app_deployment_request.attributes.deployment if not app_deployment_lrn.startswith(deployment_record_namespace): raise Exception( - "Deployment CRN %s is not in a supported namespace" + "Deployment LRN %s is not in a supported namespace" % app_deployment_request.attributes.deployment ) @@ -222,6 +227,7 @@ def process_app_deployment_request( dns_lrn, deployment_dir, app_deployment_request, + payment_address, logger, ) logger.log("Publication complete.") @@ -305,6 +311,23 @@ def dump_known_requests(filename, requests, status="SEEN"): @click.option( "--log-dir", help="Output build/deployment logs to directory.", default=None ) +@click.option( + "--min-required-payment", + help="Requests must have a minimum payment to be processed", + default=0, +) +@click.option( + "--payment-address", + help="The address to which payments should be made. " + "Default is the current laconic account.", + default=None, +) +@click.option( + "--all-requests", + help="Handle requests addressed to anyone (by default only requests to" + "my payment address are examined).", + is_flag=True, +) @click.pass_context def command( # noqa: C901 ctx, @@ -326,6 +349,9 @@ def command( # noqa: C901 force_rebuild, recreate_on_deploy, log_dir, + min_required_payment, + payment_address, + all_requests, ): if request_id and discover: print("Cannot specify both --request-id and --discover", file=sys.stderr) @@ -366,6 +392,10 @@ def command( # noqa: C901 exclude_tags = [tag.strip() for tag in exclude_tags.split(",") if tag] laconic = LaconicRegistryClient(laconic_config, log_file=sys.stderr) + if not payment_address: + payment_address = laconic.whoami().address + + main_logger.log(f"Payment address: {payment_address}") # Find deployment requests. # single request @@ -375,18 +405,20 @@ def command( # noqa: C901 # all requests elif discover: main_logger.log("Discovering deployment requests...") - requests = laconic.app_deployment_requests() + if all_requests: + requests = laconic.app_deployment_requests() + else: + requests = laconic.app_deployment_requests({"to": payment_address}) if only_update_state: if not dry_run: dump_known_requests(state_file, requests) return + previous_requests = {} if state_file: main_logger.log(f"Loading known requests from {state_file}...") previous_requests = load_known_requests(state_file) - else: - previous_requests = {} # Collapse related requests. requests.sort(key=lambda r: r.createTime) @@ -452,7 +484,10 @@ def command( # noqa: C901 # Find deployments. main_logger.log("Discovering existing app deployments...") - deployments = laconic.app_deployments() + if all_requests: + deployments = laconic.app_deployments() + else: + deployments = laconic.app_deployments({"by": payment_address}) deployments_by_request = {} for d in deployments: if d.attributes.request: @@ -466,7 +501,7 @@ def command( # noqa: C901 if r.attributes.request: cancellation_requests[r.attributes.request] = r - requests_to_execute = [] + requests_to_check_for_payment = [] for r in requests_by_name.values(): if r.id in cancellation_requests and match_owner( cancellation_requests[r.id], r @@ -488,7 +523,24 @@ def command( # noqa: C901 ) else: main_logger.log(f"Request {r.id} needs to processed.") + requests_to_check_for_payment.append(r) + + requests_to_execute = [] + if min_required_payment: + for r in requests_to_check_for_payment: + main_logger.log(f"{r.id}: Confirming payment...") + if confirm_payment( + laconic, r, payment_address, min_required_payment, main_logger + ): + main_logger.log(f"{r.id}: Payment confirmed.") requests_to_execute.append(r) + else: + main_logger.log( + f"Skipping request {r.id}: unable to verify payment." + ) + dump_known_requests(state_file, [r], status="UNPAID") + else: + requests_to_execute = requests_to_check_for_payment main_logger.log( "Found %d unsatisfied request(s) to process." % len(requests_to_execute) @@ -531,6 +583,7 @@ def command( # noqa: C901 force_rebuild, fqdn_policy, recreate_on_deploy, + payment_address, build_logger, ) status = "DEPLOYED" diff --git a/stack_orchestrator/deploy/webapp/undeploy_webapp_from_registry.py b/stack_orchestrator/deploy/webapp/undeploy_webapp_from_registry.py index 8585283e..7dd6a9d5 100644 --- a/stack_orchestrator/deploy/webapp/undeploy_webapp_from_registry.py +++ b/stack_orchestrator/deploy/webapp/undeploy_webapp_from_registry.py @@ -20,18 +20,33 @@ import sys import click -from stack_orchestrator.deploy.webapp.util import LaconicRegistryClient, match_owner, skip_by_tag +from stack_orchestrator.deploy.webapp.util import ( + TimedLogger, + LaconicRegistryClient, + match_owner, + skip_by_tag, + confirm_payment, +) + +main_logger = TimedLogger(file=sys.stderr) -def process_app_removal_request(ctx, - laconic: LaconicRegistryClient, - app_removal_request, - deployment_parent_dir, - delete_volumes, - delete_names): - deployment_record = laconic.get_record(app_removal_request.attributes.deployment, require=True) +def process_app_removal_request( + ctx, + laconic: LaconicRegistryClient, + app_removal_request, + deployment_parent_dir, + delete_volumes, + delete_names, + payment_address, +): + deployment_record = laconic.get_record( + app_removal_request.attributes.deployment, require=True + ) dns_record = laconic.get_record(deployment_record.attributes.dns, require=True) - deployment_dir = os.path.join(deployment_parent_dir, dns_record.attributes.name) + deployment_dir = os.path.join( + deployment_parent_dir, dns_record.attributes.name.lower() + ) if not os.path.exists(deployment_dir): raise Exception("Deployment directory %s does not exist." % deployment_dir) @@ -41,13 +56,18 @@ def process_app_removal_request(ctx, # Or of the original deployment request. if not matched_owner and deployment_record.attributes.request: - matched_owner = match_owner(app_removal_request, laconic.get_record(deployment_record.attributes.request, require=True)) + matched_owner = match_owner( + app_removal_request, + laconic.get_record(deployment_record.attributes.request, require=True), + ) if matched_owner: - print("Matched deployment ownership:", matched_owner) + main_logger.log("Matched deployment ownership:", matched_owner) else: - raise Exception("Unable to confirm ownership of deployment %s for removal request %s" % - (deployment_record.id, app_removal_request.id)) + raise Exception( + "Unable to confirm ownership of deployment %s for removal request %s" + % (deployment_record.id, app_removal_request.id) + ) # TODO(telackey): Call the function directly. The easiest way to build the correct click context is to # exec the process, but it would be better to refactor so we could just call down_operation with the @@ -64,8 +84,13 @@ def process_app_removal_request(ctx, "version": "1.0.0", "request": app_removal_request.id, "deployment": deployment_record.id, + "by": payment_address, } } + + if app_removal_request.attributes.payment: + removal_record["record"]["payment"] = app_removal_request.attributes.payment + laconic.publish(removal_record) if delete_names: @@ -97,22 +122,85 @@ def dump_known_requests(filename, requests): @click.command() -@click.option("--laconic-config", help="Provide a config file for laconicd", required=True) -@click.option("--deployment-parent-dir", help="Create deployment directories beneath this directory", required=True) +@click.option( + "--laconic-config", help="Provide a config file for laconicd", required=True +) +@click.option( + "--deployment-parent-dir", + help="Create deployment directories beneath this directory", + required=True, +) @click.option("--request-id", help="The ApplicationDeploymentRemovalRequest to process") -@click.option("--discover", help="Discover and process all pending ApplicationDeploymentRemovalRequests", - is_flag=True, default=False) -@click.option("--state-file", help="File to store state about previously seen requests.") -@click.option("--only-update-state", help="Only update the state file, don't process any requests anything.", is_flag=True) -@click.option("--delete-names/--preserve-names", help="Delete all names associated with removed deployments.", default=True) -@click.option("--delete-volumes/--preserve-volumes", default=True, help="delete data volumes") -@click.option("--dry-run", help="Don't do anything, just report what would be done.", is_flag=True) -@click.option("--include-tags", help="Only include requests with matching tags (comma-separated).", default="") -@click.option("--exclude-tags", help="Exclude requests with matching tags (comma-separated).", default="") +@click.option( + "--discover", + help="Discover and process all pending ApplicationDeploymentRemovalRequests", + is_flag=True, + default=False, +) +@click.option( + "--state-file", help="File to store state about previously seen requests." +) +@click.option( + "--only-update-state", + help="Only update the state file, don't process any requests anything.", + is_flag=True, +) +@click.option( + "--delete-names/--preserve-names", + help="Delete all names associated with removed deployments.", + default=True, +) +@click.option( + "--delete-volumes/--preserve-volumes", default=True, help="delete data volumes" +) +@click.option( + "--dry-run", help="Don't do anything, just report what would be done.", is_flag=True +) +@click.option( + "--include-tags", + help="Only include requests with matching tags (comma-separated).", + default="", +) +@click.option( + "--exclude-tags", + help="Exclude requests with matching tags (comma-separated).", + default="", +) +@click.option( + "--min-required-payment", + help="Requests must have a minimum payment to be processed", + default=0, +) +@click.option( + "--payment-address", + help="The address to which payments should be made. " + "Default is the current laconic account.", + default=None, +) +@click.option( + "--all-requests", + help="Handle requests addressed to anyone (by default only requests to" + "my payment address are examined).", + is_flag=True, +) @click.pass_context -def command(ctx, laconic_config, deployment_parent_dir, - request_id, discover, state_file, only_update_state, - delete_names, delete_volumes, dry_run, include_tags, exclude_tags): +def command( # noqa: C901 + ctx, + laconic_config, + deployment_parent_dir, + request_id, + discover, + state_file, + only_update_state, + delete_names, + delete_volumes, + dry_run, + include_tags, + exclude_tags, + min_required_payment, + payment_address, + all_requests, +): if request_id and discover: print("Cannot specify both --request-id and --discover", file=sys.stderr) sys.exit(2) @@ -129,34 +217,47 @@ def command(ctx, laconic_config, deployment_parent_dir, include_tags = [tag.strip() for tag in include_tags.split(",") if tag] exclude_tags = [tag.strip() for tag in exclude_tags.split(",") if tag] - laconic = LaconicRegistryClient(laconic_config) + laconic = LaconicRegistryClient(laconic_config, log_file=sys.stderr) + if not payment_address: + payment_address = laconic.whoami().address # Find deployment removal requests. # single request if request_id: + main_logger.log(f"Retrieving request {request_id}...") requests = [laconic.get_record(request_id, require=True)] # TODO: assert record type # all requests elif discover: - requests = laconic.app_deployment_removal_requests() + main_logger.log("Discovering removal requests...") + if all_requests: + requests = laconic.app_deployment_removal_requests() + else: + requests = laconic.app_deployment_removal_requests({"to": payment_address}) if only_update_state: if not dry_run: dump_known_requests(state_file, requests) return - previous_requests = load_known_requests(state_file) + previous_requests = {} + if state_file: + main_logger.log(f"Loading known requests from {state_file}...") + previous_requests = load_known_requests(state_file) + requests.sort(key=lambda r: r.createTime) requests.reverse() # Find deployments. - deployments = {} - for d in laconic.app_deployments(all=True): - deployments[d.id] = d + named_deployments = {} + main_logger.log("Discovering app deployments...") + for d in laconic.app_deployments(all=False): + named_deployments[d.id] = d # Find removal requests. removals_by_deployment = {} removals_by_request = {} + main_logger.log("Discovering deployment removals...") for r in laconic.app_deployment_removals(): if r.attributes.deployment: # TODO: should we handle CRNs? @@ -165,33 +266,65 @@ def command(ctx, laconic_config, deployment_parent_dir, one_per_deployment = {} for r in requests: if not r.attributes.deployment: - print(f"Skipping removal request {r.id} since it was a cancellation.") + main_logger.log( + f"Skipping removal request {r.id} since it was a cancellation." + ) elif r.attributes.deployment in one_per_deployment: - print(f"Skipping removal request {r.id} since it was superseded.") + main_logger.log(f"Skipping removal request {r.id} since it was superseded.") else: one_per_deployment[r.attributes.deployment] = r - requests_to_execute = [] + requests_to_check_for_payment = [] for r in one_per_deployment.values(): - if skip_by_tag(r, include_tags, exclude_tags): - print("Skipping removal request %s, filtered by tag (include %s, exclude %s, present %s)" % (r.id, - include_tags, - exclude_tags, - r.attributes.tags)) - elif r.id in removals_by_request: - print(f"Found satisfied request for {r.id} at {removals_by_request[r.id].id}") - elif r.attributes.deployment in removals_by_deployment: - print( - f"Found removal record for indicated deployment {r.attributes.deployment} at " - f"{removals_by_deployment[r.attributes.deployment].id}") - else: - if r.id not in previous_requests: - print(f"Request {r.id} needs to processed.") + try: + if r.attributes.deployment not in named_deployments: + main_logger.log( + f"Skipping removal request {r.id} for {r.attributes.deployment} because it does" + f"not appear to refer to a live, named deployment." + ) + elif skip_by_tag(r, include_tags, exclude_tags): + main_logger.log( + "Skipping removal request %s, filtered by tag (include %s, exclude %s, present %s)" + % (r.id, include_tags, exclude_tags, r.attributes.tags) + ) + elif r.id in removals_by_request: + main_logger.log( + f"Found satisfied request for {r.id} at {removals_by_request[r.id].id}" + ) + elif r.attributes.deployment in removals_by_deployment: + main_logger.log( + f"Found removal record for indicated deployment {r.attributes.deployment} at " + f"{removals_by_deployment[r.attributes.deployment].id}" + ) + else: + if r.id not in previous_requests: + main_logger.log(f"Request {r.id} needs to processed.") + requests_to_check_for_payment.append(r) + else: + main_logger.log( + f"Skipping unsatisfied request {r.id} because we have seen it before." + ) + except Exception as e: + main_logger.log(f"ERROR examining {r.id}: {e}") + + requests_to_execute = [] + if min_required_payment: + for r in requests_to_check_for_payment: + main_logger.log(f"{r.id}: Confirming payment...") + if confirm_payment( + laconic, r, payment_address, min_required_payment, main_logger + ): + main_logger.log(f"{r.id}: Payment confirmed.") requests_to_execute.append(r) else: - print(f"Skipping unsatisfied request {r.id} because we have seen it before.") + main_logger.log(f"Skipping request {r.id}: unable to verify payment.") + dump_known_requests(state_file, [r]) + else: + requests_to_execute = requests_to_check_for_payment - print("Found %d unsatisfied request(s) to process." % len(requests_to_execute)) + main_logger.log( + "Found %d unsatisfied request(s) to process." % len(requests_to_execute) + ) if not dry_run: for r in requests_to_execute: @@ -202,7 +335,10 @@ def command(ctx, laconic_config, deployment_parent_dir, r, os.path.abspath(deployment_parent_dir), delete_volumes, - delete_names + delete_names, + payment_address, ) + except Exception as e: + main_logger.log(f"ERROR processing removal request {r.id}: {e}") finally: dump_known_requests(state_file, [r]) diff --git a/stack_orchestrator/deploy/webapp/util.py b/stack_orchestrator/deploy/webapp/util.py index 8adbcb8c..e757f5d5 100644 --- a/stack_orchestrator/deploy/webapp/util.py +++ b/stack_orchestrator/deploy/webapp/util.py @@ -22,7 +22,6 @@ import subprocess import sys import tempfile import uuid - import yaml @@ -83,6 +82,84 @@ def match_owner(recordA, *records): return None +def is_lrn(name_or_id: str): + if name_or_id: + return str(name_or_id).startswith("lrn://") + return False + + +def is_id(name_or_id: str): + return not is_lrn(name_or_id) + + +def confirm_payment(laconic, record, payment_address, min_amount, logger): + req_owner = laconic.get_owner(record) + if req_owner == payment_address: + # No need to confirm payment if the sender and recipient are the same account. + return True + + if not record.attributes.payment: + logger.log(f"{record.id}: no payment tx info") + return False + + tx = laconic.get_tx(record.attributes.payment) + if not tx: + logger.log(f"{record.id}: cannot locate payment tx") + return False + + if tx.code != 0: + logger.log( + f"{record.id}: payment tx {tx.hash} was not successful - code: {tx.code}, log: {tx.log}" + ) + return False + + if tx.sender != req_owner: + logger.log( + f"{record.id}: payment sender {tx.sender} in tx {tx.hash} does not match deployment " + f"request owner {req_owner}" + ) + return False + + if tx.recipient != payment_address: + logger.log( + f"{record.id}: payment recipient {tx.recipient} in tx {tx.hash} does not match {payment_address}" + ) + return False + + pay_denom = "".join([i for i in tx.amount if not i.isdigit()]) + if pay_denom != "alnt": + logger.log( + f"{record.id}: {pay_denom} in tx {tx.hash} is not an expected payment denomination" + ) + return False + + pay_amount = int("".join([i for i in tx.amount if i.isdigit()])) + if pay_amount < min_amount: + logger.log( + f"{record.id}: payment amount {tx.amount} is less than minimum {min_amount}" + ) + return False + + # Check if the payment was already used on a + used = laconic.app_deployments( + {"by": payment_address, "payment": tx.hash}, all=True + ) + if len(used): + logger.log(f"{record.id}: payment {tx.hash} already used on deployment {used}") + return False + + used = laconic.app_deployment_removals( + {"by": payment_address, "payment": tx.hash}, all=True + ) + if len(used): + logger.log( + f"{record.id}: payment {tx.hash} already used on deployment removal {used}" + ) + return False + + return True + + class LaconicRegistryClient: def __init__(self, config_file, log_file=None): self.config_file = config_file @@ -90,10 +167,94 @@ class LaconicRegistryClient: self.cache = AttrDict( { "name_or_id": {}, + "accounts": {}, + "txs": {}, } ) - def list_records(self, criteria={}, all=False): + def whoami(self, refresh=False): + if not refresh and "whoami" in self.cache: + return self.cache["whoami"] + + args = ["laconic", "-c", self.config_file, "registry", "account", "get"] + results = [ + AttrDict(r) for r in json.loads(logged_cmd(self.log_file, *args)) if r + ] + + if len(results): + self.cache["whoami"] = results[0] + return results[0] + + return None + + def get_owner(self, record, require=False): + bond = self.get_bond(record.bondId, require) + if bond: + return bond.owner + + return bond + + def get_account(self, address, refresh=False, require=False): + if not refresh and address in self.cache["accounts"]: + return self.cache["accounts"][address] + + args = [ + "laconic", + "-c", + self.config_file, + "registry", + "account", + "get", + "--address", + address, + ] + results = [ + AttrDict(r) for r in json.loads(logged_cmd(self.log_file, *args)) if r + ] + if len(results): + self.cache["accounts"][address] = results[0] + return results[0] + + if require: + raise Exception("Cannot locate account:", address) + return None + + def get_bond(self, id, require=False): + if id in self.cache.name_or_id: + return self.cache.name_or_id[id] + + args = [ + "laconic", + "-c", + self.config_file, + "registry", + "bond", + "get", + "--id", + id, + ] + results = [ + AttrDict(r) for r in json.loads(logged_cmd(self.log_file, *args)) if r + ] + self._add_to_cache(results) + if len(results): + return results[0] + + if require: + raise Exception("Cannot locate bond:", id) + return None + + def list_bonds(self): + args = ["laconic", "-c", self.config_file, "registry", "bond", "list"] + results = [ + AttrDict(r) for r in json.loads(logged_cmd(self.log_file, *args)) if r + ] + self._add_to_cache(results) + return results + + def list_records(self, criteria=None, all=False): + if criteria is None: + criteria = {} args = ["laconic", "-c", self.config_file, "registry", "record", "list"] if all: @@ -104,22 +265,17 @@ class LaconicRegistryClient: args.append("--%s" % k) args.append(str(v)) - results = [AttrDict(r) for r in json.loads(logged_cmd(self.log_file, *args))] + results = [ + AttrDict(r) for r in json.loads(logged_cmd(self.log_file, *args)) if r + ] # Most recent records first results.sort(key=lambda r: r.createTime) results.reverse() + self._add_to_cache(results) return results - def is_lrn(self, name_or_id: str): - if name_or_id: - return str(name_or_id).startswith("lrn://") - return False - - def is_id(self, name_or_id: str): - return not self.is_lrn(name_or_id) - def _add_to_cache(self, records): if not records: return @@ -129,9 +285,10 @@ class LaconicRegistryClient: if p.names: for lrn in p.names: self.cache["name_or_id"][lrn] = p - if p.attributes.type not in self.cache: - self.cache[p.attributes.type] = [] - self.cache[p.attributes.type].append(p) + if p.attributes and p.attributes.type: + if p.attributes.type not in self.cache: + self.cache[p.attributes.type] = [] + self.cache[p.attributes.type].append(p) def resolve(self, name): if not name: @@ -142,7 +299,9 @@ class LaconicRegistryClient: args = ["laconic", "-c", self.config_file, "registry", "name", "resolve", name] - parsed = [AttrDict(r) for r in json.loads(logged_cmd(self.log_file, *args))] + parsed = [ + AttrDict(r) for r in json.loads(logged_cmd(self.log_file, *args)) if r + ] if parsed: self._add_to_cache(parsed) return parsed[0] @@ -158,7 +317,7 @@ class LaconicRegistryClient: if name_or_id in self.cache.name_or_id: return self.cache.name_or_id[name_or_id] - if self.is_lrn(name_or_id): + if is_lrn(name_or_id): return self.resolve(name_or_id) args = [ @@ -172,7 +331,9 @@ class LaconicRegistryClient: name_or_id, ] - parsed = [AttrDict(r) for r in json.loads(logged_cmd(self.log_file, *args)) if r] + parsed = [ + AttrDict(r) for r in json.loads(logged_cmd(self.log_file, *args)) if r + ] if len(parsed): self._add_to_cache(parsed) return parsed[0] @@ -181,38 +342,85 @@ class LaconicRegistryClient: raise Exception("Cannot locate record:", name_or_id) return None - def app_deployment_requests(self, all=True): - return self.list_records({"type": "ApplicationDeploymentRequest"}, all) + def get_tx(self, txHash, require=False): + if txHash in self.cache["txs"]: + return self.cache["txs"][txHash] - def app_deployments(self, all=True): - return self.list_records({"type": "ApplicationDeploymentRecord"}, all) + args = [ + "laconic", + "-c", + self.config_file, + "registry", + "tokens", + "gettx", + "--hash", + txHash, + ] - def app_deployment_removal_requests(self, all=True): - return self.list_records({"type": "ApplicationDeploymentRemovalRequest"}, all) + parsed = None + try: + parsed = AttrDict(json.loads(logged_cmd(self.log_file, *args))) + except: # noqa: E722 + pass - def app_deployment_removals(self, all=True): - return self.list_records({"type": "ApplicationDeploymentRemovalRecord"}, all) + if parsed: + self.cache["txs"][txHash] = parsed + return parsed - def publish(self, record, names=[]): + if require: + raise Exception("Cannot locate tx:", hash) + + def app_deployment_requests(self, criteria=None, all=True): + if criteria is None: + criteria = {} + criteria = criteria.copy() + criteria["type"] = "ApplicationDeploymentRequest" + return self.list_records(criteria, all) + + def app_deployments(self, criteria=None, all=True): + if criteria is None: + criteria = {} + criteria = criteria.copy() + criteria["type"] = "ApplicationDeploymentRecord" + return self.list_records(criteria, all) + + def app_deployment_removal_requests(self, criteria=None, all=True): + if criteria is None: + criteria = {} + criteria = criteria.copy() + criteria["type"] = "ApplicationDeploymentRemovalRequest" + return self.list_records(criteria, all) + + def app_deployment_removals(self, criteria=None, all=True): + if criteria is None: + criteria = {} + criteria = criteria.copy() + criteria["type"] = "ApplicationDeploymentRemovalRecord" + return self.list_records(criteria, all) + + def publish(self, record, names=None): + if names is None: + names = [] tmpdir = tempfile.mkdtemp() try: record_fname = os.path.join(tmpdir, "record.yml") - record_file = open(record_fname, 'w') + record_file = open(record_fname, "w") yaml.dump(record, record_file) record_file.close() - print(open(record_fname, 'r').read(), file=self.log_file) + print(open(record_fname, "r").read(), file=self.log_file) new_record_id = json.loads( logged_cmd( self.log_file, - "laconic", "-c", + "laconic", + "-c", self.config_file, "registry", "record", "publish", "--filename", - record_fname - ) + record_fname, + ) )["id"] for name in names: self.set_name(name, new_record_id) @@ -221,10 +429,29 @@ class LaconicRegistryClient: logged_cmd(self.log_file, "rm", "-rf", tmpdir) def set_name(self, name, record_id): - logged_cmd(self.log_file, "laconic", "-c", self.config_file, "registry", "name", "set", name, record_id) + logged_cmd( + self.log_file, + "laconic", + "-c", + self.config_file, + "registry", + "name", + "set", + name, + record_id, + ) def delete_name(self, name): - logged_cmd(self.log_file, "laconic", "-c", self.config_file, "registry", "name", "delete", name) + logged_cmd( + self.log_file, + "laconic", + "-c", + self.config_file, + "registry", + "name", + "delete", + name, + ) def file_hash(filename): @@ -248,7 +475,9 @@ def determine_base_container(clone_dir, app_type="webapp"): return base_container -def build_container_image(app_record, tag, extra_build_args=[], logger=None): +def build_container_image(app_record, tag, extra_build_args=None, logger=None): + if extra_build_args is None: + extra_build_args = [] tmpdir = tempfile.mkdtemp() # TODO: determine if this code could be calling into the Python git library like setup-repositories @@ -265,9 +494,15 @@ def build_container_image(app_record, tag, extra_build_args=[], logger=None): if github_token: logger.log("Github token detected, setting it in the git environment") git_config_args = [ - "git", "config", "--global", f"url.https://{github_token}:@github.com/.insteadOf", "https://github.com/" - ] - result = subprocess.run(git_config_args, stdout=logger.file, stderr=logger.file) + "git", + "config", + "--global", + f"url.https://{github_token}:@github.com/.insteadOf", + "https://github.com/", + ] + result = subprocess.run( + git_config_args, stdout=logger.file, stderr=logger.file + ) result.check_returncode() if ref: # TODO: Determing branch or hash, and use depth 1 if we can. @@ -275,30 +510,50 @@ def build_container_image(app_record, tag, extra_build_args=[], logger=None): # Never prompt git_env["GIT_TERMINAL_PROMPT"] = "0" try: - subprocess.check_call(["git", "clone", repo, clone_dir], env=git_env, stdout=logger.file, stderr=logger.file) + subprocess.check_call( + ["git", "clone", repo, clone_dir], + env=git_env, + stdout=logger.file, + stderr=logger.file, + ) except Exception as e: logger.log(f"git clone failed. Is the repository {repo} private?") raise e try: - subprocess.check_call(["git", "checkout", ref], cwd=clone_dir, env=git_env, stdout=logger.file, stderr=logger.file) + subprocess.check_call( + ["git", "checkout", ref], + cwd=clone_dir, + env=git_env, + stdout=logger.file, + stderr=logger.file, + ) except Exception as e: logger.log(f"git checkout failed. Does ref {ref} exist?") raise e else: # TODO: why is this code different vs the branch above (run vs check_call, and no prompt disable)? - result = subprocess.run(["git", "clone", "--depth", "1", repo, clone_dir], stdout=logger.file, stderr=logger.file) + result = subprocess.run( + ["git", "clone", "--depth", "1", repo, clone_dir], + stdout=logger.file, + stderr=logger.file, + ) result.check_returncode() - base_container = determine_base_container(clone_dir, app_record.attributes.app_type) + base_container = determine_base_container( + clone_dir, app_record.attributes.app_type + ) logger.log("Building webapp ...") build_command = [ sys.argv[0], "--verbose", "build-webapp", - "--source-repo", clone_dir, - "--tag", tag, - "--base-container", base_container + "--source-repo", + clone_dir, + "--tag", + tag, + "--base-container", + base_container, ] if extra_build_args: build_command.append("--extra-build-args") @@ -312,8 +567,11 @@ def build_container_image(app_record, tag, extra_build_args=[], logger=None): def push_container_image(deployment_dir, logger): logger.log("Pushing images ...") - result = subprocess.run([sys.argv[0], "deployment", "--dir", deployment_dir, "push-images"], - stdout=logger.file, stderr=logger.file) + result = subprocess.run( + [sys.argv[0], "deployment", "--dir", deployment_dir, "push-images"], + stdout=logger.file, + stderr=logger.file, + ) result.check_returncode() logger.log("Finished pushing images.") @@ -331,27 +589,35 @@ def deploy_to_k8s(deploy_record, deployment_dir, recreate, logger): for command in commands_to_run: logger.log(f"Running {command} command on deployment dir: {deployment_dir}") - result = subprocess.run([sys.argv[0], "deployment", "--dir", deployment_dir, command], - stdout=logger.file, stderr=logger.file) + result = subprocess.run( + [sys.argv[0], "deployment", "--dir", deployment_dir, command], + stdout=logger.file, + stderr=logger.file, + ) result.check_returncode() logger.log(f"Finished {command} command on deployment dir: {deployment_dir}") logger.log("Finished deploying to k8s.") -def publish_deployment(laconic: LaconicRegistryClient, - app_record, - deploy_record, - deployment_lrn, - dns_record, - dns_lrn, - deployment_dir, - app_deployment_request=None, - logger=None): +def publish_deployment( + laconic: LaconicRegistryClient, + app_record, + deploy_record, + deployment_lrn, + dns_record, + dns_lrn, + deployment_dir, + app_deployment_request=None, + payment_address=None, + logger=None, +): if not deploy_record: deploy_ver = "0.0.1" else: - deploy_ver = "0.0.%d" % (int(deploy_record.attributes.version.split(".")[-1]) + 1) + deploy_ver = "0.0.%d" % ( + int(deploy_record.attributes.version.split(".")[-1]) + 1 + ) if not dns_record: dns_ver = "0.0.1" @@ -369,9 +635,7 @@ def publish_deployment(laconic: LaconicRegistryClient, "version": dns_ver, "name": fqdn, "resource_type": "A", - "meta": { - "so": uniq.hex - }, + "meta": {"so": uniq.hex}, } } if app_deployment_request: @@ -391,12 +655,19 @@ def publish_deployment(laconic: LaconicRegistryClient, "dns": dns_id, "meta": { "config": file_hash(os.path.join(deployment_dir, "config.env")), - "so": uniq.hex + "so": uniq.hex, }, } } if app_deployment_request: new_deployment_record["record"]["request"] = app_deployment_request.id + if app_deployment_request.attributes.payment: + new_deployment_record["record"][ + "payment" + ] = app_deployment_request.attributes.payment + + if payment_address: + new_deployment_record["record"]["by"] = payment_address if logger: logger.log("Publishing ApplicationDeploymentRecord.") @@ -407,7 +678,9 @@ def publish_deployment(laconic: LaconicRegistryClient, def hostname_for_deployment_request(app_deployment_request, laconic): dns_name = app_deployment_request.attributes.dns if not dns_name: - app = laconic.get_record(app_deployment_request.attributes.application, require=True) + app = laconic.get_record( + app_deployment_request.attributes.application, require=True + ) dns_name = generate_hostname_for_app(app) elif dns_name.startswith("lrn://"): record = laconic.get_record(dns_name, require=True)