Support uploaded config, add 'publish-webapp-deployer' and 'request-webapp-deployment' commands #938

Merged
telackey merged 12 commits from telackey/configupload into main 2024-08-27 19:55:07 +00:00
8 changed files with 410 additions and 48 deletions

View File

@ -11,3 +11,5 @@ tomli==2.0.1
validators==0.22.0 validators==0.22.0
kubernetes>=28.1.0 kubernetes>=28.1.0
humanfriendly>=10.0 humanfriendly>=10.0
python-gnupg>=0.5.2
requests>=2.3.2

View File

@ -14,6 +14,7 @@
# along with this program. If not, see <http:#www.gnu.org/licenses/>. # along with this program. If not, see <http:#www.gnu.org/licenses/>.
import os import os
import base64
from kubernetes import client from kubernetes import client
from typing import Any, List, Set from typing import Any, List, Set
@ -260,12 +261,12 @@ class ClusterInfo:
for f in os.listdir(cfg_map_path): for f in os.listdir(cfg_map_path):
full_path = os.path.join(cfg_map_path, f) full_path = os.path.join(cfg_map_path, f)
if os.path.isfile(full_path): 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( spec = client.V1ConfigMap(
metadata=client.V1ObjectMeta(name=f"{self.app_name}-{cfg_map_name}", metadata=client.V1ObjectMeta(name=f"{self.app_name}-{cfg_map_name}",
labels={"configmap-label": cfg_map_name}), labels={"configmap-label": cfg_map_name}),
data=data binary_data=data
) )
result.append(spec) result.append(spec)
return result return result

View File

