WIP: Require payment for app deployment requests.

This commit is contained in:
Thomas E Lackey 2024-08-16 21:51:15 -05:00
parent e56da7dcc1
commit 0a9d68f4e8
3 changed files with 320 additions and 81 deletions

View File

@ -38,6 +38,7 @@ from stack_orchestrator.deploy.webapp.util import (
generate_hostname_for_app, generate_hostname_for_app,
match_owner, match_owner,
skip_by_tag, skip_by_tag,
confirm_payment,
) )
@ -78,6 +79,9 @@ def process_app_deployment_request(
else: else:
fqdn = f"{requested_name}.{default_dns_suffix}" fqdn = f"{requested_name}.{default_dns_suffix}"
# Normalize case (just in case)
fqdn = fqdn.lower()
# 3. check ownership of existing dnsrecord vs this request # 3. check ownership of existing dnsrecord vs this request
dns_lrn = f"{dns_record_namespace}/{fqdn}" dns_lrn = f"{dns_record_namespace}/{fqdn}"
dns_record = laconic.get_record(dns_lrn) dns_record = laconic.get_record(dns_lrn)
@ -119,7 +123,7 @@ def process_app_deployment_request(
app_deployment_lrn = app_deployment_request.attributes.deployment app_deployment_lrn = app_deployment_request.attributes.deployment
if not app_deployment_lrn.startswith(deployment_record_namespace): if not app_deployment_lrn.startswith(deployment_record_namespace):
raise Exception( 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 % app_deployment_request.attributes.deployment
) )
@ -305,6 +309,23 @@ def dump_known_requests(filename, requests, status="SEEN"):
@click.option( @click.option(
"--log-dir", help="Output build/deployment logs to directory.", default=None "--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 @click.pass_context
def command( # noqa: C901 def command( # noqa: C901
ctx, ctx,
@ -326,6 +347,9 @@ def command( # noqa: C901
force_rebuild, force_rebuild,
recreate_on_deploy, recreate_on_deploy,
log_dir, log_dir,
min_required_payment,
payment_address,
all_requests,
): ):
if request_id and discover: if request_id and discover:
print("Cannot specify both --request-id and --discover", file=sys.stderr) print("Cannot specify both --request-id and --discover", file=sys.stderr)
@ -366,6 +390,10 @@ def command( # noqa: C901
exclude_tags = [tag.strip() for tag in exclude_tags.split(",") if tag] exclude_tags = [tag.strip() for tag in exclude_tags.split(",") if tag]
laconic = LaconicRegistryClient(laconic_config, log_file=sys.stderr) 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. # Find deployment requests.
# single request # single request
@ -375,18 +403,20 @@ def command( # noqa: C901
# all requests # all requests
elif discover: elif discover:
main_logger.log("Discovering deployment requests...") main_logger.log("Discovering deployment requests...")
if all_requests:
requests = laconic.app_deployment_requests() requests = laconic.app_deployment_requests()
else:
requests = laconic.app_deployment_requests({"to": payment_address})
if only_update_state: if only_update_state:
if not dry_run: if not dry_run:
dump_known_requests(state_file, requests) dump_known_requests(state_file, requests)
return return
previous_requests = {}
if state_file: if state_file:
main_logger.log(f"Loading known requests from {state_file}...") main_logger.log(f"Loading known requests from {state_file}...")
previous_requests = load_known_requests(state_file) previous_requests = load_known_requests(state_file)
else:
previous_requests = {}
# Collapse related requests. # Collapse related requests.
requests.sort(key=lambda r: r.createTime) requests.sort(key=lambda r: r.createTime)
@ -466,7 +496,7 @@ def command( # noqa: C901
if r.attributes.request: if r.attributes.request:
cancellation_requests[r.attributes.request] = r cancellation_requests[r.attributes.request] = r
requests_to_execute = [] requests_to_check_for_payment = []
for r in requests_by_name.values(): for r in requests_by_name.values():
if r.id in cancellation_requests and match_owner( if r.id in cancellation_requests and match_owner(
cancellation_requests[r.id], r cancellation_requests[r.id], r
@ -488,7 +518,24 @@ def command( # noqa: C901
) )
else: else:
main_logger.log(f"Request {r.id} needs to processed.") 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) 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( main_logger.log(
"Found %d unsatisfied request(s) to process." % len(requests_to_execute) "Found %d unsatisfied request(s) to process." % len(requests_to_execute)

View File

@ -20,18 +20,31 @@ import sys
import click 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,
)
main_logger = TimedLogger(file=sys.stderr)
def process_app_removal_request(ctx, def process_app_removal_request(
ctx,
laconic: LaconicRegistryClient, laconic: LaconicRegistryClient,
app_removal_request, app_removal_request,
deployment_parent_dir, deployment_parent_dir,
delete_volumes, delete_volumes,
delete_names): delete_names,
deployment_record = laconic.get_record(app_removal_request.attributes.deployment, require=True) ):
deployment_record = laconic.get_record(
app_removal_request.attributes.deployment, require=True
)
dns_record = laconic.get_record(deployment_record.attributes.dns, 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): if not os.path.exists(deployment_dir):
raise Exception("Deployment directory %s does not exist." % deployment_dir) raise Exception("Deployment directory %s does not exist." % deployment_dir)
@ -41,13 +54,18 @@ def process_app_removal_request(ctx,
# Or of the original deployment request. # Or of the original deployment request.
if not matched_owner and deployment_record.attributes.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: if matched_owner:
print("Matched deployment ownership:", matched_owner) main_logger.log("Matched deployment ownership:", matched_owner)
else: else:
raise Exception("Unable to confirm ownership of deployment %s for removal request %s" % raise Exception(
(deployment_record.id, app_removal_request.id)) "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 # 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 # exec the process, but it would be better to refactor so we could just call down_operation with the
@ -97,22 +115,65 @@ def dump_known_requests(filename, requests):
@click.command() @click.command()
@click.option("--laconic-config", help="Provide a config file for laconicd", required=True) @click.option(
@click.option("--deployment-parent-dir", help="Create deployment directories beneath this directory", required=True) "--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("--request-id", help="The ApplicationDeploymentRemovalRequest to process")
@click.option("--discover", help="Discover and process all pending ApplicationDeploymentRemovalRequests", @click.option(
is_flag=True, default=False) "--discover",
@click.option("--state-file", help="File to store state about previously seen requests.") help="Discover and process all pending ApplicationDeploymentRemovalRequests",
@click.option("--only-update-state", help="Only update the state file, don't process any requests anything.", is_flag=True) is_flag=True,
@click.option("--delete-names/--preserve-names", help="Delete all names associated with removed deployments.", default=True) default=False,
@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(
@click.option("--include-tags", help="Only include requests with matching tags (comma-separated).", default="") "--state-file", help="File to store state about previously seen requests."
@click.option("--exclude-tags", help="Exclude requests with matching tags (comma-separated).", default="") )
@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.pass_context @click.pass_context
def command(ctx, laconic_config, deployment_parent_dir, def command(
request_id, discover, state_file, only_update_state, ctx,
delete_names, delete_volumes, dry_run, include_tags, exclude_tags): laconic_config,
deployment_parent_dir,
request_id,
discover,
state_file,
only_update_state,
delete_names,
delete_volumes,
dry_run,
include_tags,
exclude_tags,
):
if request_id and discover: if request_id and discover:
print("Cannot specify both --request-id and --discover", file=sys.stderr) print("Cannot specify both --request-id and --discover", file=sys.stderr)
sys.exit(2) sys.exit(2)
@ -134,10 +195,12 @@ def command(ctx, laconic_config, deployment_parent_dir,
# Find deployment removal requests. # Find deployment removal requests.
# single request # single request
if request_id: if request_id:
main_logger.log(f"Retrieving request {request_id}...")
requests = [laconic.get_record(request_id, require=True)] requests = [laconic.get_record(request_id, require=True)]
# TODO: assert record type # TODO: assert record type
# all requests # all requests
elif discover: elif discover:
main_logger.log("Discovering removal requests...")
requests = laconic.app_deployment_removal_requests() requests = laconic.app_deployment_removal_requests()
if only_update_state: if only_update_state:
@ -145,18 +208,24 @@ def command(ctx, laconic_config, deployment_parent_dir,
dump_known_requests(state_file, requests) dump_known_requests(state_file, requests)
return return
previous_requests = {}
if state_file:
main_logger.log(f"Loading known requests from {state_file}...")
previous_requests = load_known_requests(state_file) previous_requests = load_known_requests(state_file)
requests.sort(key=lambda r: r.createTime) requests.sort(key=lambda r: r.createTime)
requests.reverse() requests.reverse()
# Find deployments. # Find deployments.
deployments = {} named_deployments = {}
for d in laconic.app_deployments(all=True): main_logger.log("Discovering app deployments...")
deployments[d.id] = d for d in laconic.app_deployments(all=False):
named_deployments[d.id] = d
# Find removal requests. # Find removal requests.
removals_by_deployment = {} removals_by_deployment = {}
removals_by_request = {} removals_by_request = {}
main_logger.log("Discovering deployment removals...")
for r in laconic.app_deployment_removals(): for r in laconic.app_deployment_removals():
if r.attributes.deployment: if r.attributes.deployment:
# TODO: should we handle CRNs? # TODO: should we handle CRNs?
@ -165,33 +234,50 @@ def command(ctx, laconic_config, deployment_parent_dir,
one_per_deployment = {} one_per_deployment = {}
for r in requests: for r in requests:
if not r.attributes.deployment: 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: 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: else:
one_per_deployment[r.attributes.deployment] = r one_per_deployment[r.attributes.deployment] = r
requests_to_execute = [] requests_to_execute = []
for r in one_per_deployment.values(): for r in one_per_deployment.values():
if skip_by_tag(r, include_tags, exclude_tags): try:
print("Skipping removal request %s, filtered by tag (include %s, exclude %s, present %s)" % (r.id, if r.attributes.deployment not in named_deployments:
include_tags, main_logger.log(
exclude_tags, f"Skipping removal request {r.id} for {r.attributes.deployment} because it does"
r.attributes.tags)) 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: elif r.id in removals_by_request:
print(f"Found satisfied request for {r.id} at {removals_by_request[r.id].id}") 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: elif r.attributes.deployment in removals_by_deployment:
print( main_logger.log(
f"Found removal record for indicated deployment {r.attributes.deployment} at " f"Found removal record for indicated deployment {r.attributes.deployment} at "
f"{removals_by_deployment[r.attributes.deployment].id}") f"{removals_by_deployment[r.attributes.deployment].id}"
)
else: else:
if r.id not in previous_requests: if r.id not in previous_requests:
print(f"Request {r.id} needs to processed.") main_logger.log(f"Request {r.id} needs to processed.")
requests_to_execute.append(r) requests_to_execute.append(r)
else: else:
print(f"Skipping unsatisfied request {r.id} because we have seen it before.") 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}")
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: if not dry_run:
for r in requests_to_execute: for r in requests_to_execute:
@ -202,7 +288,9 @@ def command(ctx, laconic_config, deployment_parent_dir,
r, r,
os.path.abspath(deployment_parent_dir), os.path.abspath(deployment_parent_dir),
delete_volumes, delete_volumes,
delete_names delete_names,
) )
except Exception as e:
main_logger.log(f"ERROR processing removal request {r.id}: {e}")
finally: finally:
dump_known_requests(state_file, [r]) dump_known_requests(state_file, [r])

View File

@ -22,6 +22,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import uuid import uuid
from typing import final
import yaml import yaml
@ -83,6 +84,41 @@ def match_owner(recordA, *records):
return None 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):
if not record.attributes.payment:
logger.log(f"{record.id}: not payment tx")
return False
tx = laconic.get_tx(record.attributes.payment)
if not tx:
logger.log(f"{record.id}: cannot locate payment tx")
return False
owner = laconic.get_owner(record)
if tx.from_address != owner:
logger.log(f"{record.id}: {tx.from_address} != {owner}")
return False
if tx.to_address != payment_address:
logger.log(f"{record.id}: {tx.to_address} != {payment_address}")
return False
if tx.amount < min_amount:
logger.log(f"{record.id}: {tx.amount} < {min_amount}")
return False
return True
class LaconicRegistryClient: class LaconicRegistryClient:
def __init__(self, config_file, log_file=None): def __init__(self, config_file, log_file=None):
self.config_file = config_file self.config_file = config_file
@ -90,10 +126,64 @@ class LaconicRegistryClient:
self.cache = AttrDict( self.cache = AttrDict(
{ {
"name_or_id": {}, "name_or_id": {},
"accounts": {}
} }
) )
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):
bond = self.get_bond(record.bondId, require=True)
return bond.owner
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"] args = ["laconic", "-c", self.config_file, "registry", "record", "list"]
if all: if all:
@ -104,22 +194,15 @@ class LaconicRegistryClient:
args.append("--%s" % k) args.append("--%s" % k)
args.append(str(v)) 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 # Most recent records first
results.sort(key=lambda r: r.createTime) results.sort(key=lambda r: r.createTime)
results.reverse() results.reverse()
self._add_to_cache(results)
return 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): def _add_to_cache(self, records):
if not records: if not records:
return return
@ -129,6 +212,7 @@ class LaconicRegistryClient:
if p.names: if p.names:
for lrn in p.names: for lrn in p.names:
self.cache["name_or_id"][lrn] = p self.cache["name_or_id"][lrn] = p
if p.attributes and p.attributes.type:
if p.attributes.type not in self.cache: if p.attributes.type not in self.cache:
self.cache[p.attributes.type] = [] self.cache[p.attributes.type] = []
self.cache[p.attributes.type].append(p) self.cache[p.attributes.type].append(p)
@ -142,7 +226,7 @@ class LaconicRegistryClient:
args = ["laconic", "-c", self.config_file, "registry", "name", "resolve", name] 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: if parsed:
self._add_to_cache(parsed) self._add_to_cache(parsed)
return parsed[0] return parsed[0]
@ -158,7 +242,7 @@ class LaconicRegistryClient:
if name_or_id in self.cache.name_or_id: if name_or_id in self.cache.name_or_id:
return self.cache.name_or_id[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) return self.resolve(name_or_id)
args = [ args = [
@ -181,19 +265,37 @@ class LaconicRegistryClient:
raise Exception("Cannot locate record:", name_or_id) raise Exception("Cannot locate record:", name_or_id)
return None return None
def app_deployment_requests(self, all=True): def app_deployment_requests(self, criteria=None, all=True):
return self.list_records({"type": "ApplicationDeploymentRequest"}, all) if criteria is None:
criteria = {}
criteria = criteria.copy()
criteria["type"] = "ApplicationDeploymentRequest"
return self.list_records(criteria, all)
def app_deployments(self, all=True): def app_deployments(self, criteria=None, all=True):
return self.list_records({"type": "ApplicationDeploymentRecord"}, all) if criteria is None:
criteria = {}
criteria = criteria.copy()
criteria["type"] = "ApplicationDeploymentRecord"
return self.list_records(criteria, all)
def app_deployment_removal_requests(self, all=True): def app_deployment_removal_requests(self, criteria=None, all=True):
return self.list_records({"type": "ApplicationDeploymentRemovalRequest"}, all) if criteria is None:
criteria = {}
criteria = criteria.copy()
criteria["type"] = "ApplicationDeploymentRemovalRequest"
return self.list_records(criteria, all)
def app_deployment_removals(self, all=True): def app_deployment_removals(self, criteria=None, all=True):
return self.list_records({"type": "ApplicationDeploymentRemovalRecord"}, all) if criteria is None:
criteria = {}
criteria = criteria.copy()
criteria["type"] = "ApplicationDeploymentRemovalRecord"
return self.list_records(criteria, all)
def publish(self, record, names=[]): def publish(self, record, names=None):
if names is None:
names = []
tmpdir = tempfile.mkdtemp() tmpdir = tempfile.mkdtemp()
try: try:
record_fname = os.path.join(tmpdir, "record.yml") record_fname = os.path.join(tmpdir, "record.yml")
@ -248,7 +350,9 @@ def determine_base_container(clone_dir, app_type="webapp"):
return base_container 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() tmpdir = tempfile.mkdtemp()
# TODO: determine if this code could be calling into the Python git library like setup-repositories # TODO: determine if this code could be calling into the Python git library like setup-repositories