Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/horizondb/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
Release History
===============

1.0.0b7
+++++++
* Add support for restoring HorizonDB clusters through `az horizondb restore`.

1.0.0b6
+++++++
* Add private endpoint connection commands: `az horizondb private-endpoint-connection list/show/approve/reject/delete`.
Expand Down
1 change: 1 addition & 0 deletions src/horizondb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ az extension add --name horizondb
*Commands:*

- `az horizondb create`: Create a new Azure HorizonDB cluster.
- `az horizondb restore`: Restore an Azure HorizonDB cluster from a source cluster.
- `az horizondb delete`: Delete an Azure HorizonDB cluster.
- `az horizondb show`: Show details of an Azure HorizonDB cluster.

Expand Down
13 changes: 13 additions & 0 deletions src/horizondb/azext_horizondb/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@
"""


helps['horizondb restore'] = """
type: command
short-summary: Restore an Azure HorizonDB cluster from backups of an existing source cluster.
examples:
- name: Restore an Azure HorizonDB cluster from most recent backup of an existing source cluster in the same subscription and resource group.
text: az horizondb restore --name restoredcluster --resource-group exampleresourcegroup --source-cluster sourcecluster
- name: Restore an Azure HorizonDB cluster from most recent backup of an existing source cluster, given its identifier, to a specific resource group.
text: az horizondb restore --name restoredcluster --resource-group exampleresourcegroup --source-cluster /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/examplerg/providers/Microsoft.HorizonDb/clusters/sourcecluster
- name: Restore an Azure HorizonDB cluster from a specific point in time in the backup chain of an existing source cluster in the same subscription and resource group.
text: az horizondb restore --name restoredcluster --resource-group exampleresourcegroup --source-cluster sourcecluster --restore-time "2026-07-15T02:10:00+00:00"
"""


helps['horizondb delete'] = """
type: command
short-summary: Delete an Azure HorizonDB cluster.
Expand Down
16 changes: 16 additions & 0 deletions src/horizondb/azext_horizondb/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ def _horizondb_params():
arg_type=get_enum_type(['Strict', 'BestEffort']),
help='Defines how replicas are placed across availability zones.')

source_cluster_arg_type = CLIArgumentType(
options_list=['--source-cluster'],
help='Name or resource identifier of the source Azure HorizonDB cluster to restore from.')

restore_time_arg_type = CLIArgumentType(
options_list=['--restore-time'],
help='The point in time in UTC to restore from (ISO8601 format), e.g., 2026-07-15T02:10:00+00:00. '
'During preview, you must set restore time to be at least 5 minutes before the current time. '
"If --restore-time isn't specified, during preview, it will internally be adjusted to 6 minutes "
'before now.')

parameter_group_arg_type = CLIArgumentType(
options_list=['--parameter-group'],
help='The resource ID of the parameter group.')
Expand Down Expand Up @@ -154,6 +165,11 @@ def _horizondb_params():
c.argument('public_access', arg_type=public_access_create_arg_type)
c.argument('yes', arg_type=yes_arg_type)

with self.argument_context('horizondb restore') as c:
c.argument('tags', tags_type)
c.argument('source_cluster', arg_type=source_cluster_arg_type)
c.argument('restore_time', arg_type=restore_time_arg_type)

with self.argument_context('horizondb update') as c:
c.argument('tags', tags_type)
c.argument('administrator_login_password', arg_type=administrator_login_password_arg_type)
Expand Down
1 change: 1 addition & 0 deletions src/horizondb/azext_horizondb/cluster_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def load_command_table(self, _):
custom_command_type=custom_commands,
client_factory=cf_horizondb_clusters) as g:
g.custom_command('create', 'horizondb_cluster_create', table_transformer=table_transform_output)
g.custom_command('restore', 'horizondb_cluster_restore', supports_no_wait=True)
g.custom_command('update', 'horizondb_cluster_update', supports_no_wait=True)
g.custom_command('delete', 'horizondb_cluster_delete')
g.custom_command('list', 'horizondb_cluster_list')
Expand Down
59 changes: 58 additions & 1 deletion src/horizondb/azext_horizondb/commands/custom_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
# pylint: disable=line-too-long, too-many-locals

