Add deployment update and deploy-webapp-from-registry commands. #676

Merged
telackey merged 23 commits from telackey/deployer into main 2023-12-14 03:02:34 +00:00
6 changed files with 533 additions and 195 deletions
Showing only changes of commit 2495123446 - Show all commits

View File

@ -22,7 +22,6 @@ from stack_orchestrator.deploy.deploy import up_operation, down_operation, ps_op
from stack_orchestrator.deploy.deploy import exec_operation, logs_operation, create_deploy_context, update_operation
from stack_orchestrator.deploy.deploy_types import DeployCommandContext
from stack_orchestrator.deploy.deployment_context import DeploymentContext
from stack_orchestrator.deploy.webapp import update_from_registry as webapp_update
@click.group()
@ -152,18 +151,6 @@ def status(ctx):
status_operation(ctx)
@command.command()
@click.option("--laconic-config", help="laconic-cli config file", required=True)
# TODO: Add to spec.yml?
@click.option("--app-crn", help="application CRN", required=True)
@click.option("--deployment-crn", help="webapp deployment CRN", required=True)
@click.option("--force", help="force redeployment", is_flag=True, default=False)
@click.pass_context
def update_from_registry(ctx, laconic_config, app_crn, deployment_crn, force):
ctx.obj = make_deploy_context(ctx)
webapp_update.update(ctx, str(ctx.obj.stack.parent), laconic_config, app_crn, deployment_crn, force)
@command.command()
@click.pass_context
def update(ctx):

View File

