Unverified Commit a6899da9 authored by hhzhang16's avatar hhzhang16 Committed by GitHub
Browse files

feat: add update deployment to dynamo deploy API and CLI (#1048)

parent 73fdfb8a
...@@ -62,6 +62,8 @@ ln -sf $HOME/dynamo/.build/target/debug/llmctl $HOME/dynamo/deploy/sdk/src/dynam ...@@ -62,6 +62,8 @@ ln -sf $HOME/dynamo/.build/target/debug/llmctl $HOME/dynamo/deploy/sdk/src/dynam
cd $HOME/dynamo/lib/bindings/python && retry uv pip install -e . cd $HOME/dynamo/lib/bindings/python && retry uv pip install -e .
cd $HOME/dynamo && retry env DYNAMO_BIN_PATH=$HOME/dynamo/.build/target/debug uv pip install -e . cd $HOME/dynamo && retry env DYNAMO_BIN_PATH=$HOME/dynamo/.build/target/debug uv pip install -e .
export PYTHONPATH=/home/ubuntu/dynamo/components/planner/src:$PYTHONPATH
# source the venv and set the VLLM_KV_CAPI_PATH in bashrc # source the venv and set the VLLM_KV_CAPI_PATH in bashrc
echo "source /opt/dynamo/venv/bin/activate" >> ~/.bashrc echo "source /opt/dynamo/venv/bin/activate" >> ~/.bashrc
echo "export VLLM_KV_CAPI_PATH=$HOME/dynamo/.build/target/debug/libdynamo_llm_capi.so" >> ~/.bashrc echo "export VLLM_KV_CAPI_PATH=$HOME/dynamo/.build/target/debug/libdynamo_llm_capi.so" >> ~/.bashrc
......
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
# limitations under the License. # limitations under the License.
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
...@@ -23,6 +23,7 @@ from ..models.schemas import ( ...@@ -23,6 +23,7 @@ from ..models.schemas import (
DeploymentFullSchema, DeploymentFullSchema,
DeploymentListResponse, DeploymentListResponse,
ResourceSchema, ResourceSchema,
UpdateDeploymentSchema,
create_default_cluster, create_default_cluster,
create_default_user, create_default_user,
) )
...@@ -32,7 +33,9 @@ from .k8s import ( ...@@ -32,7 +33,9 @@ from .k8s import (
get_dynamo_deployment, get_dynamo_deployment,
get_namespace, get_namespace,
list_dynamo_deployments, list_dynamo_deployments,
update_dynamo_deployment,
) )
from .utils import build_latest_revision_from_cr, get_deployment_status, get_urls
router = APIRouter(prefix="/api/v2/deployments", tags=["deployments"]) router = APIRouter(prefix="/api/v2/deployments", tags=["deployments"])
...@@ -116,7 +119,7 @@ async def create_deployment(deployment: CreateDeploymentSchema): ...@@ -116,7 +119,7 @@ async def create_deployment(deployment: CreateDeploymentSchema):
kube_namespace=kube_namespace, kube_namespace=kube_namespace,
creator=creator, creator=creator,
cluster=cluster, cluster=cluster,
latest_revision=None, latest_revision=build_latest_revision_from_cr(created_crd),
manifest=None, manifest=None,
) )
...@@ -130,6 +133,15 @@ async def create_deployment(deployment: CreateDeploymentSchema): ...@@ -130,6 +133,15 @@ async def create_deployment(deployment: CreateDeploymentSchema):
@router.get("/{name}", response_model=DeploymentFullSchema) @router.get("/{name}", response_model=DeploymentFullSchema)
def get_deployment(name: str) -> DeploymentFullSchema: def get_deployment(name: str) -> DeploymentFullSchema:
"""
Retrieve a deployment by name.
Args:
name: The name of the deployment to retrieve
Returns:
DeploymentFullSchema: The deployment details
"""
try: try:
kube_namespace = get_namespace() kube_namespace = get_namespace()
cr = get_dynamo_deployment( cr = get_dynamo_deployment(
...@@ -147,7 +159,7 @@ def get_deployment(name: str) -> DeploymentFullSchema: ...@@ -147,7 +159,7 @@ def get_deployment(name: str) -> DeploymentFullSchema:
urls=get_urls(cr), urls=get_urls(cr),
creator=create_default_user(), creator=create_default_user(),
cluster=create_default_cluster(create_default_user()), cluster=create_default_cluster(create_default_user()),
latest_revision=None, latest_revision=build_latest_revision_from_cr(cr),
manifest=None, manifest=None,
) )
return deployment_schema return deployment_schema
...@@ -159,58 +171,17 @@ def get_deployment(name: str) -> DeploymentFullSchema: ...@@ -159,58 +171,17 @@ def get_deployment(name: str) -> DeploymentFullSchema:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
def get_deployment_status(resource: Dict[str, Any]) -> str: @router.delete("/{name}", response_model=DeploymentFullSchema)
""" def delete_deployment(name: str) -> DeploymentFullSchema:
Get the current status of a deployment.
Maps operator status to BentoML status values.
Returns lowercase status values matching BentoML's DeploymentStatus enum.
"""
status = resource.get("status", {})
conditions = status.get("conditions", [])
state = status.get("state", "")
# First check Ready condition
for condition in conditions:
if condition.get("type") == "Ready":
if condition.get("status") == "True":
# If state is "successful", map to "running"
if state == "successful":
return "running"
return condition.get("message", "running").lower()
elif condition.get("message"):
return condition.get("message").lower()
# If no Ready condition or not True, check state
if state == "failed":
return "failed"
elif state == "pending":
return "deploying" # map pending to deploying to match BentoML states
# Default fallback
return "unknown"
def get_urls(resource: Dict[str, Any]) -> List[str]:
"""
Get the URLs for a deployment.
Returns URLs as soon as they are available from EndpointExposed condition.
""" """
urls = [] Delete a deployment by name.
conditions = resource.get("status", {}).get("conditions", [])
# Check for EndpointExposed condition
for condition in conditions:
if (
condition.get("type") == "EndpointExposed"
and condition.get("status") == "True"
):
if message := condition.get("message"):
urls.append(message)
return urls
Args:
name: The name of the deployment to delete
@router.delete("/{name}", response_model=DeploymentFullSchema) Returns:
def delete_deployment(name: str) -> DeploymentFullSchema: DeploymentFullSchema: The deleted deployment details
"""
try: try:
kube_namespace = get_namespace() kube_namespace = get_namespace()
# Get deployment details before deletion # Get deployment details before deletion
...@@ -226,7 +197,7 @@ def delete_deployment(name: str) -> DeploymentFullSchema: ...@@ -226,7 +197,7 @@ def delete_deployment(name: str) -> DeploymentFullSchema:
urls=get_urls(cr), urls=get_urls(cr),
creator=create_default_user(), creator=create_default_user(),
cluster=create_default_cluster(create_default_user()), cluster=create_default_cluster(create_default_user()),
latest_revision=None, latest_revision=build_latest_revision_from_cr(cr),
manifest=None, manifest=None,
) )
# Delete the deployment # Delete the deployment
...@@ -295,7 +266,7 @@ def list_deployments( ...@@ -295,7 +266,7 @@ def list_deployments(
urls=get_urls(cr), urls=get_urls(cr),
creator=create_default_user(), creator=create_default_user(),
cluster=create_default_cluster(create_default_user()), cluster=create_default_cluster(create_default_user()),
latest_revision=None, latest_revision=build_latest_revision_from_cr(cr),
manifest=None, manifest=None,
) )
...@@ -334,3 +305,65 @@ def list_deployments( ...@@ -334,3 +305,65 @@ def list_deployments(
print("Error listing deployments:") print("Error listing deployments:")
print(e) print(e)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put("/{name}", response_model=DeploymentFullSchema)
def update_deployment(name: str, deployment: UpdateDeploymentSchema):
"""
Update an existing deployment.
Args:
name: The name of the deployment to update (path param)
deployment: The new deployment configuration (body)
Returns:
updated deployment details
"""
try:
ownership = {"organization_id": "default-org", "user_id": "default-user"}
kube_namespace = get_namespace()
existing_deployment = get_deployment(name)
if existing_deployment.bento != deployment.bento:
raise HTTPException(
status_code=422,
detail="Cannot update the Dynamo components of a deployment.",
)
deployment_name = sanitize_deployment_name(name, deployment.bento)
updated_crd = update_dynamo_deployment(
name=deployment_name,
namespace=kube_namespace,
dynamo_nim=deployment.bento,
labels={
"ngc-organization": ownership["organization_id"],
"ngc-user": ownership["user_id"],
},
envs=deployment.envs,
)
resource = ResourceSchema(
uid=updated_crd["metadata"]["uid"],
name=updated_crd["metadata"]["name"],
created_at=updated_crd["metadata"].get(
"creationTimestamp", datetime.utcnow()
),
updated_at=datetime.utcnow(),
resource_type="deployment",
labels=[],
)
creator = create_default_user()
cluster = create_default_cluster(creator)
deployment_schema = DeploymentFullSchema(
**resource.dict(),
status=get_deployment_status(updated_crd),
kube_namespace=kube_namespace,
creator=creator,
cluster=cluster,
latest_revision=build_latest_revision_from_cr(updated_crd),
manifest=None,
urls=get_urls(updated_crd),
)
return deployment_schema
except Exception as e:
print("Error updating deployment:")
print(e)
raise HTTPException(status_code=500, detail=str(e))
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
# limitations under the License. # limitations under the License.
import os import os
from functools import wraps
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import HTTPException from fastapi import HTTPException
...@@ -34,6 +35,19 @@ DynamoGraphDeployment = K8sResource( ...@@ -34,6 +35,19 @@ DynamoGraphDeployment = K8sResource(
) )
def ensure_kube_config(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
config.load_incluster_config()
except config.config_exception.ConfigException:
config.load_kube_config()
return func(*args, **kwargs)
return wrapper
@ensure_kube_config
def create_custom_resource( def create_custom_resource(
group: str, version: str, namespace: str, plural: str, body: Dict[str, Any] group: str, version: str, namespace: str, plural: str, body: Dict[str, Any]
) -> Dict[str, Any]: ) -> Dict[str, Any]:
...@@ -50,11 +64,6 @@ def create_custom_resource( ...@@ -50,11 +64,6 @@ def create_custom_resource(
Returns: Returns:
Created resource Created resource
""" """
try:
config.load_incluster_config()
except config.config_exception.ConfigException:
config.load_kube_config()
api = client.CustomObjectsApi() api = client.CustomObjectsApi()
return api.create_namespaced_custom_object( return api.create_namespaced_custom_object(
group=group, version=version, namespace=namespace, plural=plural, body=body group=group, version=version, namespace=namespace, plural=plural, body=body
...@@ -101,6 +110,7 @@ def create_dynamo_deployment( ...@@ -101,6 +110,7 @@ def create_dynamo_deployment(
) )
@ensure_kube_config
def get_dynamo_deployment(name: str, namespace: str) -> Dict[str, Any]: def get_dynamo_deployment(name: str, namespace: str) -> Dict[str, Any]:
""" """
Get a DynamoGraphDeployment custom resource. Get a DynamoGraphDeployment custom resource.
...@@ -115,11 +125,6 @@ def get_dynamo_deployment(name: str, namespace: str) -> Dict[str, Any]: ...@@ -115,11 +125,6 @@ def get_dynamo_deployment(name: str, namespace: str) -> Dict[str, Any]:
Raises: Raises:
HTTPException: If the deployment is not found or an error occurs HTTPException: If the deployment is not found or an error occurs
""" """
try:
config.load_incluster_config()
except config.config_exception.ConfigException:
config.load_kube_config()
api = client.CustomObjectsApi() api = client.CustomObjectsApi()
try: try:
return api.get_namespaced_custom_object( return api.get_namespaced_custom_object(
...@@ -143,15 +148,11 @@ def get_namespace() -> str: ...@@ -143,15 +148,11 @@ def get_namespace() -> str:
return os.getenv("DEFAULT_KUBE_NAMESPACE", "dynamo") return os.getenv("DEFAULT_KUBE_NAMESPACE", "dynamo")
@ensure_kube_config
def delete_dynamo_deployment(name: str, namespace: str) -> Dict[str, Any]: def delete_dynamo_deployment(name: str, namespace: str) -> Dict[str, Any]:
""" """
Delete a DynamoGraphDeployment custom resource. Delete a DynamoGraphDeployment custom resource.
""" """
try:
config.load_incluster_config()
except config.config_exception.ConfigException:
config.load_kube_config()
api = client.CustomObjectsApi() api = client.CustomObjectsApi()
try: try:
return api.delete_namespaced_custom_object( return api.delete_namespaced_custom_object(
...@@ -168,6 +169,7 @@ def delete_dynamo_deployment(name: str, namespace: str) -> Dict[str, Any]: ...@@ -168,6 +169,7 @@ def delete_dynamo_deployment(name: str, namespace: str) -> Dict[str, Any]:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@ensure_kube_config
def list_dynamo_deployments( def list_dynamo_deployments(
namespace: str, namespace: str,
label_selector: Optional[str] = None, label_selector: Optional[str] = None,
...@@ -185,11 +187,6 @@ def list_dynamo_deployments( ...@@ -185,11 +187,6 @@ def list_dynamo_deployments(
Raises: Raises:
HTTPException: If an error occurs during listing HTTPException: If an error occurs during listing
""" """
try:
config.load_incluster_config()
except config.config_exception.ConfigException:
config.load_kube_config()
api = client.CustomObjectsApi() api = client.CustomObjectsApi()
try: try:
response = api.list_namespaced_custom_object( response = api.list_namespaced_custom_object(
...@@ -202,3 +199,62 @@ def list_dynamo_deployments( ...@@ -202,3 +199,62 @@ def list_dynamo_deployments(
return response["items"] return response["items"]
except client.rest.ApiException as e: except client.rest.ApiException as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@ensure_kube_config
def update_dynamo_deployment(
name: str,
namespace: str,
dynamo_nim: str,
labels: Dict[str, str],
envs: Optional[List[Dict[str, str]]] = None,
) -> Dict[str, Any]:
"""
Update a DynamoGraphDeployment custom resource.
Args:
name: Deployment name
namespace: Target namespace
dynamo_nim: Bento name and version (format: name:version)
labels: Resource labels
envs: Optional list of environment variables
Returns:
Updated deployment
"""
# Fetch the current resource to get resourceVersion
current = get_dynamo_deployment(name, namespace)
resource_version = current["metadata"].get("resourceVersion")
if not resource_version:
raise RuntimeError("resourceVersion not found in current resource")
body = {
"apiVersion": "nvidia.com/v1alpha1",
"kind": "DynamoGraphDeployment",
"metadata": {
"name": name,
"namespace": namespace,
"labels": labels,
"resourceVersion": resource_version, # Required for update
},
"spec": {
"dynamoGraph": dynamo_nim,
"services": {},
"envs": envs if envs else [],
},
}
api = client.CustomObjectsApi()
try:
return api.replace_namespaced_custom_object(
group=DynamoGraphDeployment.group,
version=DynamoGraphDeployment.version,
namespace=namespace,
plural=DynamoGraphDeployment.plural,
name=name,
body=body,
)
except client.rest.ApiException as e:
if e.status == 404:
raise HTTPException(status_code=404, detail="Deployment not found")
else:
raise HTTPException(status_code=500, detail=str(e))
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# #
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# #
# http://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from datetime import datetime
from typing import Any, Dict, List
def get_deployment_status(resource: Dict[str, Any]) -> str:
"""
Get the current status of a deployment.
Maps operator status to BentoML status values.
Returns lowercase status values matching BentoML's DeploymentStatus enum.
"""
status = resource.get("status", {})
conditions = status.get("conditions", [])
state = status.get("state", "")
# First check Ready condition
for condition in conditions:
if condition.get("type") == "Ready":
if condition.get("status") == "True":
# If state is "successful", map to "running"
if state == "successful":
return "running"
return condition.get("message", "running").lower()
elif condition.get("message"):
return condition.get("message").lower()
# If no Ready condition or not True, check state
if state == "failed":
return "failed"
elif state == "pending":
return "deploying" # map pending to deploying to match BentoML states
# Default fallback
return "unknown"
def get_urls(resource: Dict[str, Any]) -> List[str]:
"""
Get the URLs for a deployment.
Returns URLs as soon as they are available from EndpointExposed condition.
"""
urls = []
conditions = resource.get("status", {}).get("conditions", [])
# Check for EndpointExposed condition
for condition in conditions:
if (
condition.get("type") == "EndpointExposed"
and condition.get("status") == "True"
):
if message := condition.get("message"):
urls.append(message)
return urls
def build_latest_revision_from_cr(cr: dict) -> dict:
spec = cr.get("spec", {})
meta = cr.get("metadata", {})
now = datetime.utcnow().isoformat() + "Z"
bento_str = spec.get("dynamoGraph", "unknown:unknown")
if ":" in bento_str:
bento_name, bento_version = bento_str.split(":", 1)
else:
bento_name, bento_version = "unknown", "unknown"
# Dummy creator
creator = {"name": "system", "email": "", "first_name": "", "last_name": ""}
# Dummy repository
repository = {
"uid": "dummy-repo-uid",
"created_at": now,
"updated_at": now,
"deleted_at": None,
"name": bento_name,
"resource_type": "bento_repository",
"labels": [],
"description": "",
"latest_bento": None,
}
# Dummy bento
bento = {
"uid": "dummy-bento-uid",
"created_at": now,
"updated_at": now,
"deleted_at": None,
"name": bento_version,
"resource_type": "bento",
"labels": [],
"description": "",
"repository": repository,
"version": bento_version,
"image_build_status": "",
"upload_status": "",
"upload_finished_reason": "",
"presigned_upload_url": "",
"presigned_download_url": "",
}
# Target
target = {
"uid": "dummy-target-uid",
"created_at": now,
"updated_at": now,
"deleted_at": None,
"name": "default-target",
"resource_type": "deployment_target",
"labels": [],
"creator": creator,
"status": "running",
"config": {
"services": spec.get("services", {}),
"access_authorization": True,
"envs": spec.get("envs", []),
},
"bento": bento,
}
# Revision
return {
"uid": meta.get("uid", "dummy-uid"),
"created_at": meta.get("creationTimestamp", now),
"updated_at": meta.get("creationTimestamp", now),
"deleted_at": None,
"name": meta.get("name", "dummy-revision"),
"resource_type": "deployment_revision",
"labels": [],
"creator": creator,
"status": "running",
"targets": [target],
}
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# #
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# #
# http://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# #
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# #
# http://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
...@@ -13,7 +13,8 @@ ...@@ -13,7 +13,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from .deployments import get_deployment_status, get_urls
from ..api.utils import build_latest_revision_from_cr, get_deployment_status, get_urls
def test_get_deployment_status(): def test_get_deployment_status():
...@@ -57,3 +58,43 @@ def test_get_urls(): ...@@ -57,3 +58,43 @@ def test_get_urls():
} }
} }
assert get_urls(resource) == ["https://example.com"] assert get_urls(resource) == ["https://example.com"]
def test_build_latest_revision_from_cr_minimal():
cr = {
"metadata": {
"uid": "u1",
"name": "n1",
"creationTimestamp": "2024-01-01T00:00:00Z",
},
"spec": {
"dynamoGraph": "repo:ver",
"services": {"svc": {}},
"envs": [{"name": "A", "value": "B"}],
},
}
rev = build_latest_revision_from_cr(cr)
assert rev["uid"] == "u1"
assert rev["name"] == "n1"
assert rev["targets"][0]["bento"]["repository"]["name"] == "repo"
assert rev["targets"][0]["bento"]["name"] == "ver"
assert rev["targets"][0]["config"]["services"] == {"svc": {}}
assert rev["targets"][0]["config"]["envs"] == [{"name": "A", "value": "B"}]
def test_build_latest_revision_from_cr_missing_fields():
cr = {"spec": {}}
rev = build_latest_revision_from_cr(cr)
assert rev["uid"] == "dummy-uid"
assert rev["name"] == "dummy-revision"
assert rev["targets"][0]["bento"]["repository"]["name"] == "unknown"
assert rev["targets"][0]["bento"]["name"] == "unknown"
assert rev["targets"][0]["config"]["services"] == {}
assert rev["targets"][0]["config"]["envs"] == []
def test_build_latest_revision_from_cr_bento_colonless():
cr = {"spec": {"dynamoGraph": "justrepo"}}
rev = build_latest_revision_from_cr(cr)
assert rev["targets"][0]["bento"]["repository"]["name"] == "unknown"
assert rev["targets"][0]["bento"]["name"] == "unknown"
...@@ -68,33 +68,85 @@ def raise_deployment_config_error(err: BentoMLException, action: str) -> t.NoRet ...@@ -68,33 +68,85 @@ def raise_deployment_config_error(err: BentoMLException, action: str) -> t.NoRet
) from None ) from None
@inject def _get_urls(deployment: Deployment) -> List[str]:
def create_deployment( """Get URLs from deployment."""
pipeline: Optional[str] = None, latest = deployment._client.v2.get_deployment(deployment.name, deployment.cluster)
name: Optional[str] = None, urls = latest.urls if hasattr(latest, "urls") else None
return urls if urls is not None else []
def _display_deployment_info(spinner: Spinner, deployment: Deployment) -> None:
"""Helper function to display deployment status and URLs consistently."""
# Get status directly from schema and escape any Rich markup
status = deployment._schema.status if deployment._schema.status else "unknown"
# Escape any characters that are interpreted as markup
reformatted_status = status.replace("[", "\\[")
spinner.log(f"[bold]Status:[/] {reformatted_status}")
# Get URLs directly from schema
spinner.log("[bold]Ingress URLs:[/]")
try:
# Get latest deployment info for URLs
urls = _get_urls(deployment)
if urls:
for url in urls:
spinner.log(f" - {url}")
else:
spinner.log(" No URLs available")
except Exception:
# If refresh fails, fall back to existing URLs
if deployment._urls:
for url in deployment._urls:
spinner.log(f" - {url}")
else:
spinner.log(" No URLs available")
def _build_env_dicts(
config_file: Optional[TextIO] = None, config_file: Optional[TextIO] = None,
wait: bool = True, args: Optional[list[str]] = None,
timeout: int = 3600, envs: Optional[list[str]] = None,
dev: bool = False, ) -> list[dict]:
args: Optional[List[str]] = None, """
envs: Optional[List[str]] = None, Build a list of environment variable dicts from config file, args, and env strings.
_cloud_client: BentoCloudClient = Provide[BentoMLContainer.bentocloud_client],
) -> Deployment: Args:
# Load config from file and serialize to env config_file: Optional configuration file
args: Optional list of extra arguments
envs: Optional list of environment variable strings (KEY=VALUE)
Returns:
List of dicts suitable for use as envs
"""
service_configs = resolve_service_config(config_file=config_file, args=args) service_configs = resolve_service_config(config_file=config_file, args=args)
env_dicts = [] env_dicts = []
if service_configs: if service_configs:
config_json = json.dumps(service_configs) config_json = json.dumps(service_configs)
logger.info(f"Deployment service configuration: {config_json}") logger.info(f"Deployment service configuration: {config_json}")
env_dicts.append({"name": "DYN_DEPLOYMENT_CONFIG", "value": config_json}) env_dicts.append({"name": "DYN_DEPLOYMENT_CONFIG", "value": config_json})
# Add user-supplied envs
if envs: if envs:
for env in envs: for env in envs:
if "=" not in env: if "=" not in env:
raise CLIException(f"Invalid env format: {env}. Use KEY=VALUE.") raise CLIException(f"Invalid env format: {env}. Use KEY=VALUE.")
key, value = env.split("=", 1) key, value = env.split("=", 1)
env_dicts.append({"name": key, "value": value}) env_dicts.append({"name": key, "value": value})
return env_dicts
@inject
def create_deployment(
pipeline: Optional[str] = None,
name: Optional[str] = None,
config_file: Optional[TextIO] = None,
wait: bool = True,
timeout: int = 3600,
dev: bool = False,
args: Optional[List[str]] = None,
envs: Optional[List[str]] = None,
_cloud_client: BentoCloudClient = Provide[BentoMLContainer.bentocloud_client],
) -> Deployment:
# Build env_dicts from config_file, args, and envs
env_dicts = _build_env_dicts(config_file=config_file, args=args, envs=envs)
config_params = DeploymentConfigParameters( config_params = DeploymentConfigParameters(
name=name, name=name,
...@@ -152,38 +204,54 @@ def create_deployment( ...@@ -152,38 +204,54 @@ def create_deployment(
sys.exit(1) sys.exit(1)
def _get_urls(deployment: Deployment) -> List[str]: @inject
"""Get URLs from deployment.""" def update_deployment(
latest = deployment._client.v2.get_deployment(deployment.name, deployment.cluster) name: str,
urls = latest.urls if hasattr(latest, "urls") else None config_file: Optional[TextIO] = None,
return urls if urls is not None else [] args: Optional[List[str]] = None,
envs: Optional[List[str]] = None,
_cloud_client: BentoCloudClient = Provide[BentoMLContainer.bentocloud_client],
) -> Deployment:
"""Update an existing deployment on Dynamo Cloud.
def _display_deployment_info(spinner: Spinner, deployment: Deployment) -> None: Args:
"""Helper function to display deployment status and URLs consistently.""" name: The name of the deployment to update
# Get status directly from schema and escape any Rich markup config_file: Optional configuration file for the update
status = deployment._schema.status if deployment._schema.status else "unknown" args: Optional extra arguments for config
# Escape any characters that are interpreted as markup envs: Optional list of environment variables (KEY=VALUE)
reformatted_status = status.replace("[", "\\[")
spinner.log(f"[bold]Status:[/] {reformatted_status}")
# Get URLs directly from schema Returns:
spinner.log("[bold]Ingress URLs:[/]") Deployment: The updated deployment object
"""
# Build env_dicts from config_file, args, and envs
env_dicts = _build_env_dicts(config_file=config_file, args=args, envs=envs)
config_params = DeploymentConfigParameters(
name=name,
envs=env_dicts,
cli=True,
)
try: try:
# Get latest deployment info for URLs config_params.verify(create=False)
urls = _get_urls(deployment) except BentoMLException as e:
if urls: print(f"Error: {str(e)}")
for url in urls: sys.exit(1)
spinner.log(f" - {url}") with Spinner(console=console) as spinner:
else: try:
spinner.log(" No URLs available") spinner.update(f'Updating deployment "{name}" on Dynamo Cloud...')
except Exception: deployment = _cloud_client.deployment.update(
# If refresh fails, fall back to existing URLs deployment_config_params=config_params
if deployment._urls: )
for url in deployment._urls: spinner.log(
spinner.log(f" - {url}") f':white_check_mark: Updated deployment "{deployment.name}" in cluster "{deployment.cluster}"'
else: )
spinner.log(" No URLs available") spinner.log(
"[yellow]Update submitted. It may take a short time for the new pods to become active. Please wait a bit before accessing the deployment to ensure your changes are live.[/yellow]"
)
_display_deployment_info(spinner, deployment)
return deployment
except BentoMLException as e:
spinner.log(f"[red]:x: Error:[/] Failed to update deployment: {str(e)}")
sys.exit(1)
@inject @inject
...@@ -343,8 +411,8 @@ def get( ...@@ -343,8 +411,8 @@ def get(
get_deployment(name, cluster=cluster) get_deployment(name, cluster=cluster)
@app.command() @app.command("list")
def list( def list_deployments_command(
cluster: Optional[str] = typer.Option(None, "--cluster", help="Cluster name"), cluster: Optional[str] = typer.Option(None, "--cluster", help="Cluster name"),
search: Optional[str] = typer.Option(None, "--search", help="Search query"), search: Optional[str] = typer.Option(None, "--search", help="Search query"),
dev: bool = typer.Option(False, "--dev", help="List development deployments"), dev: bool = typer.Option(False, "--dev", help="List development deployments"),
...@@ -367,6 +435,33 @@ def list( ...@@ -367,6 +435,33 @@ def list(
list_deployments(cluster=cluster, search=search, dev=dev, q=query) list_deployments(cluster=cluster, search=search, dev=dev, q=query)
@app.command()
def update(
name: str = typer.Argument(..., help="Deployment name to update"),
config_file: Optional[typer.FileText] = typer.Option(
None, "--config-file", "-f", help="Configuration file path"
),
envs: Optional[List[str]] = typer.Option(
None,
"--env",
help="Environment variable(s) to set (format: KEY=VALUE). Note: These environment variables will be set on ALL services in your Dynamo pipeline.",
),
endpoint: str = typer.Option(
..., "--endpoint", "-e", help="Dynamo Cloud endpoint", envvar="DYNAMO_CLOUD"
),
) -> None:
"""Update an existing deployment on Dynamo Cloud.
Update a deployment using parameters or a config yaml file.
"""
login_to_cloud(endpoint)
update_deployment(
name=name,
config_file=config_file,
envs=envs,
)
@app.command() @app.command()
def delete( def delete(
name: str = typer.Argument(..., help="Deployment name"), name: str = typer.Argument(..., help="Deployment name"),
......
...@@ -104,7 +104,7 @@ Deploy your service using the Dynamo deployment command: ...@@ -104,7 +104,7 @@ Deploy your service using the Dynamo deployment command:
export DEPLOYMENT_NAME=hello-world export DEPLOYMENT_NAME=hello-world
# Create the deployment # Create the deployment
dynamo deployment create $DYNAMO_TAG --no-wait -n $DEPLOYMENT_NAME dynamo deployment create $DYNAMO_TAG -n $DEPLOYMENT_NAME
``` ```
#### Managing Deployments #### Managing Deployments
...@@ -124,6 +124,12 @@ To get detailed information about a specific deployment: ...@@ -124,6 +124,12 @@ To get detailed information about a specific deployment:
dynamo deployment get $DEPLOYMENT_NAME dynamo deployment get $DEPLOYMENT_NAME
``` ```
To update a specific deployment:
```bash
dynamo deployment update $DEPLOYMENT_NAME [--config-file FILENAME] [--env ENV_VAR]
```
To remove a deployment and all its associated resources: To remove a deployment and all its associated resources:
```bash ```bash
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment