Skip to content

Commit

Permalink
Merge pull request #690 from vespa-engine/tmaregge/prod-deployment
Browse files Browse the repository at this point in the history
Support prod deployments via Pyvespa
  • Loading branch information
tmaregge committed Mar 11, 2024
2 parents 57a1f8b + 4abbff7 commit e9098e6
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 6 deletions.
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def get_target_version():
include_package_data=True,
install_requires=[
"requests",
"requests_toolbelt",
"docker",
"jinja2",
"cryptography",
Expand Down
27 changes: 24 additions & 3 deletions tests/unit/test_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@
ContainerCluster,
Parameter,
ApplicationPackage,
AuthClient
AuthClient,
DeploymentConfiguration
)


class TestField(unittest.TestCase):
def test_field_name_type(self):
field = Field(name="test_name", type="string")
Expand Down Expand Up @@ -1405,11 +1405,32 @@ def test_services_to_text(self):
' <nodes>\n'
' <node distribution-key="0" hostalias="node1"></node>\n'
' </nodes>\n'
' <redundancy>1</redundancy>\n'
' <min-redundancy>1</min-redundancy>\n'
' <documents>\n'
' <document type="test" mode="index"></document>\n'
' </documents>\n'
' </content>\n'
'</services>'
)
self.assertEqual(self.app_package.services_to_text, expected_result)


# class TestDeploymentConfiguration(unittest.TestCase):
# def test_deployment_to_text(self):
# deploy_config = DeploymentConfiguration(
# environment="prod",
# regions=["aws-us-east-1c", "aws-us-west-2a"]
# )

# app_package = ApplicationPackage(name="test", deployment_config=deploy_config)

# expected_result = (
# '<deployment version="1.0">\n'
# ' <prod>\n'
# ' <region>aws-us-east-1c</region>\n'
# ' <region>aws-us-west-2a</region>\n'
# ' </prod>\n'
# '</deployment>'
# )