@ -54,26 +54,7 @@ def _fixup_url_spec(spec_file_name: str, url: str):
wfile.write(contents)
@click.group()
@click.pass_context
def command(ctx):
'''manage a webapp deployment'''
# Check that --stack wasn't supplied
if ctx.parent.obj.stack:
error_exit("--stack can't be supplied with the deploy-webapp command")
@command.command()
@click.option("--kube-config", help="Provide a config file for a k8s deployment")
@click.option("--image-registry", help="Provide a container image registry url for this k8s cluster")
@click.option("--deployment-dir", help="Create deployment files in this directory", required=True)
@click.option("--image", help="image to deploy", required=True)
@click.option("--url", help="url to serve", required=True)
@click.option("--env-file", help="environment file for webapp")
@click.pass_context
def create(ctx, deployment_dir, image, url, kube_config, image_registry, env_file):
'''create a deployment for the specified webapp container'''
def create_deployment(ctx, deployment_dir, image, url, kube_config, image_registry, env_file):
# Do the equivalent of:
# 1. laconic-so --stack webapp-template deploy --deploy-to k8s init --output webapp-spec.yml
# --config (eqivalent of the contents of my-config.env)
@ -92,7 +73,7 @@ def create(ctx, deployment_dir, image, url, kube_config, image_registry, env_fil
# TODO: support env file
deploy_command_context: DeployCommandContext = create_deploy_context(
global_options2(ctx), None, stack, None, None, None, env_file, "k8s"
)
)
init_operation(
deploy_command_context,
stack,
@ -116,3 +97,27 @@ def create(ctx, deployment_dir, image, url, kube_config, image_registry, env_fil
# Fix up the container tag inside the deployment compose file
_fixup_container_tag(deployment_dir, image)
os.remove(spec_file_name)
@click.group()
@click.pass_context
def command(ctx):
'''manage a webapp deployment'''
# Check that --stack wasn't supplied
if ctx.parent.obj.stack:
error_exit("--stack can't be supplied with the deploy-webapp command")
@command.command()
@click.option("--kube-config", help="Provide a config file for a k8s deployment")
@click.option("--image-registry", help="Provide a container image registry url for this k8s cluster")
@click.option("--deployment-dir", help="Create deployment files in this directory", required=True)
@click.option("--image", help="image to deploy", required=True)
@click.option("--url", help="url to serve", required=True)
@click.option("--env-file", help="environment file for webapp")
@click.pass_context
def create(ctx, deployment_dir, image, url, kube_config, image_registry, env_file):
'''create a deployment for the specified webapp container'''
return create_deployment(ctx, deployment_dir, image, url, kube_config, image_registry, env_file)

View File

@ -0,0 +1,259 @@
# 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 hashlib
import json
import os
import shlex
import shutil
import sys
import tempfile
import click
from stack_orchestrator.deploy.webapp import deploy_webapp
from stack_orchestrator.deploy.webapp.util import (AttrDict, LaconicRegistryClient,
build_container_image, push_container_image,
file_hash, deploy_to_k8s, publish_deployment)
def process_app_deployment_request(
ctx,
laconic: LaconicRegistryClient,
app_deployment_request,
deployment_record_namespace,
dns_record_namespace,
deployment_parent_dir,
kube_config,
image_registry
):
# 1. look up application
app = laconic.get_record(app_deployment_request.attributes.application, require=True)
# 2. determine dns
requested_name = hostname_for_deployment_request(app_deployment_request, laconic)
# HACK
if "." in requested_name:
raise Exception("Only unqualified hostnames allowed at this time.")
# HACK
fqdn = f"{requested_name}.laconic.servesthe.world"
container_tag = "%s:local" % app.attributes.name.replace("@", "")
# 3. check ownership of existing dnsrecord vs this request
# 4. get build and runtime config from request
env_filename = None
if app_deployment_request.attributes.config and "env" in app_deployment_request.attributes.config:
env_filename = tempfile.mktemp()
with open(env_filename, 'w') as file:
for k, v in app_deployment_request.attributes.config["env"].items():
file.write("%s=%s\n" % (k, shlex.quote(str(v))))
# 5. determine new or existing deployment
# a. check for deployment crn
app_deployment_crn = f"{deployment_record_namespace}/{fqdn}"
if app_deployment_request.attributes.deployment:
app_deployment_crn = app_deployment_request.attributes.deployment
if not app_deployment_crn.startswith(deployment_record_namespace):
raise Exception("Deployment CRN %s is not in a supported namespace" % app_deployment_request.attributes.deployment)
deployment_record = laconic.get_record(app_deployment_crn)
deployment_dir = os.path.join(deployment_parent_dir, fqdn)
deployment_config_file = os.path.join(deployment_dir, "config.env")
# b. check for deployment directory (create if necessary)
if not os.path.exists(deployment_dir):
if deployment_record:
raise ("Deployment record %s exists, but not deployment dir %s. Please remove name." %
(app_deployment_crn, deployment_dir))
print("deploy_webapp", deployment_dir)
deploy_webapp.create_deployment(ctx, deployment_dir, container_tag,
f"https://{fqdn}", kube_config, image_registry, env_filename)
elif env_filename:
shutil.copyfile(env_filename, deployment_config_file)
needs_k8s_deploy = False
# 6. build container (if needed)
if not deployment_record or deployment_record.attributes.application != app.id:
build_container_image(app, container_tag)
push_container_image(deployment_dir)
needs_k8s_deploy = True
# 7. update config (if needed)
if not deployment_record or file_hash(deployment_config_file) != deployment_record.attributes.meta.config:
needs_k8s_deploy = True
# 8. update k8s deployment
if needs_k8s_deploy:
print("Deploying to k8s")
deploy_to_k8s(
laconic,
app,
deployment_record,
app_deployment_crn,
deployment_dir,
app_deployment_request
)
publish_deployment(
laconic,
app,
deployment_record,
app_deployment_crn,
deployment_dir,
app_deployment_request
)
def hostname_for_deployment_request(app_deployment_request, laconic):
dns_name = app_deployment_request.attributes.dns
if not dns_name:
app = laconic.get_record(app_deployment_request.attributes.application, require=True)
dns_name = generate_hostname_for_app(app)
elif dns_name.startswith("crn://"):
record = laconic.get_record(dns_name, require=True)
dns_name = record.attributes.name
return dns_name
def generate_hostname_for_app(app):
last_part = app.attributes.name.split("/")[-1]
m = hashlib.sha256()
m.update(app.attributes.name.encode())
m.update(b"|")
if isinstance(app.attributes.repository, list):
m.update(app.attributes.repository[0].encode())
else:
m.update(app.attributes.repository.encode())
return "%s-%s" % (last_part, m.hexdigest()[0:10])
def load_known_requests(filename):
if filename and os.path.exists(filename):
return json.load(open(filename, "r"))
return {}
def dump_known_requests(filename, requests):
if not filename:
return
known_requests = load_known_requests(filename)
for r in requests:
known_requests[r.id] = r.createTime
json.dump(known_requests, open(filename, "w"))
@click.command()
@click.option("--kube-config", help="Provide a config file for a k8s deployment")
@click.option("--laconic-config", help="Provide a config file for laconicd", required=True)
@click.option("--image-registry", help="Provide a container image registry url for this k8s cluster")
@click.option("--deployment-parent-dir", help="Create deployment directories beneath this directory", required=True)
@click.option("--request-id", help="The ApplicationDeploymentRequest to process")
@click.option("--discover", help="Discover and process all pending ApplicationDeploymentRequests", is_flag=True, default=False)
@click.option("--state-file", help="File to store state about previously seen requests.")
@click.option("--only-update-state", help="Only update the state file, don't process any requests anything.", is_flag=True)
@click.pass_context
def command(ctx, kube_config, laconic_config, image_registry, deployment_parent_dir, request_id, discover, state_file, only_update_state):
if request_id and discover:
print("Cannot specify both --request-id and --discover", file=sys.stderr)
sys.exit(2)
if not request_id and not discover:
print("Must specify either --request-id or --discover", file=sys.stderr)
sys.exit(2)
if only_update_state and not state_file:
print("--only-update-state requires --state-file", file=sys.stderr)
sys.exit(2)
laconic = LaconicRegistryClient(laconic_config)
# Find deployment requests.
# single request
if request_id:
requests = [laconic.get_record(request_id, require=True)]
# all requests
elif discover:
requests = laconic.app_deployment_requests()
if only_update_state:
dump_known_requests(state_file, requests)
return
previous_requests = load_known_requests(state_file)
# Collapse related requests.
requests.sort(key=lambda r: r.createTime)
requests.reverse()
requests_by_name = {}
for r in requests:
app = laconic.get_record(r.attributes.application)
if not app:
print("Skipping request %s, cannot locate app." % r.id)
continue
requested_name = r.attributes.dns
if not requested_name:
requested_name = generate_hostname_for_app(app)
print("Generating name %s for request %s." % (requested_name, r.id))
if requested_name not in requests_by_name:
print(
"Found request %s to run application %s on %s."
% (r.id, r.attributes.application, requested_name)
)
requests_by_name[requested_name] = r
else:
print(
"Ignoring request %s, it is superseded by %s."
% (r.id, requests_by_name[requested_name].id)
)
# Find deployments.
deployments = laconic.app_deployments()
deployments_by_request = {}
for d in deployments:
if d.attributes.request:
deployments_by_request[d.attributes.request] = d
requests_to_execute = []
for r in requests_by_name.values():
if r.id not in deployments_by_request:
if r.id not in previous_requests:
print(f"Request {r.id} needs to processed.")
requests_to_execute.append(r)
else:
print(
f"Skipping unsatisfied request {r.id} because we have seen it before."
)
else:
print(f"Found satisfied request {r.id}")
print("Found %d unsatisfied request(s) to process." % len(requests_to_execute))
for r in requests_to_execute:
try:
process_app_deployment_request(
ctx,
laconic,
r,
"crn://cerc-io/deployments",
"crn://cerc-io/dns",
deployment_parent_dir,
kube_config,
image_registry
)
finally:
dump_known_requests(state_file, [r])

View File

@ -1,160 +0,0 @@
# 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 hashlib
import json
import os
import subprocess
import sys
import tempfile
import yaml
def cmd(*vargs):
try:
result = subprocess.run(vargs, capture_output=True)
result.check_returncode()
return result.stdout.decode()
except Exception as err:
print(result.stderr.decode())
raise err
def build_image(app_record, deployment_dir):
tmpdir = tempfile.mkdtemp()
try:
record_id = app_record["id"]
name = app_record.get("attributes", {})["name"].replace("@", "")
tag = app_record.get("attributes", {}).get("repository_tag")
repo = app_record.get("attributes", {}).get("repository")
clone_dir = os.path.join(tmpdir, record_id)
print(f"Cloning repository {repo} to {clone_dir} ...")
if tag:
result = subprocess.run(["git", "clone", "--depth", "1", "--branch", tag, repo, clone_dir])
result.check_returncode()
else:
result = subprocess.run(["git", "clone", "--depth", "1", repo, clone_dir])
result.check_returncode()
print("Building webapp ...")
result = subprocess.run([sys.argv[0], "build-webapp", "--source-repo", clone_dir, "--tag", f"{name}:local"])
result.check_returncode()
print("Pushing image ...")
result = subprocess.run([sys.argv[0], "deployment", "--dir", deployment_dir, "push-images"])
result.check_returncode()
finally:
cmd("rm", "-rf", tmpdir)
def config_hash(deployment_dir):
return hashlib.sha1(open(os.path.join(deployment_dir, "config.env")).read().encode()).hexdigest()
def config_changed(deploy_record, deployment_dir):
if not deploy_record:
return True
old = json.loads(deploy_record["attributes"]["meta"])["config"]
return config_hash(deployment_dir) != old
def redeploy(laconic_config, app_record, deploy_record, deploy_crn, deployment_dir):
print("Updating deployment ...")
result = subprocess.run([sys.argv[0], "deployment", "--dir", deployment_dir, "update"])
result.check_returncode()
spec = yaml.full_load(open(os.path.join(deployment_dir, "spec.yml")))
hostname = spec["network"]["http-proxy"][0]["host-name"]
if not deploy_record:
version = "0.0.1"
else:
version = "0.0.%d" % (int(deploy_record["attributes"]["version"].split(".")[-1]) + 1)
meta = {
"record": {
"type": "WebAppDeploymentRecord",
"version": version,
"url": f"http://{hostname}",
"name": hostname,
"application": app_record["id"],
"meta": {
"config": config_hash(deployment_dir)
},
}
}
tmpdir = tempfile.mkdtemp()
try:
record_fname = os.path.join(tmpdir, "record.yml")
record_file = open(record_fname, 'w')
yaml.dump(meta, record_file)
record_file.close()
print(open(record_fname, 'r').read())
print("Updating deployment record ...")
new_record_id = json.loads(
cmd("laconic", "-c", laconic_config, "cns", "record", "publish", "--filename", record_fname)
)["id"]
print("Updating deployment registered name ...")
cmd("laconic", "-c", laconic_config, "cns", "name", "set", deploy_crn, new_record_id)
finally:
cmd("rm", "-rf", tmpdir)
def update(ctx, deployment_dir, laconic_config, app_crn, deploy_crn, force=False):
'''update the specified webapp deployment'''
# The deployment must already exist
if not os.path.exists(deployment_dir):
print("Deployment does not exist:", deployment_dir, file=sys.stderr)
sys.exit(1)
# resolve name
app_record = json.loads(cmd("laconic", "-c", laconic_config, "cns", "name", "resolve", app_crn))[0]
# compare
try:
deploy_record = json.loads(cmd("laconic", "-c", laconic_config, "cns", "name", "resolve", deploy_crn))[0]
except: # noqa: E722
deploy_record = {}
needs_update = False
if app_record["id"] == deploy_record.get("attributes", {}).get("application"):
print("Deployment %s has latest application: %s" % (deploy_crn, app_record["id"]))
else:
needs_update = True
print("Found updated application record eligible for deployment %s (old: %s, new: %s)" % (
deploy_crn, deploy_record.get("id"), app_record["id"]))
build_image(app_record, deployment_dir)
# check config
if config_changed(deploy_record, deployment_dir):
needs_update = True
old = None
if deploy_record:
old = json.loads(deploy_record["attributes"]["meta"])["config"]
print("Deployment %s has changed config: (old: %s, new: %s)" % (
deploy_crn, old, config_hash(deployment_dir)))
else:
print("Deployment %s has latest config: %s" % (
deploy_crn, json.loads(deploy_record["attributes"]["meta"])["config"]))
if needs_update or force:
redeploy(laconic_config, app_record, deploy_record, deploy_crn, deployment_dir)

View File

@ -0,0 +1,246 @@
# 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 hashlib
import json
import os
import random
import subprocess
import sys
import tempfile
import yaml
class AttrDict(dict):
def __init__(self, *args, **kwargs):
super(AttrDict, self).__init__(*args, **kwargs)
self.__dict__ = self
def __getattribute__(self, attr):
__dict__ = super(AttrDict, self).__getattribute__("__dict__")
if attr in __dict__:
v = super(AttrDict, self).__getattribute__(attr)
if isinstance(v, dict):
return AttrDict(v)
return v
def cmd(*vargs):
try:
result = subprocess.run(vargs, capture_output=True)
result.check_returncode()
return result.stdout.decode()
except Exception as err:
print(result.stderr.decode())
raise err
class LaconicRegistryClient:
def __init__(self, config_file):
self.config_file = config_file
self.cache = AttrDict(
{
"name_or_id": {},
}
)
def list_records(self, criteria={}, all=False):
args = ["laconic", "-c", self.config_file, "cns", "record", "list"]
if all:
args.append("--all")
if criteria:
for k, v in criteria.items():
args.append("--%s" % k)
args.append(str(v))
results = [AttrDict(r) for r in json.loads(cmd(*args))]
# Most recent records first
results.sort(key=lambda r: r.createTime)
results.reverse()
return results
def is_crn(self, name_or_id: str):
if name_or_id:
return str(name_or_id).startswith("crn://")
return False
def is_id(self, name_or_id: str):
return not self.is_crn(name_or_id)
def _add_to_cache(self, records):
if not records:
return
for p in records:
self.cache["name_or_id"][p.id] = p
if p.names:
for crn in p.names:
self.cache["name_or_id"][crn] = p
if p.attributes.type not in self.cache:
self.cache[p.attributes.type] = []
self.cache[p.attributes.type].append(p)
def resolve(self, name):
if not name:
return None
if name in self.cache.name_or_id:
return self.cache.name_or_id[name]
args = ["laconic", "-c", self.config_file, "cns", "name", "resolve", name]
parsed = [AttrDict(r) for r in json.loads(cmd(*args))]
if parsed:
self._add_to_cache(parsed)
return parsed[0]
return None
def get_record(self, name_or_id, require=False):
if not name_or_id:
if require:
raise Exception("Cannot locate record:", name_or_id)
return None
if name_or_id in self.cache.name_or_id:
return self.cache.name_or_id[name_or_id]
if self.is_crn(name_or_id):
return self.resolve(name_or_id)
args = [
"laconic",
"-c",
self.config_file,
"cns",
"record",
"get",
"--id",
name_or_id,
]
parsed = [AttrDict(r) for r in json.loads(cmd(*args))]
if len(parsed):
self._add_to_cache(parsed)
return parsed[0]
if require:
raise Exception("Cannot locate record:", name_or_id)
return None
def app_deployment_requests(self):
return self.list_records({"type": "ApplicationDeploymentRequest"}, True)
def app_deployments(self):
return self.list_records({"type": "ApplicationDeploymentRecord"})
def publish(self, record, names=[]):
tmpdir = tempfile.mkdtemp()
try:
record_fname = os.path.join(tmpdir, "record.yml")
record_file = open(record_fname, 'w')
yaml.dump(record, record_file)
record_file.close()
print(open(record_fname, 'r').read())
new_record_id = json.loads(
cmd("laconic", "-c", self.config_file, "cns", "record", "publish", "--filename", record_fname)
)["id"]
for name in names:
cmd("laconic", "-c", self.config_file, "cns", "name", "set", name, new_record_id)
return new_record_id
finally:
cmd("rm", "-rf", tmpdir)
def file_hash(filename):
return hashlib.sha1(open(filename).read().encode()).hexdigest()
def build_container_image(app_record, tag, extra_build_args=[]):
tmpdir = tempfile.mkdtemp()
try:
record_id = app_record["id"]
name = app_record.attributes.name.replace("@", "")
ref = app_record.attributes.repository_ref
repo = random.choice(app_record.attributes.repository)
clone_dir = os.path.join(tmpdir, record_id)
print(f"Cloning repository {repo} to {clone_dir} ...")
if ref:
result = subprocess.run(["git", "clone", "--depth", "1", "--branch", ref, repo, clone_dir])
result.check_returncode()
else:
result = subprocess.run(["git", "clone", "--depth", "1", repo, clone_dir])
result.check_returncode()
print("Building webapp ...")
build_command = [sys.argv[0], "build-webapp", "--source-repo", clone_dir, "--tag", tag]
if extra_build_args:
build_command.append("--extra-build-args")
build_command.append(" ".join(extra_build_args))
result = subprocess.run(build_command)
result.check_returncode()
finally:
cmd("rm", "-rf", tmpdir)
def push_container_image(deployment_dir):
print("Pushing image ...")
result = subprocess.run([sys.argv[0], "deployment", "--dir", deployment_dir, "push-images"])
result.check_returncode()
def deploy_to_k8s(laconic: LaconicRegistryClient, app_record, deploy_record, deployment_crn, deployment_dir, app_deployment_request=None):
if not deploy_record:
command = "up"
else:
command = "update"
result = subprocess.run([sys.argv[0], "deployment", "--dir", deployment_dir, command])
result.check_returncode()
def publish_deployment(laconic: LaconicRegistryClient, app_record, deploy_record, deployment_crn, deployment_dir, app_deployment_request=None):
if not deploy_record:
version = "0.0.1"
else:
version = "0.0.%d" % (int(deploy_record["attributes"]["version"].split(".")[-1]) + 1)
spec = yaml.full_load(open(os.path.join(deployment_dir, "spec.yml")))
hostname = spec["network"]["http-proxy"][0]["host-name"]
record = {
"record": {
"type": "ApplicationDeploymentRecord",
"version": version,
"url": f"https://{hostname}",
"name": hostname,
"application": app_record["id"],
"meta": {
"config": file_hash(os.path.join(deployment_dir, "config.env"))
},
}
}
if app_deployment_request:
record["record"]["request"] = app_deployment_request.id
return laconic.publish(record, [deployment_crn])

View File

@ -20,7 +20,7 @@ from stack_orchestrator.repos import setup_repositories
from stack_orchestrator.build import build_containers
from stack_orchestrator.build import build_npms
from stack_orchestrator.build import build_webapp
from stack_orchestrator.deploy.webapp import run_webapp, deploy_webapp
from stack_orchestrator.deploy.webapp import run_webapp, deploy_webapp, deploy_webapp_from_registry
from stack_orchestrator.deploy import deploy
from stack_orchestrator import version
from stack_orchestrator.deploy import deployment
@ -53,6 +53,7 @@ cli.add_command(build_npms.command, "build-npms")
cli.add_command(build_webapp.command, "build-webapp")
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(deploy.command, "deploy") # deploy is an alias for deploy-system
cli.add_command(deploy.command, "deploy-system")
cli.add_command(deployment.command, "deployment")