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")