Require payment for app deployment requests. (#928)
All checks were successful
Database Test / Run database hosting test on kind/k8s (push) Successful in 9m33s
Lint Checks / Run linter (push) Successful in 35s
Publish / Build and publish (push) Successful in 1m18s
Smoke Test / Run basic test suite (push) Successful in 3m58s
Container Registry Test / Run contaier registry hosting test on kind/k8s (push) Successful in 3m44s
Webapp Test / Run webapp test suite (push) Successful in 4m45s
External Stack Test / Run external stack test suite (push) Successful in 4m39s
Deploy Test / Run deploy test suite (push) Successful in 5m10s
Fixturenet-Laconicd-Test / Run Laconicd fixturenet and Laconic CLI tests (push) Successful in 13m5s
K8s Deploy Test / Run deploy test suite on kind/k8s (push) Successful in 7m19s

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: #928
Reviewed-by: David Boreham <dboreham@noreply.git.vdb.to>
Co-authored-by: Thomas E Lackey <telackey@bozemanpass.com>
Co-committed-by: Thomas E Lackey <telackey@bozemanpass.com>
This commit is contained in:
Thomas E Lackey 2024-08-21 14:39:20 +00:00 committed by David Boreham
parent 44b9709717
commit 75ff60752a
3 changed files with 584 additions and 122 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,
) )
@ -54,6 +55,7 @@ def process_app_deployment_request(
force_rebuild, force_rebuild,
fqdn_policy, fqdn_policy,
recreate_on_deploy, recreate_on_deploy,
payment_address,
logger, logger,
): ):
logger.log("BEGIN - process_app_deployment_request") logger.log("BEGIN - process_app_deployment_request")
@ -78,6 +80,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 +124,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
) )
@ -222,6 +227,7 @@ def process_app_deployment_request(
dns_lrn, dns_lrn,
deployment_dir, deployment_dir,
app_deployment_request, app_deployment_request,
payment_address,
logger, logger,
) )
logger.log("Publication complete.") logger.log("Publication complete.")
@ -305,6 +311,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 +349,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 +392,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 +405,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...")
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 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)
@ -452,7 +484,10 @@ def command( # noqa: C901
# Find deployments. # Find deployments.
main_logger.log("Discovering existing app 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 = {} deployments_by_request = {}
for d in deployments: for d in deployments:
if d.attributes.request: if d.attributes.request:
@ -466,7 +501,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 +523,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)
@ -531,6 +583,7 @@ def command( # noqa: C901
force_rebuild, force_rebuild,
fqdn_policy, fqdn_policy,
recreate_on_deploy, recreate_on_deploy,
payment_address,
build_logger, build_logger,
) )
status = "DEPLOYED" status = "DEPLOYED"

View File