from knack.log import get_logger
from azure.cli.core.azclierror import ArgumentUsageError, CLIInternalError
from azure.cli.core.azclierror import ArgumentUsageError, CLIInternalError, InvalidArgumentValueError
from azure.cli.core.util import sdk_no_wait, user_confirmation
from azure.mgmt.core.tools import is_valid_resource_id, parse_resource_id
from ..utils.temp_cluster_capabilities import temp_cluster_capabilities
from ..utils.validators import (
is_supported_vcore,
Expand All @@ -22,6 +23,19 @@
HORIZONDB_VERSION_DEFAULT = 17


def _resolve_source_cluster(client, resource_group_name, source_cluster):
if is_valid_resource_id(source_cluster):
source_cluster_id_parts = parse_resource_id(source_cluster)
source_resource_group = source_cluster_id_parts.get("resource_group")
source_cluster_name = source_cluster_id_parts.get("name")
if not source_resource_group or not source_cluster_name:
raise ArgumentUsageError(
"Invalid source cluster resource identifier. Ensure it contains both resource group and cluster name."
)
return client.get(resource_group_name=source_resource_group, cluster_name=source_cluster_name)
return client.get(resource_group_name=resource_group_name, cluster_name=source_cluster)


def horizondb_cluster_create(cmd, client, resource_group_name=None, cluster_name=None, location=None,
administrator_login=None, administrator_login_password=None,
tags=None, version=None,
Expand Down Expand Up @@ -87,6 +101,49 @@ def horizondb_cluster_create(cmd, client, resource_group_name=None, cluster_name
return cluster


def _parse_restore_time(restore_time):
import datetime
if restore_time is None:
# During preview, default to 6 minutes before now to satisfy the minimum 5-minute buffer.
return datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) - datetime.timedelta(minutes=6)
Comment on lines +106 to +108
from dateutil import parser
try:
parsed = parser.parse(restore_time)
except (ValueError, OverflowError):
raise InvalidArgumentValueError(
"The restore time value has an incorrect date format. "
"Please use ISO8601 format, e.g., 2026-07-15T02:10:00+00:00.")
# Normalize to a UTC-aware datetime so the value sent to the service is unambiguous.
# A value without an explicit offset is interpreted as UTC (per the --restore-time contract);
# an offset-aware value is converted to UTC.
if parsed.tzinfo is None:
return parsed.replace(tzinfo=datetime.timezone.utc)
return parsed.astimezone(datetime.timezone.utc)


def horizondb_cluster_restore(client, resource_group_name, cluster_name, source_cluster,
restore_time=None, tags=None, no_wait=False):
from azext_horizondb.vendored_sdks.models import HorizonDbCluster, HorizonDbClusterProperties

source_cluster_resource = _resolve_source_cluster(client, resource_group_name, source_cluster)
properties = HorizonDbClusterProperties(
create_mode="PointInTimeRestore",
source_cluster_resource_id=source_cluster_resource.id,
point_in_time_utc=_parse_restore_time(restore_time),
)

resource = HorizonDbCluster(
location=source_cluster_resource.location,
tags=tags,
properties=properties,
)

return sdk_no_wait(no_wait, client.begin_create_or_update,
resource_group_name=resource_group_name,
cluster_name=cluster_name,
resource=resource)


def _resolve_public_access_range_for_command(public_access, yes, is_update):
"""Return an (start_ip, end_ip) tuple to open via a firewall rule, or None when no rule should be
created. Handles the read-only-flag reality: 'Disabled'/'None'/unset create no rule."""
Expand Down
Loading
Loading