# self.assertEqual(expected_result, app_package.deployment_to_text)
96 changes: 95 additions & 1 deletion vespa/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import docker
import requests
from requests_toolbelt.multipart.encoder import MultipartEncoder
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization, hashes
Expand Down Expand Up @@ -450,7 +451,7 @@ def deploy(self, instance: Optional[str]="default", disk_folder: Optional[str] =
if not disk_folder:
disk_folder = os.path.join(os.getcwd(), self.application_package.name)
self.application_package.to_files(disk_folder)

region = self.get_dev_region()
job = "dev-" + region
run = self._start_deployment(instance, job, disk_folder, None)
Expand All @@ -472,6 +473,28 @@ def deploy(self, instance: Optional[str]="default", disk_folder: Optional[str] =
print("Finished deployment.", file=self.output)
return app

def deploy_to_prod(self, instance: Optional[str]="default", disk_folder: Optional[str] = None) -> None:
"""
Deploy the given application package as the given instance in the Vespa Cloud prod environment.
:param instance: Name of this instance of the application, in the Vespa Cloud.
:param disk_folder: Disk folder to save the required Vespa config files. Default to application name
folder within user's current working directory.
"""
if not disk_folder:
disk_folder = os.path.join(os.getcwd(), self.application_package.name)
self.application_package.to_files(disk_folder)

if self.application_package.deployment_config is None:
raise ValueError("'Prod deployment requires a deployment_config.")

self._start_prod_deployment(disk_folder)

deploy_url = "https://console.vespa-cloud.com/tenant/{}/application/{}/prod/deployment".format(
self.tenant, self.application
)
print(f"Follow deployment at: {deploy_url}", file=self.output)

def deploy_from_disk(self, instance: str, application_root: Path) -> Vespa:
"""
Deploy from a directory tree.
Expand Down Expand Up @@ -731,6 +754,73 @@ def get_token_endpoint(self, instance: Optional[str]="default", region: Optional
return endpoint['url']
raise RuntimeError("No token endpoints found for container cluster " + cluster_name)

def _start_prod_deployment(self, disk_folder: str) -> None:
# The submit API is used for prod deployments
deploy_path = "/application/v4/tenant/{}/application/{}/submit/".format(
self.tenant, self.application
)

# Create app package zip
Path(disk_folder).mkdir(parents=True, exist_ok=True)
application_package_zip_bytes = self._to_application_zip(disk_folder=disk_folder)

# Read certs
if self.private_cert_file_name:
# Means pyvespa generated its own cert/key
self._write_private_key_and_cert(
self.data_key, self.data_certificate, disk_folder
)

# Create submission
# TODO Avoid hardcoding projectId and risk
# TODO Consider supporting optional fields
submit_options = {
"projectId": 1,
"risk": 0,
# "repository": "",
# "branch": "",
# "commit": "",
# "description": "",
# "authorEmail": "",
# "sourceUrl": ""
}

# Vespa expects prod deployments to be submitted as multipart data
multipart_data = MultipartEncoder(
fields={
'submitOptions': ('', json.dumps(submit_options), 'application/json'),
'applicationZip': ('application.zip', application_package_zip_bytes, 'application/zip')
# TODO Implement test package zip
}
)

# Compute content hash, etc
url = "https://" + self.connection.host + ":" + str(self.connection.port) + deploy_path
digest = hashes.Hash(hashes.SHA256(), default_backend())
digest.update(multipart_data.to_string()) # This moves the buffer position to the end
multipart_data._buffer.seek(0) # Needs to be reset. Otherwise, no data will be sent
content_hash = standard_b64encode(digest.finalize()).decode("UTF-8")
timestamp = (
datetime.utcnow().isoformat() + "Z"
) # Java's Instant.parse requires the neutral time zone appended
canonical_message = "POST" + "\n" + url + "\n" + timestamp + "\n" + content_hash
signature = self.api_key.sign(
canonical_message.encode("UTF-8"), ec.ECDSA(hashes.SHA256())
)
headers = {
"X-Timestamp": timestamp,
"X-Content-Hash": content_hash,
"X-Key-Id": self.tenant + ":" + self.application + ":" + "default",
"X-Key": self.api_public_key_bytes,
"X-Authorization": standard_b64encode(signature),
"Content-Type": multipart_data.content_type,
}

response = requests.post(url, data=multipart_data, headers=headers)

message = response.json()["message"]
print(message, file=self.output)


def _start_deployment(self, instance: str, job: str, disk_folder: str,
application_zip_bytes: Optional[BytesIO] = None) -> int:
Expand Down Expand Up @@ -805,6 +895,10 @@ def _to_application_zip(self, disk_folder: str) -> BytesIO:
"security/clients.pem",
self.data_certificate.public_bytes(serialization.Encoding.PEM),
)
if self.application_package.deployment_config:
zip_archive.writestr(
"deployment.xml", self.application_package.deployment_to_text
)

return buffer

Expand Down
47 changes: 45 additions & 2 deletions vespa/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -1900,12 +1900,14 @@ def __init__(self,
id: str,
document_name: str,
version: str = "1.0",
nodes: Optional[Nodes] = None
nodes: Optional[Nodes] = None,
min_redundancy: Optional[str] = "1"
) -> None:
"""
Defines the configuration of a content cluster.
:param document_name: Name of document.
:param min_redundancy: Minimum redundancy of the content cluster. Must be at least 2 for production deployments.
Example:
Expand All @@ -1914,6 +1916,7 @@ def __init__(self,
"""
super().__init__(id, version, nodes)
self.document_name = document_name
self.min_redundancy = min_redundancy

def __repr__(self) -> str:
base_str = super().__repr__()
Expand All @@ -1932,7 +1935,7 @@ def to_xml_string(self, indent=1):
node.set("distribution-key", "0")
node.set("hostalias", "node1")

ET.SubElement(root, "redundancy").text = "1"
ET.SubElement(root, "min-redundancy").text = self.min_redundancy

documents = ET.SubElement(root, "documents")
document = ET.SubElement(documents, "document")
Expand Down Expand Up @@ -2027,6 +2030,21 @@ def __init__(
self.comment = comment


class DeploymentConfiguration(object):
def __init__(self, environment: str, regions: List[str]):
self.environment = environment
self.regions = regions

def to_xml_string(self, indent=1) -> str:
root = ET.Element(self.environment)
for region in self.regions:
region_xml = ET.SubElement(root, "region")
region_xml.text = region

xml_str = minidom.parseString(ET.tostring(root)).toprettyxml(indent=" " * 4)
xml_lines = xml_str.strip().split("\n")
return "\n".join([xml_lines[1]] + [(" " * 4 * indent) + line for line in xml_lines[2:]])

class ApplicationPackage(object):
def __init__(
self,
Expand All @@ -2042,6 +2060,7 @@ def __init__(
components: Optional[List[Component]] = None,
auth_clients: Optional[List[AuthClient]] = None,
clusters: Optional[List[Cluster]] = None,
deployment_config: Optional[DeploymentConfiguration] = None,
) -> None:
"""
Create an `Application Package <https://docs.vespa.ai/en/application-packages.html>`__.
Expand All @@ -2067,6 +2086,7 @@ def __init__(
:param clusters: List of :class:`Cluster` that contains configurations for content or container clusters.
If clusters is used, any :class: `Component`s must be configured as part of a cluster.
:param clients: List of :class:`Client` that contains configurations for client authorization.
:param deployment_config: DeploymentConfiguration` that contains configurations for production deployments.
The easiest way to get started is to create a default application package:
Expand Down Expand Up @@ -2105,6 +2125,7 @@ def __init__(
self.components = components
self.auth_clients = auth_clients
self.clusters = clusters
self.deployment_config = deployment_config

@property
def schemas(self) -> List[Schema]:
Expand Down Expand Up @@ -2215,6 +2236,21 @@ def validations_to_text(self):
validations_template = env.get_template("validation-overrides.xml")
return validations_template.render(validations=self.validations)

@property
def deployment_to_text(self):
env = Environment(
loader=PackageLoader("vespa", "templates"),
autoescape=select_autoescape(
disabled_extensions=("txt",),
default_for_string=True,
default=True,
),
)
env.trim_blocks = True
env.lstrip_blocks = True
deployment_template = env.get_template("deployment.xml")
return deployment_template.render(deployment_config=self.deployment_config)

@staticmethod
def _application_package_file_name(disk_folder):
return os.path.join(disk_folder, "application_package.json")
Expand Down Expand Up @@ -2261,6 +2297,9 @@ def to_zip(self) -> BytesIO:
self.query_profile_type_to_text,
)

if self.deployment_config:
zip_archive.writestr("deployment.xml", self.deployment_to_text)

buffer.seek(0)
return buffer

Expand Down Expand Up @@ -2333,6 +2372,10 @@ def to_files(self, root: Path) -> None:
with open(os.path.join(root, "validation-overrides.xml"), "w") as f:
f.write(self.validations_to_text)

if self.deployment_config:
with open(os.path.join(root, "deployment.xml"), "w") as f:
f.write(self.deployment_to_text)

def __eq__(self, other: object) -> bool:
if not isinstance(other, self.__class__):
return NotImplemented
Expand Down
5 changes: 5 additions & 0 deletions vespa/templates/deployment.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<deployment version="1.0">
{% autoescape off %}
{{ deployment_config.to_xml_string(1) }}
{% endautoescape %}
</deployment>

0 comments on commit e9098e6

Please sign in to comment.