@ -20,18 +20,33 @@ 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,
confirm_payment,
)
main_logger = TimedLogger(file=sys.stderr)
def process_app_removal_request(ctx, def process_app_removal_request(
laconic: LaconicRegistryClient, ctx,
app_removal_request, laconic: LaconicRegistryClient,
deployment_parent_dir, app_removal_request,
delete_volumes, deployment_parent_dir,
delete_names): delete_volumes,
deployment_record = laconic.get_record(app_removal_request.attributes.deployment, require=True) 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) 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 +56,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
@ -64,8 +84,13 @@ def process_app_removal_request(ctx,
"version": "1.0.0", "version": "1.0.0",
"request": app_removal_request.id, "request": app_removal_request.id,
"deployment": deployment_record.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) laconic.publish(removal_record)
if delete_names: if delete_names:
@ -97,22 +122,85 @@ 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.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(ctx, laconic_config, deployment_parent_dir, def command( # noqa: C901
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,
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)
sys.exit(2) 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] include_tags = [tag.strip() for tag in include_tags.split(",") if tag]
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) laconic = LaconicRegistryClient(laconic_config, log_file=sys.stderr)
if not payment_address:
payment_address = laconic.whoami().address
# 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:
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 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 = 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.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 +266,65 @@ 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_check_for_payment = []
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 r.id in removals_by_request: )
print(f"Found satisfied request for {r.id} at {removals_by_request[r.id].id}") elif skip_by_tag(r, include_tags, exclude_tags):
elif r.attributes.deployment in removals_by_deployment: main_logger.log(
print( "Skipping removal request %s, filtered by tag (include %s, exclude %s, present %s)"
f"Found removal record for indicated deployment {r.attributes.deployment} at " % (r.id, include_tags, exclude_tags, r.attributes.tags)
f"{removals_by_deployment[r.attributes.deployment].id}") )
else: elif r.id in removals_by_request:
if r.id not in previous_requests: main_logger.log(
print(f"Request {r.id} needs to processed.") 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) 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 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: if not dry_run:
for r in requests_to_execute: for r in requests_to_execute:
@ -202,7 +335,10 @@ 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,
payment_address,
) )
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,7 +22,6 @@ import subprocess
import sys import sys
import tempfile import tempfile
import uuid import uuid
import yaml import yaml
@ -83,6 +82,84 @@ 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):
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: 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 +167,94 @@ class LaconicRegistryClient:
self.cache = AttrDict( self.cache = AttrDict(
{ {
"name_or_id": {}, "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"] args = ["laconic", "-c", self.config_file, "registry", "record", "list"]
if all: if all:
@ -104,22 +265,17 @@ 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,9 +285,10 @@ 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.type not in self.cache: if p.attributes and p.attributes.type:
self.cache[p.attributes.type] = [] if p.attributes.type not in self.cache:
self.cache[p.attributes.type].append(p) self.cache[p.attributes.type] = []
self.cache[p.attributes.type].append(p)
def resolve(self, name): def resolve(self, name):
if not name: if not name:
@ -142,7 +299,9 @@ 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 +317,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 = [
@ -172,7 +331,9 @@ class LaconicRegistryClient:
name_or_id, 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): if len(parsed):
self._add_to_cache(parsed) self._add_to_cache(parsed)
return parsed[0] return parsed[0]
@ -181,38 +342,85 @@ 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 get_tx(self, txHash, require=False):
return self.list_records({"type": "ApplicationDeploymentRequest"}, all) if txHash in self.cache["txs"]:
return self.cache["txs"][txHash]
def app_deployments(self, all=True): args = [
return self.list_records({"type": "ApplicationDeploymentRecord"}, all) "laconic",
"-c",
self.config_file,
"registry",
"tokens",
"gettx",
"--hash",
txHash,
]
def app_deployment_removal_requests(self, all=True): parsed = None
return self.list_records({"type": "ApplicationDeploymentRemovalRequest"}, all) try:
parsed = AttrDict(json.loads(logged_cmd(self.log_file, *args)))
except: # noqa: E722
pass
def app_deployment_removals(self, all=True): if parsed:
return self.list_records({"type": "ApplicationDeploymentRemovalRecord"}, all) 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() tmpdir = tempfile.mkdtemp()
try: try:
record_fname = os.path.join(tmpdir, "record.yml") 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) yaml.dump(record, record_file)
record_file.close() 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( new_record_id = json.loads(
logged_cmd( logged_cmd(
self.log_file, self.log_file,
"laconic", "-c", "laconic",
"-c",
self.config_file, self.config_file,
"registry", "registry",
"record", "record",
"publish", "publish",
"--filename", "--filename",
record_fname record_fname,
) )
)["id"] )["id"]
for name in names: for name in names:
self.set_name(name, new_record_id) self.set_name(name, new_record_id)
@ -221,10 +429,29 @@ class LaconicRegistryClient:
logged_cmd(self.log_file, "rm", "-rf", tmpdir) logged_cmd(self.log_file, "rm", "-rf", tmpdir)
def set_name(self, name, record_id): 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): 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): def file_hash(filename):
@ -248,7 +475,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
@ -265,9 +494,15 @@ def build_container_image(app_record, tag, extra_build_args=[], logger=None):
if github_token: if github_token:
logger.log("Github token detected, setting it in the git environment") logger.log("Github token detected, setting it in the git environment")
git_config_args = [ git_config_args = [
"git", "config", "--global", f"url.https://{github_token}:@github.com/.insteadOf", "https://github.com/" "git",
] "config",
result = subprocess.run(git_config_args, stdout=logger.file, stderr=logger.file) "--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() result.check_returncode()
if ref: if ref:
# TODO: Determing branch or hash, and use depth 1 if we can. # 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 # Never prompt
git_env["GIT_TERMINAL_PROMPT"] = "0" git_env["GIT_TERMINAL_PROMPT"] = "0"
try: 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: except Exception as e:
logger.log(f"git clone failed. Is the repository {repo} private?") logger.log(f"git clone failed. Is the repository {repo} private?")
raise e raise e
try: 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: except Exception as e:
logger.log(f"git checkout failed. Does ref {ref} exist?") logger.log(f"git checkout failed. Does ref {ref} exist?")
raise e raise e
else: else:
# TODO: why is this code different vs the branch above (run vs check_call, and no prompt disable)? # 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() 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 ...") logger.log("Building webapp ...")
build_command = [ build_command = [
sys.argv[0], sys.argv[0],
"--verbose", "--verbose",
"build-webapp", "build-webapp",
"--source-repo", clone_dir, "--source-repo",
"--tag", tag, clone_dir,
"--base-container", base_container "--tag",
tag,
"--base-container",
base_container,
] ]
if extra_build_args: if extra_build_args:
build_command.append("--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): def push_container_image(deployment_dir, logger):
logger.log("Pushing images ...") logger.log("Pushing images ...")
result = subprocess.run([sys.argv[0], "deployment", "--dir", deployment_dir, "push-images"], result = subprocess.run(
stdout=logger.file, stderr=logger.file) [sys.argv[0], "deployment", "--dir", deployment_dir, "push-images"],
stdout=logger.file,
stderr=logger.file,
)
result.check_returncode() result.check_returncode()
logger.log("Finished pushing images.") 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: for command in commands_to_run:
logger.log(f"Running {command} command on deployment dir: {deployment_dir}") logger.log(f"Running {command} command on deployment dir: {deployment_dir}")
result = subprocess.run([sys.argv[0], "deployment", "--dir", deployment_dir, command], result = subprocess.run(
stdout=logger.file, stderr=logger.file) [sys.argv[0], "deployment", "--dir", deployment_dir, command],
stdout=logger.file,
stderr=logger.file,
)
result.check_returncode() result.check_returncode()
logger.log(f"Finished {command} command on deployment dir: {deployment_dir}") logger.log(f"Finished {command} command on deployment dir: {deployment_dir}")
logger.log("Finished deploying to k8s.") logger.log("Finished deploying to k8s.")
def publish_deployment(laconic: LaconicRegistryClient, def publish_deployment(
app_record, laconic: LaconicRegistryClient,
deploy_record, app_record,
deployment_lrn, deploy_record,
dns_record, deployment_lrn,
dns_lrn, dns_record,
deployment_dir, dns_lrn,
app_deployment_request=None, deployment_dir,
logger=None): app_deployment_request=None,
payment_address=None,
logger=None,
):
if not deploy_record: if not deploy_record:
deploy_ver = "0.0.1" deploy_ver = "0.0.1"
else: 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: if not dns_record:
dns_ver = "0.0.1" dns_ver = "0.0.1"
@ -369,9 +635,7 @@ def publish_deployment(laconic: LaconicRegistryClient,
"version": dns_ver, "version": dns_ver,
"name": fqdn, "name": fqdn,
"resource_type": "A", "resource_type": "A",
"meta": { "meta": {"so": uniq.hex},
"so": uniq.hex
},
} }
} }
if app_deployment_request: if app_deployment_request:
@ -391,12 +655,19 @@ def publish_deployment(laconic: LaconicRegistryClient,
"dns": dns_id, "dns": dns_id,
"meta": { "meta": {
"config": file_hash(os.path.join(deployment_dir, "config.env")), "config": file_hash(os.path.join(deployment_dir, "config.env")),
"so": uniq.hex "so": uniq.hex,
}, },
} }
} }
if app_deployment_request: if app_deployment_request:
new_deployment_record["record"]["request"] = app_deployment_request.id 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: if logger:
logger.log("Publishing ApplicationDeploymentRecord.") logger.log("Publishing ApplicationDeploymentRecord.")
@ -407,7 +678,9 @@ def publish_deployment(laconic: LaconicRegistryClient,
def hostname_for_deployment_request(app_deployment_request, laconic): def hostname_for_deployment_request(app_deployment_request, laconic):
dns_name = app_deployment_request.attributes.dns dns_name = app_deployment_request.attributes.dns
if not dns_name: 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) dns_name = generate_hostname_for_app(app)
elif dns_name.startswith("lrn://"): elif dns_name.startswith("lrn://"):
record = laconic.get_record(dns_name, require=True) record = laconic.get_record(dns_name, require=True)