@ -21,12 +21,15 @@ import sys
import tempfile import tempfile
import time import time
import uuid import uuid
import yaml
import click import click
import gnupg
from stack_orchestrator.deploy.images import remote_image_exists from stack_orchestrator.deploy.images import remote_image_exists
from stack_orchestrator.deploy.webapp import deploy_webapp from stack_orchestrator.deploy.webapp import deploy_webapp
from stack_orchestrator.deploy.webapp.util import ( from stack_orchestrator.deploy.webapp.util import (
AttrDict,
LaconicRegistryClient, LaconicRegistryClient,
TimedLogger, TimedLogger,
build_container_image, build_container_image,
@ -55,7 +58,10 @@ def process_app_deployment_request(
force_rebuild, force_rebuild,
fqdn_policy, fqdn_policy,
recreate_on_deploy, recreate_on_deploy,
payment_address, webapp_deployer_record,
gpg,
private_key_passphrase,
config_upload_dir,
logger, logger,
): ):
logger.log("BEGIN - process_app_deployment_request") logger.log("BEGIN - process_app_deployment_request")
@ -107,14 +113,31 @@ def process_app_deployment_request(
) )
# 4. get build and runtime config from 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 env_filename = None
if ( if env:
app_deployment_request.attributes.config
and "env" in app_deployment_request.attributes.config
):
env_filename = tempfile.mktemp() env_filename = tempfile.mktemp()
with open(env_filename, "w") as file: 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)))) file.write("%s=%s\n" % (k, shlex.quote(str(v))))
# 5. determine new or existing deployment # 5. determine new or existing deployment
@ -227,7 +250,7 @@ def process_app_deployment_request(
dns_lrn, dns_lrn,
deployment_dir, deployment_dir,
app_deployment_request, app_deployment_request,
payment_address, webapp_deployer_record,
logger, logger,
) )
logger.log("Publication complete.") 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", help="How to handle requests with an FQDN: prohibit, allow, preexisting",
default="prohibit", default="prohibit",
) )
@click.option("--record-namespace-dns", help="eg, lrn://laconic/dns") @click.option("--record-namespace-dns", help="eg, lrn://laconic/dns", required=True)
@click.option("--record-namespace-deployments", help="eg, lrn://laconic/deployments") @click.option(
"--record-namespace-deployments",
help="eg, lrn://laconic/deployments",
required=True,
)
@click.option( @click.option(
"--dry-run", help="Don't do anything, just report what would be done.", is_flag=True "--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( @click.option(
"--min-required-payment", "--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, default=0,
) )
@click.option( @click.option("--lrn", help="The LRN of this deployer.", required=True)
"--payment-address",
help="The address to which payments should be made. "
"Default is the current laconic account.",
default=None,
)
@click.option( @click.option(
"--all-requests", "--all-requests",
help="Handle requests addressed to anyone (by default only requests to" help="Handle requests addressed to anyone (by default only requests to"
"my payment address are examined).", "my payment address are examined).",
is_flag=True, 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 @click.pass_context
def command( # noqa: C901 def command( # noqa: C901
ctx, ctx,
@ -350,7 +385,10 @@ def command( # noqa: C901
recreate_on_deploy, recreate_on_deploy,
log_dir, log_dir,
min_required_payment, min_required_payment,
payment_address, lrn,
config_upload_dir,
private_key_file,
private_key_passphrase,
all_requests, all_requests,
): ):
if request_id and discover: if request_id and discover:
@ -384,6 +422,18 @@ def command( # noqa: C901
) )
sys.exit(2) 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) main_logger = TimedLogger(file=sys.stderr)
try: try:
@ -392,11 +442,17 @@ 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: webapp_deployer_record = laconic.get_record(lrn, require=True)
payment_address = laconic.whoami().address payment_address = webapp_deployer_record.attributes.paymentAddress
main_logger.log(f"Payment address: {payment_address}") 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. # Find deployment requests.
# single request # single request
if request_id: if request_id:
@ -408,7 +464,7 @@ def command( # noqa: C901
if all_requests: if all_requests:
requests = laconic.app_deployment_requests() requests = laconic.app_deployment_requests()
else: else:
requests = laconic.app_deployment_requests({"to": payment_address}) requests = laconic.app_deployment_requests({"deployer": lrn})
if only_update_state: if only_update_state:
if not dry_run: if not dry_run:
@ -487,7 +543,7 @@ def command( # noqa: C901
if all_requests: if all_requests:
deployments = laconic.app_deployments() deployments = laconic.app_deployments()
else: else:
deployments = laconic.app_deployments({"by": payment_address}) deployments = laconic.app_deployments({"deployer": lrn})
deployments_by_request = {} deployments_by_request = {}
for d in deployments: for d in deployments:
if d.attributes.request: if d.attributes.request:
@ -530,7 +586,11 @@ def command( # noqa: C901
for r in requests_to_check_for_payment: for r in requests_to_check_for_payment:
main_logger.log(f"{r.id}: Confirming payment...") main_logger.log(f"{r.id}: Confirming payment...")
if confirm_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.") main_logger.log(f"{r.id}: Payment confirmed.")
requests_to_execute.append(r) requests_to_execute.append(r)
@ -583,7 +643,10 @@ def command( # noqa: C901
force_rebuild, force_rebuild,
fqdn_policy, fqdn_policy,
recreate_on_deploy, recreate_on_deploy,
payment_address, webapp_deployer_record,
gpg,
private_key_passphrase,
config_upload_dir,
build_logger, build_logger,
) )
status = "DEPLOYED" status = "DEPLOYED"
@ -604,3 +667,5 @@ def command( # noqa: C901
except Exception as e: except Exception as e:
main_logger.log("UNCAUGHT ERROR:" + str(e)) main_logger.log("UNCAUGHT ERROR:" + str(e))
raise e raise e
finally:
shutil.rmtree(tempdir)

View File

@ -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 <http:#www.gnu.org/licenses/>.
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])

View File

@ -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 <http:#www.gnu.org/licenses/>.
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)

View File

