diff --git a/requirements.txt b/requirements.txt index f6e3d07c..b7acede4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,5 @@ tomli==2.0.1 validators==0.22.0 kubernetes>=28.1.0 humanfriendly>=10.0 +python-gnupg>=0.5.2 +requests>=2.3.2 diff --git a/stack_orchestrator/deploy/k8s/cluster_info.py b/stack_orchestrator/deploy/k8s/cluster_info.py index 0a1f4a71..be1b2e3d 100644 --- a/stack_orchestrator/deploy/k8s/cluster_info.py +++ b/stack_orchestrator/deploy/k8s/cluster_info.py @@ -14,6 +14,7 @@ # along with this program. If not, see . import os +import base64 from kubernetes import client from typing import Any, List, Set @@ -260,12 +261,12 @@ class ClusterInfo: for f in os.listdir(cfg_map_path): full_path = os.path.join(cfg_map_path, f) if os.path.isfile(full_path): - data[f] = open(full_path, 'rt').read() + data[f] = base64.b64encode(open(full_path, 'rb').read()).decode('ASCII') spec = client.V1ConfigMap( metadata=client.V1ObjectMeta(name=f"{self.app_name}-{cfg_map_name}", labels={"configmap-label": cfg_map_name}), - data=data + binary_data=data ) result.append(spec) return result diff --git a/stack_orchestrator/deploy/webapp/deploy_webapp_from_registry.py b/stack_orchestrator/deploy/webapp/deploy_webapp_from_registry.py index f8dd796f..5da1f4eb 100644 --- a/stack_orchestrator/deploy/webapp/deploy_webapp_from_registry.py +++ b/stack_orchestrator/deploy/webapp/deploy_webapp_from_registry.py @@ -21,12 +21,15 @@ import sys import tempfile import time import uuid +import yaml import click +import gnupg from stack_orchestrator.deploy.images import remote_image_exists from stack_orchestrator.deploy.webapp import deploy_webapp from stack_orchestrator.deploy.webapp.util import ( + AttrDict, LaconicRegistryClient, TimedLogger, build_container_image, @@ -55,7 +58,10 @@ def process_app_deployment_request( force_rebuild, fqdn_policy, recreate_on_deploy, - payment_address, + webapp_deployer_record, + gpg, + private_key_passphrase, + config_upload_dir, logger, ): logger.log("BEGIN - process_app_deployment_request") @@ -107,14 +113,31 @@ def process_app_deployment_request( ) # 4. get build and runtime config from request + env = {} + if app_deployment_request.attributes.config: + if "ref" in app_deployment_request.attributes.config: + with open( + f"{config_upload_dir}/{app_deployment_request.attributes.config.ref}", + "rb", + ) as file: + record_owner = laconic.get_owner(app_deployment_request) + decrypted = gpg.decrypt_file(file, passphrase=private_key_passphrase) + parsed = AttrDict(yaml.safe_load(decrypted.data)) + if record_owner not in parsed.authorized: + raise Exception( + f"{record_owner} not authorized to access config {app_deployment_request.attributes.config.ref}" + ) + if "env" in parsed.config: + env.update(parsed.config.env) + + if "env" in app_deployment_request.attributes.config: + env.update(app_deployment_request.attributes.config.env) + env_filename = None - if ( - app_deployment_request.attributes.config - and "env" in app_deployment_request.attributes.config - ): + if env: env_filename = tempfile.mktemp() with open(env_filename, "w") as file: - for k, v in app_deployment_request.attributes.config["env"].items(): + for k, v in env.items(): file.write("%s=%s\n" % (k, shlex.quote(str(v)))) # 5. determine new or existing deployment @@ -227,7 +250,7 @@ def process_app_deployment_request( dns_lrn, deployment_dir, app_deployment_request, - payment_address, + webapp_deployer_record, logger, ) logger.log("Publication complete.") @@ -285,8 +308,12 @@ def dump_known_requests(filename, requests, status="SEEN"): help="How to handle requests with an FQDN: prohibit, allow, preexisting", default="prohibit", ) -@click.option("--record-namespace-dns", help="eg, lrn://laconic/dns") -@click.option("--record-namespace-deployments", help="eg, lrn://laconic/deployments") +@click.option("--record-namespace-dns", help="eg, lrn://laconic/dns", required=True) +@click.option( + "--record-namespace-deployments", + help="eg, lrn://laconic/deployments", + required=True, +) @click.option( "--dry-run", help="Don't do anything, just report what would be done.", is_flag=True ) @@ -313,21 +340,29 @@ def dump_known_requests(filename, requests, status="SEEN"): ) @click.option( "--min-required-payment", - help="Requests must have a minimum payment to be processed", + help="Requests must have a minimum payment to be processed (in alnt)", 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("--lrn", help="The LRN of this deployer.", required=True) @click.option( "--all-requests", help="Handle requests addressed to anyone (by default only requests to" "my payment address are examined).", is_flag=True, ) +@click.option( + "--config-upload-dir", + help="The directory containing uploaded config.", + required=True, +) +@click.option( + "--private-key-file", help="The private key for decrypting config.", required=True +) +@click.option( + "--private-key-passphrase", + help="The passphrase for the private key.", + required=True, +) @click.pass_context def command( # noqa: C901 ctx, @@ -350,7 +385,10 @@ def command( # noqa: C901 recreate_on_deploy, log_dir, min_required_payment, - payment_address, + lrn, + config_upload_dir, + private_key_file, + private_key_passphrase, all_requests, ): if request_id and discover: @@ -384,6 +422,18 @@ def command( # noqa: C901 ) sys.exit(2) + tempdir = tempfile.mkdtemp() + gpg = gnupg.GPG(gnupghome=tempdir) + + # Import the deployer's public key + result = gpg.import_keys(open(private_key_file, "rb").read()) + if 1 != result.imported: + print( + f"Failed to load private key file: {private_key_file}.", + file=sys.stderr, + ) + sys.exit(2) + main_logger = TimedLogger(file=sys.stderr) try: @@ -392,11 +442,17 @@ 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 - + webapp_deployer_record = laconic.get_record(lrn, require=True) + payment_address = webapp_deployer_record.attributes.paymentAddress main_logger.log(f"Payment address: {payment_address}") + if min_required_payment and not payment_address: + print( + f"Minimum payment required, but no payment address listed for deployer: {lrn}.", + file=sys.stderr, + ) + sys.exit(2) + # Find deployment requests. # single request if request_id: @@ -408,7 +464,7 @@ def command( # noqa: C901 if all_requests: requests = laconic.app_deployment_requests() else: - requests = laconic.app_deployment_requests({"to": payment_address}) + requests = laconic.app_deployment_requests({"deployer": lrn}) if only_update_state: if not dry_run: @@ -487,7 +543,7 @@ def command( # noqa: C901 if all_requests: deployments = laconic.app_deployments() else: - deployments = laconic.app_deployments({"by": payment_address}) + deployments = laconic.app_deployments({"deployer": lrn}) deployments_by_request = {} for d in deployments: if d.attributes.request: @@ -530,7 +586,11 @@ def command( # noqa: C901 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 + laconic, + r, + payment_address, + min_required_payment, + main_logger, ): main_logger.log(f"{r.id}: Payment confirmed.") requests_to_execute.append(r) @@ -583,7 +643,10 @@ def command( # noqa: C901 force_rebuild, fqdn_policy, recreate_on_deploy, - payment_address, + webapp_deployer_record, + gpg, + private_key_passphrase, + config_upload_dir, build_logger, ) status = "DEPLOYED" @@ -604,3 +667,5 @@ def command( # noqa: C901 except Exception as e: main_logger.log("UNCAUGHT ERROR:" + str(e)) raise e + finally: + shutil.rmtree(tempdir) diff --git a/stack_orchestrator/deploy/webapp/publish_webapp_deployer.py b/stack_orchestrator/deploy/webapp/publish_webapp_deployer.py new file mode 100644 index 00000000..851e90e1 --- /dev/null +++ b/stack_orchestrator/deploy/webapp/publish_webapp_deployer.py @@ -0,0 +1,91 @@ +# Copyright ©2023 Vulcanize +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import base64 +import click +import sys +import yaml + +from urllib.parse import urlparse + +from stack_orchestrator.deploy.webapp.util import LaconicRegistryClient + + +@click.command() +@click.option( + "--laconic-config", help="Provide a config file for laconicd", required=True +) +@click.option("--api-url", help="The API URL of the deployer.", required=True) +@click.option( + "--public-key-file", + help="The public key to use. This should be a binary file.", + required=True, +) +@click.option( + "--lrn", help="eg, lrn://laconic/deployers/my.deployer.name", required=True +) +@click.option( + "--payment-address", + help="The address to which payments should be made. " + "Default is the current laconic account.", + default=None, +) +@click.option( + "--min-required-payment", + help="List the minimum required payment (in alnt) to process a deployment request.", + default=0, +) +@click.option( + "--dry-run", + help="Don't publish anything, just report what would be done.", + is_flag=True, +) +@click.pass_context +def command( # noqa: C901 + ctx, + laconic_config, + api_url, + public_key_file, + lrn, + payment_address, + min_required_payment, + dry_run, +): + laconic = LaconicRegistryClient(laconic_config) + if not payment_address: + payment_address = laconic.whoami().address + + pub_key = base64.b64encode(open(public_key_file, "rb").read()).decode("ASCII") + hostname = urlparse(api_url).hostname + webapp_deployer_record = { + "record": { + "type": "WebappDeployer", + "version": "1.0.0", + "apiUrl": api_url, + "name": hostname, + "publicKey": pub_key, + "paymentAddress": payment_address, + } + } + + if min_required_payment: + webapp_deployer_record["record"][ + "minimumPayment" + ] = f"{min_required_payment}alnt" + + if dry_run: + yaml.dump(webapp_deployer_record, sys.stdout) + return + + laconic.publish(webapp_deployer_record, [lrn]) diff --git a/stack_orchestrator/deploy/webapp/request_webapp_deployment.py b/stack_orchestrator/deploy/webapp/request_webapp_deployment.py new file mode 100644 index 00000000..3ad1cb49 --- /dev/null +++ b/stack_orchestrator/deploy/webapp/request_webapp_deployment.py @@ -0,0 +1,174 @@ +# Copyright ©2023 Vulcanize +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +import base64 + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import shutil +import sys +import tempfile +from datetime import datetime + +import gnupg +import click +import requests +import yaml + +from stack_orchestrator.deploy.webapp.util import ( + LaconicRegistryClient, +) +from dotenv import dotenv_values + + +def fatal(msg: str): + print(msg, file=sys.stderr) + sys.exit(1) + + +@click.command() +@click.option( + "--laconic-config", help="Provide a config file for laconicd", required=True +) +@click.option( + "--app", + help="The LRN of the application to deploy.", + required=True, +) +@click.option( + "--deployer", + help="The LRN of the deployer to process this request.", + required=True, +) +@click.option("--env-file", help="environment file for webapp") +@click.option( + "--make-payment", + help="The payment to make (in alnt). The value should be a number or 'auto' to use the deployer's minimum required payment.", +) +@click.option( + "--use-payment", help="The TX id of an existing, unused payment", default=None +) +@click.option("--dns", help="the DNS name to request (default is autogenerated)") +@click.option( + "--dry-run", + help="Don't publish anything, just report what would be done.", + is_flag=True, +) +@click.pass_context +def command( + ctx, + laconic_config, + app, + deployer, + env_file, + make_payment, + use_payment, + dns, + dry_run, +): # noqa: C901 + tempdir = tempfile.mkdtemp() + try: + laconic = LaconicRegistryClient(laconic_config) + + app_record = laconic.get_record(app) + if not app_record: + fatal(f"Unable to locate app: {app}") + + deployer_record = laconic.get_record(deployer) + if not deployer_record: + fatal(f"Unable to locate deployer: {deployer}") + + config_ref = None + + # If env_file + if env_file: + gpg = gnupg.GPG(gnupghome=tempdir) + + # Import the deployer's public key + result = gpg.import_keys( + base64.b64decode(deployer_record.attributes.publicKey) + ) + if 1 != result.imported: + fatal("Failed to import deployer's public key.") + + recip = gpg.list_keys()[0]["uids"][0] + + # Wrap the config + config = { + # Include account (and payment?) details + "authorized": [laconic.whoami().address], + "config": {"env": dict(dotenv_values(env_file))}, + } + serialized = yaml.dump(config) + + # Encrypt + result = gpg.encrypt(serialized, recip, always_trust=True, armor=False) + if not result.ok: + fatal("Failed to encrypt config.") + + # Upload it to the deployer's API + response = requests.post( + f"{deployer_record.attributes.apiUrl}/upload/config", + data=result.data, + headers={"Content-Type": "application/octet-stream"}, + ) + if not response.ok: + response.raise_for_status() + + config_ref = response.json()["id"] + + deployment_request = { + "record": { + "type": "ApplicationDeploymentRequest", + "application": app, + "version": "1.0.0", + "name": f"{app_record.attributes.name}@{app_record.attributes.version}", + "deployer": deployer, + "meta": { + "when": str(datetime.utcnow()) + } + } + } + + if config_ref: + deployment_request["record"]["config"] = {"ref": config_ref} + + if dns: + deployment_request["record"]["dns"] = dns.lower() + + if make_payment: + amount = 0 + if dry_run: + deployment_request["record"]["payment"] = "DRY_RUN" + elif "auto" == make_payment: + if "minimumPayment" in deployer_record.attributes: + amount = int( + deployer_record.attributes.minimumPayment.replace("alnt", "") + ) + else: + amount = make_payment + if amount: + receipt = laconic.send_tokens( + deployer_record.attributes.paymentAddress, amount + ) + deployment_request["record"]["payment"] = receipt.tx.hash + print("Payment TX:", receipt.tx.hash) + elif use_payment: + deployment_request["record"]["payment"] = use_payment + + if dry_run: + print(yaml.dump(deployment_request)) + return + + # Send the request + laconic.publish(deployment_request) + finally: + shutil.rmtree(tempdir) diff --git a/stack_orchestrator/deploy/webapp/undeploy_webapp_from_registry.py b/stack_orchestrator/deploy/webapp/undeploy_webapp_from_registry.py index 7dd6a9d5..cbab5896 100644 --- a/stack_orchestrator/deploy/webapp/undeploy_webapp_from_registry.py +++ b/stack_orchestrator/deploy/webapp/undeploy_webapp_from_registry.py @@ -38,7 +38,7 @@ def process_app_removal_request( deployment_parent_dir, delete_volumes, delete_names, - payment_address, + webapp_deployer_record, ): deployment_record = laconic.get_record( app_removal_request.attributes.deployment, require=True @@ -84,7 +84,7 @@ def process_app_removal_request( "version": "1.0.0", "request": app_removal_request.id, "deployment": deployment_record.id, - "by": payment_address, + "deployer": webapp_deployer_record.names[0], } } @@ -168,15 +168,10 @@ def dump_known_requests(filename, requests): ) @click.option( "--min-required-payment", - help="Requests must have a minimum payment to be processed", + help="Requests must have a minimum payment to be processed (in alnt)", 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("--lrn", help="The LRN of this deployer.", required=True) @click.option( "--all-requests", help="Handle requests addressed to anyone (by default only requests to" @@ -198,7 +193,7 @@ def command( # noqa: C901 include_tags, exclude_tags, min_required_payment, - payment_address, + lrn, all_requests, ): if request_id and discover: @@ -218,8 +213,16 @@ 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 + deployer_record = laconic.get_record(lrn, require=True) + payment_address = deployer_record.attributes.paymentAddress + main_logger.log(f"Payment address: {payment_address}") + + if min_required_payment and not payment_address: + print( + f"Minimum payment required, but no payment address listed for deployer: {lrn}.", + file=sys.stderr, + ) + sys.exit(2) # Find deployment removal requests. # single request @@ -233,7 +236,7 @@ def command( # noqa: C901 if all_requests: requests = laconic.app_deployment_removal_requests() else: - requests = laconic.app_deployment_removal_requests({"to": payment_address}) + requests = laconic.app_deployment_removal_requests({"deployer": lrn}) if only_update_state: if not dry_run: @@ -312,7 +315,11 @@ def command( # noqa: C901 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 + laconic, + r, + payment_address, + min_required_payment, + main_logger, ): main_logger.log(f"{r.id}: Payment confirmed.") requests_to_execute.append(r) @@ -336,7 +343,7 @@ def command( # noqa: C901 os.path.abspath(deployment_parent_dir), delete_volumes, delete_names, - payment_address, + deployer_record, ) except Exception as e: main_logger.log(f"ERROR processing removal request {r.id}: {e}") diff --git a/stack_orchestrator/deploy/webapp/util.py b/stack_orchestrator/deploy/webapp/util.py index e757f5d5..c6b6a786 100644 --- a/stack_orchestrator/deploy/webapp/util.py +++ b/stack_orchestrator/deploy/webapp/util.py @@ -1,4 +1,4 @@ -# Copyright © 2023 Vulcanize +# = str(min_required_payment) Copyright © 2023 Vulcanize # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -142,14 +142,14 @@ def confirm_payment(laconic, record, payment_address, min_amount, logger): # Check if the payment was already used on a used = laconic.app_deployments( - {"by": payment_address, "payment": tx.hash}, all=True + {"deployer": 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 + {"deployer": payment_address, "payment": tx.hash}, all=True ) if len(used): logger.log( @@ -453,6 +453,24 @@ class LaconicRegistryClient: name, ) + def send_tokens(self, address, amount, type="alnt"): + args = [ + "laconic", + "-c", + self.config_file, + "registry", + "tokens", + "send", + "--address", + address, + "--quantity", + str(amount), + "--type", + type, + ] + + return AttrDict(json.loads(logged_cmd(self.log_file, *args))) + def file_hash(filename): return hashlib.sha1(open(filename).read().encode()).hexdigest() @@ -609,7 +627,7 @@ def publish_deployment( dns_lrn, deployment_dir, app_deployment_request=None, - payment_address=None, + webapp_deployer_record=None, logger=None, ): if not deploy_record: @@ -666,8 +684,8 @@ def publish_deployment( "payment" ] = app_deployment_request.attributes.payment - if payment_address: - new_deployment_record["record"]["by"] = payment_address + if webapp_deployer_record: + new_deployment_record["record"]["deployer"] = webapp_deployer_record.names[0] if logger: logger.log("Publishing ApplicationDeploymentRecord.") diff --git a/stack_orchestrator/main.py b/stack_orchestrator/main.py index 06fe4ec7..5ae10468 100644 --- a/stack_orchestrator/main.py +++ b/stack_orchestrator/main.py @@ -24,7 +24,9 @@ from stack_orchestrator.build import build_webapp from stack_orchestrator.deploy.webapp import (run_webapp, deploy_webapp, deploy_webapp_from_registry, - undeploy_webapp_from_registry) + undeploy_webapp_from_registry, + publish_webapp_deployer, + request_webapp_deployment) from stack_orchestrator.deploy import deploy from stack_orchestrator import version from stack_orchestrator.deploy import deployment @@ -61,6 +63,8 @@ cli.add_command(run_webapp.command, "run-webapp") cli.add_command(deploy_webapp.command, "deploy-webapp") cli.add_command(deploy_webapp_from_registry.command, "deploy-webapp-from-registry") cli.add_command(undeploy_webapp_from_registry.command, "undeploy-webapp-from-registry") +cli.add_command(publish_webapp_deployer.command, "publish-deployer-to-registry") +cli.add_command(request_webapp_deployment.command, "request-webapp-deployment") cli.add_command(deploy.command, "deploy") # deploy is an alias for deploy-system cli.add_command(deploy.command, "deploy-system") cli.add_command(deployment.command, "deployment")