@ -38,7 +38,7 @@ def process_app_removal_request(
deployment_parent_dir, deployment_parent_dir,
delete_volumes, delete_volumes,
delete_names, delete_names,
payment_address, webapp_deployer_record,
): ):
deployment_record = laconic.get_record( deployment_record = laconic.get_record(
app_removal_request.attributes.deployment, require=True app_removal_request.attributes.deployment, require=True
@ -84,7 +84,7 @@ def process_app_removal_request(
"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, "deployer": webapp_deployer_record.names[0],
} }
} }
@ -168,15 +168,10 @@ def dump_known_requests(filename, requests):
) )
@click.option( @click.option(
"--min-required-payment", "--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, default=0,
) )
@click.option( @click.option("--lrn", help="The LRN of this deployer.", required=True)
"--payment-address",
help="The address to which payments should be made. "
"Default is the current laconic account.",
default=None,
)
@click.option( @click.option(
"--all-requests", "--all-requests",
help="Handle requests addressed to anyone (by default only requests to" help="Handle requests addressed to anyone (by default only requests to"
@ -198,7 +193,7 @@ def command( # noqa: C901
include_tags, include_tags,
exclude_tags, exclude_tags,
min_required_payment, min_required_payment,
payment_address, lrn,
all_requests, all_requests,
): ):
if request_id and discover: 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] 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: deployer_record = laconic.get_record(lrn, require=True)
payment_address = laconic.whoami().address 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. # Find deployment removal requests.
# single request # single request
@ -233,7 +236,7 @@ def command( # noqa: C901
if all_requests: if all_requests:
requests = laconic.app_deployment_removal_requests() requests = laconic.app_deployment_removal_requests()
else: else:
requests = laconic.app_deployment_removal_requests({"to": payment_address}) requests = laconic.app_deployment_removal_requests({"deployer": lrn})
if only_update_state: if only_update_state:
if not dry_run: if not dry_run:
@ -312,7 +315,11 @@ def command( # noqa: C901
for r in requests_to_check_for_payment: for r in requests_to_check_for_payment:
main_logger.log(f"{r.id}: Confirming payment...") main_logger.log(f"{r.id}: Confirming payment...")
if confirm_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.") main_logger.log(f"{r.id}: Payment confirmed.")
requests_to_execute.append(r) requests_to_execute.append(r)
@ -336,7 +343,7 @@ def command( # noqa: C901
os.path.abspath(deployment_parent_dir), os.path.abspath(deployment_parent_dir),
delete_volumes, delete_volumes,
delete_names, delete_names,
payment_address, deployer_record,
) )
except Exception as e: except Exception as e:
main_logger.log(f"ERROR processing removal request {r.id}: {e}") main_logger.log(f"ERROR processing removal request {r.id}: {e}")

View File

@ -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 # 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 # 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 # Check if the payment was already used on a
used = laconic.app_deployments( used = laconic.app_deployments(
{"by": payment_address, "payment": tx.hash}, all=True {"deployer": payment_address, "payment": tx.hash}, all=True
) )
if len(used): if len(used):
logger.log(f"{record.id}: payment {tx.hash} already used on deployment {used}") logger.log(f"{record.id}: payment {tx.hash} already used on deployment {used}")
return False return False
used = laconic.app_deployment_removals( used = laconic.app_deployment_removals(
{"by": payment_address, "payment": tx.hash}, all=True {"deployer": payment_address, "payment": tx.hash}, all=True
) )
if len(used): if len(used):
logger.log( logger.log(
@ -453,6 +453,24 @@ class LaconicRegistryClient:
name, 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): def file_hash(filename):
return hashlib.sha1(open(filename).read().encode()).hexdigest() return hashlib.sha1(open(filename).read().encode()).hexdigest()
@ -609,7 +627,7 @@ def publish_deployment(
dns_lrn, dns_lrn,
deployment_dir, deployment_dir,
app_deployment_request=None, app_deployment_request=None,
payment_address=None, webapp_deployer_record=None,
logger=None, logger=None,
): ):
if not deploy_record: if not deploy_record:
@ -666,8 +684,8 @@ def publish_deployment(
"payment" "payment"
] = app_deployment_request.attributes.payment ] = app_deployment_request.attributes.payment
if payment_address: if webapp_deployer_record:
new_deployment_record["record"]["by"] = payment_address new_deployment_record["record"]["deployer"] = webapp_deployer_record.names[0]
if logger: if logger:
logger.log("Publishing ApplicationDeploymentRecord.") logger.log("Publishing ApplicationDeploymentRecord.")

View File

@ -24,7 +24,9 @@ from stack_orchestrator.build import build_webapp
from stack_orchestrator.deploy.webapp import (run_webapp, from stack_orchestrator.deploy.webapp import (run_webapp,
deploy_webapp, deploy_webapp,
deploy_webapp_from_registry, 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.deploy import deploy
from stack_orchestrator import version from stack_orchestrator import version
from stack_orchestrator.deploy import deployment 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.command, "deploy-webapp")
cli.add_command(deploy_webapp_from_registry.command, "deploy-webapp-from-registry") 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(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") # deploy is an alias for deploy-system
cli.add_command(deploy.command, "deploy-system") cli.add_command(deploy.command, "deploy-system")
cli.add_command(deployment.command, "deployment") cli.add_command(deployment.command, "deployment")