diff --git a/.flake8 b/.flake8 index caa89c014..1926c5317 100644 --- a/.flake8 +++ b/.flake8 @@ -21,5 +21,8 @@ exclude = scripts doc build_scripts + env + venv + .venv */test/* */devops_sdk/* \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0db4b55e3..63c35ff14 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,15 +3,18 @@ "tasks": [ { "label": "BuildWheel", - "command": "${command:python.interpreterPath}", + "type": "shell", + "command": "python", "args": [ "setup.py", "sdist", "bdist_wheel" ], - "type": "shell", "options": { - "cwd": "${workspaceRoot}/azure-devops/" + "cwd": "${workspaceRoot}/azure-devops/", + "env": { + "PATH": "${workspaceRoot}\\env\\Scripts;${env:PATH}" + } }, "presentation": { "echo": true, diff --git a/README.md b/README.md index e6fdd4cf3..e25d870ae 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ $az [group] [subgroup] [command] {parameters} ``` Adding the Azure DevOps Extension adds `devops`, `pipelines`, `artifacts`, `boards` and `repos` groups. +Enterprise live migrations are available under `az devops migrations`. For usage and help content for any command, pass in the -h parameter, for example: ```bash @@ -43,6 +44,7 @@ Group Subgroups: admin : Manage administration operations. + migrations : Manage enterprise live migrations. extension : Manage extensions. project : Manage team projects. security : Manage security related operations. @@ -64,6 +66,7 @@ Commands: - Checkout the CLI docs at [docs.microsoft.com - Azure DevOps CLI](https://docs.microsoft.com/azure/devops/cli/). - Check out other examples in the [How-to guides](https://docs.microsoft.com/azure/devops/cli/?view=azure-devops#how-to-guides) section. - You can view the various commands and its usage here - [docs.microsoft.com - Azure DevOps Extension Reference](https://docs.microsoft.com/en-us/cli/azure/devops?view=azure-cli-latest) +- Enterprise live migrations guide: [doc/migrations.md](doc/migrations.md) ## Contribute diff --git a/azure-devops/azext_devops/__init__.py b/azure-devops/azext_devops/__init__.py index a2c4f30b6..6c22154c1 100644 --- a/azure-devops/azext_devops/__init__.py +++ b/azure-devops/azext_devops/__init__.py @@ -21,6 +21,8 @@ def load_command_table(self, args): load_admin_commands(self, args) from azext_devops.dev.boards.commands import load_work_commands load_work_commands(self, args) + from azext_devops.dev.migration.commands import load_migration_commands + load_migration_commands(self, args) from azext_devops.dev.pipelines.commands import load_build_commands load_build_commands(self, args) from azext_devops.dev.repos.commands import load_code_commands @@ -36,6 +38,8 @@ def load_arguments(self, command): load_admin_arguments(self, command) from azext_devops.dev.boards.arguments import load_work_arguments load_work_arguments(self, command) + from azext_devops.dev.migration.arguments import load_migration_arguments + load_migration_arguments(self, command) from azext_devops.dev.pipelines.arguments import load_build_arguments load_build_arguments(self, command) from azext_devops.dev.repos.arguments import load_code_arguments @@ -47,8 +51,8 @@ def load_arguments(self, command): @staticmethod def post_parse_args(_cli_ctx, **kwargs): - if (kwargs.get('command', None) and - kwargs['command'].startswith(('devops', 'boards', 'artifacts', 'pipelines', 'repos'))): + command = kwargs.get('command', None) + if command and command.startswith(('devops', 'boards', 'artifacts', 'pipelines', 'repos', 'migrations')): from azext_devops.dev.common.telemetry import set_tracking_data # we need to set tracking data only after we know that all args are valid, # otherwise we may log EUII data that a user inadvertently sent as an argument diff --git a/azure-devops/azext_devops/dev/common/arguments.py b/azure-devops/azext_devops/dev/common/arguments.py index c5eae8abf..a4805429c 100644 --- a/azure-devops/azext_devops/dev/common/arguments.py +++ b/azure-devops/azext_devops/dev/common/arguments.py @@ -25,8 +25,7 @@ def convert_date_string_to_iso8601(value, argument=None): if d.tzinfo is None: from dateutil.tz import tzlocal d = d.replace(tzinfo=tzlocal()) - d = d.isoformat() - return d + return d.isoformat() def convert_date_only_string_to_iso8601(value, argument=None): diff --git a/azure-devops/azext_devops/dev/migration/__init__.py b/azure-devops/azext_devops/dev/migration/__init__.py new file mode 100644 index 000000000..e2f28bdcd --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/__init__.py @@ -0,0 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from ._help import load_migration_help + +load_migration_help() diff --git a/azure-devops/azext_devops/dev/migration/_format.py b/azure-devops/azext_devops/dev/migration/_format.py new file mode 100644 index 000000000..e5a08e51d --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/_format.py @@ -0,0 +1,46 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from collections import OrderedDict +from azext_devops.dev.common.format import trim_for_display, date_time_to_only_date + + +_TARGET_TRUNCATION_LENGTH = 60 + + +def transform_migrations_table_output(result): + migrations = _unwrap_migration_list(result) + table_output = [] + for item in migrations: + table_output.append(_transform_migration_row(item)) + return table_output + + +def transform_migration_table_output(result): + if result is None: + return [] + return [_transform_migration_row(result)] + + +def _unwrap_migration_list(result): + if isinstance(result, dict) and 'value' in result: + return result['value'] + if isinstance(result, list): + return result + return [] + + +def _transform_migration_row(row): + table_row = OrderedDict() + table_row['RepositoryId'] = row.get('repositoryId') + table_row['TargetRepository'] = trim_for_display(row.get('targetRepository'), + _TARGET_TRUNCATION_LENGTH) + table_row['Status'] = row.get('status') + table_row['Stage'] = row.get('stage') + table_row['ValidateOnly'] = row.get('validateOnly') + table_row['CutoverDate'] = date_time_to_only_date(row.get('scheduledCutoverDate')) + table_row['CodeSyncDate'] = date_time_to_only_date(row.get('codeSyncDate')) + table_row['PrSyncDate'] = date_time_to_only_date(row.get('pullRequestSyncDate')) + return table_row diff --git a/azure-devops/azext_devops/dev/migration/_help.py b/azure-devops/azext_devops/dev/migration/_help.py new file mode 100644 index 000000000..96c1a5c55 --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/_help.py @@ -0,0 +1,91 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.help_files import helps + + +def load_migration_help(): + helps['devops migrations'] = """ + type: group + short-summary: Manage enterprise live migrations. + long-summary: 'This command group is a part of the azure-devops extension. For ELM migrations, --org should be your Azure DevOps organization URL (for example: https://dev.azure.com/myorg).' + """ + + helps['devops migrations list'] = """ + type: command + short-summary: List migrations in an organization. + examples: + - name: List migrations. + text: | + az devops migrations list --org https://dev.azure.com/myorg + - name: List all migrations including inactive ones. + text: | + az devops migrations list --org https://dev.azure.com/myorg --include-inactive + """ + + helps['devops migrations status'] = """ + type: command + short-summary: Get migration status for a repository. + examples: + - name: Get migration status by repository id. + text: | + az devops migrations status --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 + """ + + helps['devops migrations create'] = """ + type: command + short-summary: Create a migration for a repository. + examples: + - name: Create a migration. + text: | + az devops migrations create --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --target-repository https://github.com/OrgName/RepoName --target-owner-user-id OwnerUserId --agent-pool MigrationPool + - name: Create a validate-only migration. + text: | + az devops migrations create --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --target-repository https://github.com/OrgName/RepoName --target-owner-user-id OwnerUserId --agent-pool MigrationPool --validate-only --skip-validation ActivePullRequestCount,PullRequestDeltaSize + """ + + helps['devops migrations pause'] = """ + type: command + short-summary: Pause an active migration. + """ + + helps['devops migrations resume'] = """ + type: command + short-summary: Resume a stopped (paused, failed) migration. + examples: + - name: Resume using the current mode. + text: | + az devops migrations resume --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 + - name: Resume in validate-only mode. + text: | + az devops migrations resume --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --validate-only + - name: Continue migration (clears validate-only mode). + text: | + az devops migrations resume --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --migration + """ + + helps['devops migrations abandon'] = """ + type: command + short-summary: Abandon and delete a migration. + """ + + helps['devops migrations cutover'] = """ + type: group + short-summary: Manage migration cutover. + """ + + helps['devops migrations cutover set'] = """ + type: command + short-summary: Schedule cutover for a migration. + examples: + - name: Schedule cutover. + text: | + az devops migrations cutover set --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --date 2030-12-31T11:59:00Z + """ + + helps['devops migrations cutover cancel'] = """ + type: command + short-summary: Cancel a scheduled cutover. + """ diff --git a/azure-devops/azext_devops/dev/migration/arguments.py b/azure-devops/azext_devops/dev/migration/arguments.py new file mode 100644 index 000000000..f253c10d8 --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -0,0 +1,45 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azext_devops.dev.common.arguments import convert_date_string_to_iso8601 +from azext_devops.dev.team.arguments import load_global_args + + +# pylint: disable=too-many-statements +def load_migration_arguments(self, _): + with self.argument_context('devops migrations') as context: + load_global_args(context) + context.argument('repository_id', options_list='--repository-id', + help='ID of the repository (GUID).') + + with self.argument_context('devops migrations list') as context: + context.argument('include_inactive', options_list='--include-inactive', action='store_true', + help='Include inactive (completed, abandoned, failed) migrations in the results.') + + with self.argument_context('devops migrations create') as context: + context.argument('target_repository', options_list='--target-repository', + help='Target repository URL.') + context.argument('target_owner_user_id', options_list='--target-owner-user-id', + help='Target repository owner user ID.') + context.argument('validate_only', options_list='--validate-only', action='store_true', + help='Create in validate-only mode (pre-migration checks only).') + context.argument('cutover_date', options_list='--cutover-date', + type=convert_date_string_to_iso8601, + help='Scheduled cutover date/time (ISO 8601).') + context.argument('agent_pool', options_list='--agent-pool', + help='Agent pool name to use for migration work.') + context.argument('skip_validation', options_list='--skip-validation', + help='Comma-separated list of validation policies to skip.') + + with self.argument_context('devops migrations cutover set') as context: + context.argument('cutover_date', options_list='--date', + type=convert_date_string_to_iso8601, + help='The date and time for cutover (ISO 8601).') + + with self.argument_context('devops migrations resume') as context: + context.argument('validate_only', options_list='--validate-only', action='store_true', + help='Resume in validate-only mode.') + context.argument('migration', options_list='--migration', action='store_true', + help='Continue the migration (clears any validate-only mode).') diff --git a/azure-devops/azext_devops/dev/migration/commands.py b/azure-devops/azext_devops/dev/migration/commands.py new file mode 100644 index 000000000..684803f91 --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/commands.py @@ -0,0 +1,29 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.commands import CliCommandType +from azext_devops.dev.common.exception_handler import azure_devops_exception_handler +from ._format import transform_migrations_table_output, transform_migration_table_output + + +migrationOps = CliCommandType( + operations_tmpl='azext_devops.dev.migration.migration#{}', + exception_handler=azure_devops_exception_handler +) + + +def load_migration_commands(self, _): + with self.command_group('devops migrations', command_type=migrationOps) as g: + g.command('list', 'list_migrations', table_transformer=transform_migrations_table_output) + g.command('status', 'get_migration', table_transformer=transform_migration_table_output) + g.command('create', 'create_migration', table_transformer=transform_migration_table_output) + g.command('pause', 'pause_migration', table_transformer=transform_migration_table_output) + g.command('resume', 'resume_migration', table_transformer=transform_migration_table_output) + g.command('abandon', 'delete_migration', + confirmation='Are you sure you want to abandon this migration?') + + with self.command_group('devops migrations cutover', command_type=migrationOps) as g: + g.command('set', 'schedule_cutover', table_transformer=transform_migration_table_output) + g.command('cancel', 'cancel_cutover', table_transformer=transform_migration_table_output) diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py new file mode 100644 index 000000000..921372fa2 --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -0,0 +1,205 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from msrest import Configuration +from msrest.service_client import ServiceClient +from msrest.universal_http import ClientRequest +from knack.util import CLIError + +from azext_devops.version import VERSION +from azext_devops.dev.common.services import get_connection, resolve_instance +from azext_devops.dev.common.uuid import is_uuid + + +API_VERSION = '7.2-preview' +MIGRATIONS_API_PATH = '/_apis/elm/migrations' +_NON_ACTIVE_STATES = { + 'succeeded', + 'failed', + 'suspended' +} +_ACTIVE_STAGES = { + 'queued', + 'validation', + 'synchronization', + 'cutover' +} +def list_migrations(include_inactive=False, organization=None, detect=None): + organization = _resolve_org_for_auth(organization, detect) + client = _get_service_client(organization) + url = _build_migration_url(organization) + if include_inactive: + url += '&includeInactiveMigrations=true' + return _send_request(client, 'GET', url) + + +def _normalize_optional_text(value): + if value is None: + return None + normalized = str(value).strip() + return normalized if normalized else None + + +def get_migration(repository_id=None, organization=None, detect=None): + organization = _resolve_org_for_auth(organization, detect) + repository_id = _resolve_repository_id(repository_id) + client = _get_service_client(organization) + url = _build_migration_url(organization, repository_id) + return _send_request(client, 'GET', url) + + +def create_migration(repository_id=None, target_repository=None, target_owner_user_id=None, + validate_only=False, cutover_date=None, agent_pool=None, + skip_validation=None, organization=None, detect=None): + agent_pool = _normalize_optional_text(agent_pool) + skip_validation = _normalize_optional_text(skip_validation) + if not target_owner_user_id: + raise CLIError('--target-owner-user-id must be specified.') + + organization = _resolve_org_for_auth(organization, detect) + repository_id = _resolve_repository_id(repository_id) + + payload = { + 'targetRepository': target_repository, + 'targetOwnerUserId': target_owner_user_id, + 'validateOnly': bool(validate_only), + } + if agent_pool: + payload['agentPoolName'] = agent_pool + if cutover_date is not None: + payload['scheduledCutoverDate'] = cutover_date + if skip_validation is not None: + payload['skipValidation'] = skip_validation + + client = _get_service_client(organization) + url = _build_migration_url(organization, repository_id) + return _send_request(client, 'POST', url, payload) + + +def pause_migration(repository_id=None, organization=None, detect=None): + return _update_migration(repository_id, organization, detect, status_requested='suspended') + + +def resume_migration(repository_id=None, validate_only=False, migration=False, organization=None, detect=None): + if validate_only and migration: + raise CLIError('Please specify only one of --validate-only or --migration.') + + migration_data = get_migration(repository_id=repository_id, organization=organization, detect=detect) + if _is_migration_active(migration_data): + status = migration_data.get('statusRequested') or migration_data.get('status') + stage = migration_data.get('stage') + raise CLIError('Migration is active (statusRequested: {}, stage: {}). Pause it before resuming or changing mode.' + .format(status, stage)) + + validate_only_value = None + if validate_only: + validate_only_value = True + elif migration: + validate_only_value = False + + return _update_migration(repository_id, organization, detect, + validate_only=validate_only_value, status_requested='active') + + +def schedule_cutover(repository_id=None, cutover_date=None, organization=None, detect=None): + if not cutover_date: + raise CLIError('--date must be specified.') + return _update_migration(repository_id, organization, detect, scheduled_cutover_date=cutover_date, + include_cutover=True) + + +def cancel_cutover(repository_id=None, organization=None, detect=None): + return _update_migration(repository_id, organization, detect, scheduled_cutover_date=None, + include_cutover=True) + + +def delete_migration(repository_id=None, organization=None, detect=None): + organization = _resolve_org_for_auth(organization, detect) + repository_id = _resolve_repository_id(repository_id) + client = _get_service_client(organization) + url = _build_migration_url(organization, repository_id) + return _send_request(client, 'DELETE', url) + + +def _update_migration(repository_id, organization, detect, validate_only=None, + status_requested=None, scheduled_cutover_date=None, include_cutover=False): + organization = _resolve_org_for_auth(organization, detect) + repository_id = _resolve_repository_id(repository_id) + client = _get_service_client(organization) + url = _build_migration_url(organization, repository_id) + + payload = {} + if validate_only is not None: + payload['validateOnly'] = bool(validate_only) + if status_requested is not None: + payload['statusRequested'] = status_requested + if include_cutover: + payload['scheduledCutoverDate'] = scheduled_cutover_date + return _send_request(client, 'PUT', url, payload) + + +def _resolve_repository_id(repository_id): + if not repository_id: + raise CLIError('--repository-id must be specified.') + if not is_uuid(repository_id): + raise CLIError('--repository-id must be a valid GUID.') + return repository_id + + +def _normalize_state(value): + if value is None: + return '' + normalized = str(value).strip().lower() + return normalized.replace(' ', '').replace('-', '').replace('_', '') + + +def _is_migration_active(migration): + if not isinstance(migration, dict): + return False + + status = _normalize_state(migration.get('statusRequested') or migration.get('status')) + if status: + return status not in _NON_ACTIVE_STATES + + stage = _normalize_state(migration.get('stage')) + if stage: + return stage in _ACTIVE_STAGES + + return False + + +def _resolve_org_for_auth(organization, detect): + return resolve_instance(detect=detect, organization=organization) + + +def _build_migration_url(base_url, repository_id=None): + url = base_url.rstrip('/') + MIGRATIONS_API_PATH + if repository_id: + url += '/{}'.format(repository_id) + return url + '?api-version=' + API_VERSION + + +def _get_service_client(organization): + config = Configuration(base_url=None) + config.add_user_agent('devOpsCli/{}'.format(VERSION)) + connection = get_connection(organization) + return ServiceClient(creds=connection._creds, config=config) # pylint: disable=protected-access + + +def _send_request(client, method, url, content=None): + request = ClientRequest(method=method, url=url) + headers = { + 'Content-Type': 'application/json; charset=utf-8', + 'Accept': 'application/json;api-version=' + API_VERSION + } + response = client.send(request=request, headers=headers, content=content) + if response.status_code < 200 or response.status_code >= 300: + error_detail = getattr(response, 'text', None) or getattr(response, 'content', None) or '' + raise CLIError('Request failed with status {}. {}'.format(response.status_code, error_detail)) + + content_type = response.headers.get('Content-Type') if response.headers else None + if content_type and 'json' in content_type: + return response.json() + return {} diff --git a/azure-devops/azext_devops/tests/latest/common/test_arguments.py b/azure-devops/azext_devops/tests/latest/common/test_arguments.py index 92b913f48..e0dfa4fad 100644 --- a/azure-devops/azext_devops/tests/latest/common/test_arguments.py +++ b/azure-devops/azext_devops/tests/latest/common/test_arguments.py @@ -3,8 +3,9 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import json import unittest -from azext_devops.dev.common.arguments import should_detect +from azext_devops.dev.common.arguments import should_detect, convert_date_string_to_iso8601 class TestArgumentsMethods(unittest.TestCase): @@ -14,5 +15,99 @@ def test_should_detect(self): self.assertEqual(should_detect(None), True) +class TestConvertDateStringToIso8601(unittest.TestCase): + + def _assert_valid(self, input_value, expected_substring=None): + result = convert_date_string_to_iso8601(input_value) + self.assertIsInstance(result, str, 'Expected string, got {}'.format(type(result))) + # Must be JSON-serializable as a string field + json.dumps({'date': result}) + if expected_substring: + self.assertIn(expected_substring, result) + return result + + # --- timezone-aware inputs (the original bug) --- + + def test_utc_z_suffix(self): + result = self._assert_valid('2026-03-24T20:55:00Z', '2026-03-24T20:55:00') + self.assertIn('+00:00', result) + + def test_utc_explicit_offset(self): + result = self._assert_valid('2026-03-24T20:55:00+00:00', '2026-03-24T20:55:00') + self.assertIn('+00:00', result) + + def test_negative_offset(self): + result = self._assert_valid('2026-03-24T20:55:00-07:00') + self.assertIn('-07:00', result) + + def test_positive_offset(self): + result = self._assert_valid('2026-03-24T20:55:00+05:30') + self.assertIn('+05:30', result) + + def test_milliseconds_with_z(self): + result = self._assert_valid('2026-03-24T20:55:00.000Z') + self.assertIn('+00:00', result) + + # --- timezone-naive inputs (should get local tz applied) --- + + def test_naive_datetime(self): + self._assert_valid('2026-03-24T20:55:00') + + def test_date_only(self): + result = self._assert_valid('2026-03-24') + self.assertIn('2026-03-24T00:00:00', result) + + def test_human_readable_date(self): + result = self._assert_valid('March 24, 2026') + self.assertIn('2026-03-24', result) + + def test_slash_date(self): + result = self._assert_valid('03/24/2026') + self.assertIn('2026-03-24', result) + + # --- invalid inputs --- + + def test_empty_string_raises(self): + with self.assertRaises(ValueError): + convert_date_string_to_iso8601('') + + def test_whitespace_raises(self): + with self.assertRaises(ValueError): + convert_date_string_to_iso8601(' ') + + def test_garbage_string_raises(self): + with self.assertRaises(ValueError): + convert_date_string_to_iso8601('not-a-date') + + def test_null_string_raises(self): + with self.assertRaises(ValueError): + convert_date_string_to_iso8601('null') + + def test_none_raises(self): + with self.assertRaises((ValueError, TypeError)): + convert_date_string_to_iso8601(None) + + # --- argument name in error message --- + + def test_error_includes_argument_name(self): + with self.assertRaises(ValueError) as ctx: + convert_date_string_to_iso8601('bad', argument='scheduled-cutover-date') + self.assertIn('scheduled-cutover-date', str(ctx.exception)) + + def test_error_without_argument_name(self): + with self.assertRaises(ValueError) as ctx: + convert_date_string_to_iso8601('bad') + self.assertIn('bad', str(ctx.exception)) + + # --- JSON payload round-trip (simulates what _send_request does) --- + + def test_json_payload_roundtrip(self): + result = convert_date_string_to_iso8601('2026-03-24T20:55:00Z') + payload = {'scheduledCutoverDate': result} + serialized = json.dumps(payload) + deserialized = json.loads(serialized) + self.assertEqual(deserialized['scheduledCutoverDate'], result) + + if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/azure-devops/azext_devops/tests/latest/migration/__init__.py b/azure-devops/azext_devops/tests/latest/migration/__init__.py new file mode 100644 index 000000000..34913fb39 --- /dev/null +++ b/azure-devops/azext_devops/tests/latest/migration/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/azure-devops/azext_devops/tests/latest/migration/test_migration.py b/azure-devops/azext_devops/tests/latest/migration/test_migration.py new file mode 100644 index 000000000..6078f43c0 --- /dev/null +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -0,0 +1,330 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest + +try: + # Attempt to load mock (works on Python 3.3 and above) + from unittest.mock import patch +except ImportError: + # Attempt to load mock (works on Python version below 3.3) + from mock import patch + +from knack.util import CLIError + +from azext_devops.dev.migration.migration import (list_migrations, + create_migration, + cancel_cutover, + resume_migration) + + +class TestMigrationCommands(unittest.TestCase): + + _TEST_ORG = 'https://elm.contoso.com/elmo1' + + def test_list_migrations_calls_get(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + list_migrations(organization=self._TEST_ORG, detect=False) + + mock_resolve.assert_called_once_with(detect=False, organization=self._TEST_ORG) + args = mock_send.call_args[0] + self.assertEqual(args[1], 'GET') + self.assertTrue(args[2].startswith(self._TEST_ORG.rstrip('/'))) + self.assertIn('/_apis/elm/migrations', args[2]) + self.assertNotIn('includeInactiveMigrations', args[2]) + + def test_list_migrations_include_inactive(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + list_migrations(include_inactive=True, organization=self._TEST_ORG, detect=False) + + args = mock_send.call_args[0] + self.assertEqual(args[1], 'GET') + self.assertIn('includeInactiveMigrations=true', args[2]) + + def test_create_migration_payload_defaults_validate_only_false(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + agent_pool='MigrationPool', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertFalse(payload['validateOnly']) + + def test_create_migration_without_agent_pool(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertNotIn('agentPoolName', payload) + + def test_create_migration_payload_includes_optional_fields(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + validate_only=True, + cutover_date='2030-12-31T11:59:00Z', + agent_pool='MigrationPool', + skip_validation='ActivePullRequestCount,PullRequestDeltaSize', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertTrue(payload['validateOnly']) + self.assertEqual(payload['scheduledCutoverDate'], '2030-12-31T11:59:00Z') + self.assertEqual(payload['agentPoolName'], 'MigrationPool') + self.assertEqual(payload['skipValidation'], 'ActivePullRequestCount,PullRequestDeltaSize') + + def test_create_migration_empty_agent_pool_omitted(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + agent_pool=' ', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertNotIn('agentPoolName', payload) + + def test_create_migration_omits_empty_skip_validation(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + agent_pool='MigrationPool', + skip_validation=' ', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertNotIn('skipValidation', payload) + + def test_create_migration_trims_optional_fields(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + agent_pool=' MigrationPool ', + skip_validation=' ActivePullRequestCount, PullRequestDeltaSize ', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['agentPoolName'], 'MigrationPool') + self.assertEqual(payload['skipValidation'], 'ActivePullRequestCount, PullRequestDeltaSize') + + def test_create_migration_passes_target_repository_to_api(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + agent_pool='MigrationPool', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['targetRepository'], 'https://example.com/OrgName/RepoName') + + def test_create_migration_validate_only_flag_sends_true(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + validate_only=True, + agent_pool='MigrationPool', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertTrue(payload['validateOnly']) + + def test_create_migration_agent_pool_always_in_payload(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + agent_pool='MigrationPool', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['agentPoolName'], 'MigrationPool') + + def test_cancel_cutover_sets_null(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + cancel_cutover( + repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertIsNone(payload['scheduledCutoverDate']) + + def test_resume_rejects_both_flags(self): + with self.assertRaises(CLIError): + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + validate_only=True, migration=True, + organization=self._TEST_ORG, detect=False) + + def test_resume_fails_when_active(self): + with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ + patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve: + mock_get.return_value = {'status': 'active', 'stage': 'synchronization'} + mock_resolve.return_value = self._TEST_ORG + + with self.assertRaises(CLIError): + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, detect=False) + + def test_resume_fails_when_active_via_statusRequested(self): + with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ + patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve: + mock_get.return_value = {'statusRequested': 'Active', 'stage': 'validation'} + mock_resolve.return_value = self._TEST_ORG + + with self.assertRaises(CLIError): + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, detect=False) + + def test_resume_sets_validate_only(self): + with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ + patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_get.return_value = {'status': 'succeeded'} + mock_resolve.return_value = self._TEST_ORG + + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + validate_only=True, + organization=self._TEST_ORG, detect=False) + + payload = mock_send.call_args[0][3] + self.assertTrue(payload['validateOnly']) + self.assertEqual(payload['statusRequested'], 'active') + + def test_resume_sets_migration(self): + with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ + patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_get.return_value = {'status': 'suspended'} + mock_resolve.return_value = self._TEST_ORG + + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + migration=True, + organization=self._TEST_ORG, detect=False) + + payload = mock_send.call_args[0][3] + self.assertFalse(payload['validateOnly']) + self.assertEqual(payload['statusRequested'], 'active') + + def test_resume_without_flags_preserves_mode(self): + with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ + patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_get.return_value = {'status': 'failed'} + mock_resolve.return_value = self._TEST_ORG + + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, detect=False) + + payload = mock_send.call_args[0][3] + self.assertNotIn('validateOnly', payload) + self.assertEqual(payload['statusRequested'], 'active') + + +if __name__ == '__main__': + unittest.main() diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md new file mode 100644 index 000000000..681a32ee5 --- /dev/null +++ b/doc/elm_migrations_tsg.md @@ -0,0 +1,402 @@ +# ELM Migrations — End-to-End Guide & Troubleshooting (TSG) + +Migrate Git repositories from Azure DevOps to GitHub using the `az devops migrations` CLI commands. + +> **Shell note:** Examples use `\` for line continuation (bash/zsh). In PowerShell, use backtick `` ` `` instead, or put the entire command on one line. + +--- + +## 1. Prerequisites & Setup + +### 1.1 Install Azure CLI (if not already installed) + +Follow [Install the Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli). + +Verify it's installed: + +```powershell +az --version +``` + +### 1.2 Install the ELM extension from the wheel file + +You'll receive a `.whl` file (e.g., `azure_devops-1.0.3-py2.py3-none-any.whl`). This is the Azure DevOps CLI extension package that contains the migration commands. + +```powershell +# Remove any existing version first (ignore errors if not installed) +az extension remove -n azure-devops + +# Install from the wheel file (use the actual path to your .whl file) +az extension add --source ./azure_devops-1.0.3-py2.py3-none-any.whl -y + +# Verify installation — you should see name: "azure-devops" and a version +az extension show -n azure-devops --query "{name:name,version:version}" -o json +``` + +### 1.3 Sign in + +```powershell +# Option A: Azure AD / Entra ID (recommended) +az login + +# Option B: Personal Access Token (needs "Full access" or at minimum Code Read/Write scope) +az devops login +``` + +### 1.4 Set your default org (recommended) + +This saves you from typing `--org` on every single command: + +```powershell +az devops configure -d organization=https://dev.azure.com/ +``` + +### 1.5 Verify your config + +```powershell +az devops configure -l +``` + +You should see your org URL under `organization`. If you see a wrong URL (e.g., `codedev.ms` or an old org URL), re-run step 1.4 with the correct URL. + +--- + +## 2. Understand the Migration Lifecycle + +A migration moves through these **stages**: + +``` +Queued → Validation → Synchronization → Cutover → Migrated +``` + +And has one of these **statuses**: + +| Status | Meaning | +|---|---| +| `Active` | Migration is running (in one of the stages above) | +| `Succeeded` | Migration completed successfully | +| `Failed` | Migration encountered an error (can be resumed) | +| `Suspended` | Migration was paused by the user (can be resumed) | + +### Recommended workflow + +The safest approach is **validate first, then migrate**: + +``` +Create (validate-only) → Check status → Pause → Resume (--migration) → Monitor → Schedule cutover → Done +``` + +--- + +## 3. End-to-End Walkthrough + +### What you'll need before starting + +| Item | Example | How to get it | +|---|---|---| +| Azure DevOps org URL | `https://dev.azure.com/myorg` | Your ADO org URL — used for `--org` and to look up repo GUIDs | +| ADO project name | `MyProject` | The project containing the source repo | +| ADO repo name | `my-repo` | The repo you want to migrate | +| Target repo URL | `https://example.ghe.com/OrgName/RepoName` | Create the empty target repo in GitHub **before** starting | +| Target owner user ID | `GeoffCoxMSFT` | The GitHub user ID who owns the target repo | +| Agent pool name | `MigrationPool` | Ask your admin | + +### 3.1 Get the source repository GUID from Azure DevOps + +Every migration command uses a repository GUID (not the repo name). Get it from your ADO org: + +```powershell +az repos show --org https://dev.azure.com/myorg/ --project MyProject --repository my-repo --query id -o tsv +``` + +Example output: +``` +b3e18946-5b39-40ca-8e2f-d0eb683d8a85 +``` + +Save this GUID — you'll use it in every command below. + +### 3.2 (Optional) Check for existing migrations + +See if any migrations already exist for your org: + +```powershell +# Active migrations only +az devops migrations list --detect false + +# All migrations including completed/failed/suspended +az devops migrations list --detect false --include-inactive +``` + +### 3.3 Create a validate-only migration + +Start with validation to catch any issues **before** moving data. This runs pre-migration checks without transferring any code or PRs: + +```powershell +az devops migrations create --detect false \ + --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 \ + --target-repository https://example.ghe.com/OrgName/RepoName \ + --target-owner-user-id GeoffCoxMSFT \ + --agent-pool MigrationPool \ + --validate-only +``` + +The command returns the migration details as JSON. The migration begins immediately in the background. + +> **Tip:** If you're confident and want to start a full migration right away (skip validate-only), omit the `--validate-only` flag. + +**Optional parameters you can add at creation time:** + +| Parameter | What it does | Example | +|---|---|---| +| `--cutover-date` | Pre-schedule the final cutover date | `--cutover-date 2030-12-31T11:59:00Z` | +| `--skip-validation` | Skip specific validation checks | `--skip-validation ActivePullRequestCount,PullRequestDeltaSize` | + +### 3.4 Monitor migration status + +Check status anytime — run this as often as you need: + +```powershell +az devops migrations status --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 +``` + +For the full JSON response (useful for debugging): + +```powershell +az devops migrations status --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 -o json +``` + +**How to read the output:** + +| You see this | It means | What to do next | +|---|---|---| +| `status: Active`, `stage: Validation` | Validation is in progress | Wait, check again later | +| `status: Active`, `stage: Synchronization` | Code/PRs are syncing | Wait, check again later | +| `status: Succeeded` | Current phase completed | If validate-only: go to step 3.5. If migration: go to step 3.6 | +| `status: Failed` | Something went wrong | Check the error in `-o json` output, fix the issue, then resume (step 4) | +| `status: Suspended` | You paused it | Resume when ready (step 3.5) | + +### 3.5 Promote from validate-only to full migration + +**When to do this:** After step 3.4 shows `status: Succeeded` (validation passed). + +You need to pause first (because the migration may still be active), then resume in migration mode: + +```powershell +# Step A: Pause the current migration +az devops migrations pause --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 + +# Step B: Resume as a full migration (this starts data movement) +az devops migrations resume --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 --migration +``` + +> **If you get:** `Migration is active (status: ..., stage: ...). Pause it before resuming or changing mode.` +> Run the pause command first (Step A), then retry Step B. + +After this, monitor with step 3.4 until `stage: Synchronization` is running. + +### 3.6 Schedule cutover + +Once synchronization is running and you're ready to finalize the migration: + +```powershell +az devops migrations cutover set --detect false \ + --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 --date 2030-12-31T11:59:00Z +``` + +> **Date format:** Must be ISO 8601. Examples: `2030-12-31T11:59:00Z`, `2030-06-15T08:00:00-07:00` + +Changed your mind? Cancel the scheduled cutover: + +```powershell +az devops migrations cutover cancel --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 +``` + +### 3.7 Verify completion + +After cutover completes, confirm the migration finished: + +```powershell +az devops migrations status --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 +``` + +**Success looks like:** `status: Succeeded`, `stage: Migrated`. + +At this point your repository has been fully migrated from Azure DevOps to GitHub. Verify the target repo in GitHub has all your code, branches, and pull requests. + +### 3.8 (If needed) Abandon a migration + +If something went wrong and you want to delete the migration entirely and start over: + +```powershell +az devops migrations abandon --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 +``` + +> **Warning:** This permanently deletes the migration record. You will be prompted to confirm. After abandoning, you can create a new migration for the same repository. + +--- + +## 4. Other Scenarios + +### Pause and resume without changing mode + +If you need to temporarily stop a migration and restart it in the same mode: + +```powershell +# Pause +az devops migrations pause --detect false --repository-id + +# Resume (keeps whatever mode — validate-only or full migration — it was in) +az devops migrations resume --detect false --repository-id +``` + +### Switch back to validate-only after starting full migration + +Changed your mind after promoting to full migration? You can go back: + +```powershell +az devops migrations pause --detect false --repository-id +az devops migrations resume --detect false --repository-id --validate-only +``` + +### Resume a failed migration + +If a migration fails (you'll see `status: Failed` in the status output), you can resume it directly — no pause needed since it's already stopped: + +```powershell +# Resume in the same mode +az devops migrations resume --detect false --repository-id + +# Or resume and switch mode at the same time +az devops migrations resume --detect false --repository-id --migration +az devops migrations resume --detect false --repository-id --validate-only +``` + +--- + +## 5. Complete Command & Parameter Reference + +| Command | Required Params | Optional Params | HTTP | Description | +|---|---|---|---|---| +| `list` | `--org` | `--include-inactive`, `--detect` | GET | List migrations. By default only active ones. | +| `status` | `--org`, `--repository-id` | `--detect` | GET | Get detailed status for one migration. | +| `create` | `--org`, `--repository-id`, `--target-repository`, `--target-owner-user-id`, `--agent-pool` | `--validate-only`, `--cutover-date`, `--skip-validation`, `--detect` | POST | Create a new migration. | +| `pause` | `--org`, `--repository-id` | `--detect` | PUT | Pause an active migration. | +| `resume` | `--org`, `--repository-id` | `--validate-only`, `--migration`, `--detect` | PUT | Resume a stopped migration. | +| `cutover set` | `--org`, `--repository-id`, `--date` | `--detect` | PUT | Schedule a cutover date/time. | +| `cutover cancel` | `--org`, `--repository-id` | `--detect` | PUT | Cancel a scheduled cutover. | +| `abandon` | `--org`, `--repository-id` | `--detect` | DELETE | Permanently delete a migration (prompts for confirmation). | + +### 5.1 Parameter Details + +| Parameter | Type | Used By | Description | +|---|---|---|---| +| `--org` | URL | All | Azure DevOps org URL (e.g., `https://dev.azure.com/myorg`). Can be set as default. | +| `--repository-id` | GUID | All except `list` | Azure Repos repository GUID. Get from `az repos show --query id`. | +| `--target-repository` | URL | `create` | Target repository URL (e.g., `https://example.ghe.com/OrgName/RepoName`). Validated by the server. | +| `--target-owner-user-id` | string | `create` | Target repository owner user ID. | +| `--agent-pool` | string | `create` | Agent pool name for migration work. Required. | +| `--validate-only` | flag | `create`, `resume` | On `create`: run pre-migration checks only. On `resume`: switch to validate-only mode. | +| `--migration` | flag | `resume` | Switch to full migration mode (clears validate-only). Mutually exclusive with `--validate-only`. | +| `--cutover-date` | ISO 8601 | `create` | Pre-schedule cutover at creation time. E.g., `2030-12-31T11:59:00Z`. | +| `--date` | ISO 8601 | `cutover set` | Schedule cutover date/time. E.g., `2030-12-31T11:59:00Z`. | +| `--skip-validation` | string | `create` | Comma-separated list of validation policies to skip. | +| `--include-inactive` | flag | `list` | Include completed, failed, and suspended migrations. | +| `--detect` | flag | All | Auto-detect org from git remote (default: `true`). Use `--detect false` to disable. | + +## 6. Common Pitfalls + +| Pitfall | Symptom | Fix | +|---|---|---| +| **Auto-detect overrides `--org`** | Requests go to wrong host (e.g., `codedev.ms`) | Add `--detect false` or run from a non-ADO-repo directory | +| **Stale default org in config** | Requests go to old/dev URL (e.g., `codedev.ms`) | Run `az devops configure -d organization=https://dev.azure.com/` to update | +| **Resume on an active migration** | Error: "Migration is active..." | Pause first with `az devops migrations pause`, then resume | +| **Both `--validate-only` and `--migration` on resume** | Error: "Please specify only one..." | Use only one flag at a time | +| **Missing `--agent-pool` on create** | Error: "--agent-pool must be specified." | Always provide `--agent-pool ` | +| **Invalid `--repository-id`** | Error: "--repository-id must be a valid GUID." | Use `az repos show --query id` to get the correct GUID | +| **Bad date format** | Error: "must be a valid date or datetime string" | Use ISO 8601 format, e.g., `2030-12-31T11:59:00Z` | + +## 7. Common Errors and Fixes + +### Authentication Errors (401 / 403) + +**Symptom:** `Request failed with status 401` or `403`. + +**Fix:** +1. Run `az login` (AAD) or `az devops login` (PAT). +2. Ensure the token/account has permission to the organization. +3. Verify `--org` points to the correct Azure DevOps org URL. + +### 404 Not Found + +**Symptom:** `Request failed with status 404`. + +**Fix:** +1. Verify `--org` is correct (e.g., `https://dev.azure.com/myorg`). +2. Verify the `--repository-id` is a valid GUID that exists in the organization. + +### 400 Bad Request + +**Symptom:** `Request failed with status 400` or `JsonReaderException`. + +**Fix:** +1. Check date values are valid ISO 8601 strings (e.g., `2030-12-31T11:59:00Z`). +2. Ensure `--target-repository` is a valid URL. +3. Ensure `--agent-pool` matches a pool name the service recognizes. + +### 406 Not Acceptable + +**Symptom:** `Request failed with status 406`. + +**Fix:** +1. Verify `--org` is correct. +2. Confirm you are using the latest CLI extension version. +3. Contact your admin if it persists. + +### 500 Internal Server Error / Retries Exhausted + +**Symptom:** `Max retries exceeded with url: ... (Caused by ResponseError('too many 500 error responses'))`. + +**Fix:** +1. Check if the requests are going to the **wrong host** (e.g., `codedev.ms` instead of your org URL). + - Run `az devops configure -l` to check your default org. + - Fix with `az devops configure -d organization=https://dev.azure.com/`. + - Or pass `--org --detect false` explicitly. +2. If the correct host is being used, the service may be temporarily unavailable — retry later or contact your admin. + +## 8. Useful Commands + +```powershell +# Check extension version +az extension show -n azure-devops --query "{name:name,version:version}" -o json + +# Set default org (so you can omit --org) +az devops configure -d organization=https://dev.azure.com/ + +# View current defaults +az devops configure -l + +# Install/update the extension from a wheel file +az extension add --source ./azure_devops-1.0.3-py2.py3-none-any.whl -y + +# Uninstall the extension +az extension remove -n azure-devops + +# Get repo GUID from ADO +az repos show --org https://dev.azure.com// --project --repository --query id -o tsv + +# List all migrations (including inactive) +az devops migrations list --include-inactive + +# Get full JSON output (instead of table) +az devops migrations status --repository-id -o json +``` + +## 9. Output Formats + +| Flag | Format | Best for | +|---|---|---| +| (default / `--output table`) | Table with key columns | Quick overview | +| `--output json` | Full JSON response from API | Scripting, debugging, seeing all fields | +| `--output tsv` | Tab-separated values | Piping to other commands | +| `--query ` | Filtered output | Extracting specific fields (e.g., `--query status`) | diff --git a/doc/getting_started.md b/doc/getting_started.md index f3b38186a..0d30c05c4 100644 --- a/doc/getting_started.md +++ b/doc/getting_started.md @@ -100,3 +100,7 @@ Global Arguments --- ---------- --------- --------- ------------- ----------------------- -------------- -------------------------- ------- 1 20190116.2 completed succeeded 1 Contoso.CI master 2019-01-16 17:29:07.497795 manual ``` + +## Enterprise live migrations + +If you are using enterprise live migrations, see the guide at [migrations.md](migrations.md). diff --git a/doc/migrations.md b/doc/migrations.md new file mode 100644 index 000000000..ed06d4a99 --- /dev/null +++ b/doc/migrations.md @@ -0,0 +1,106 @@ +# Enterprise live migrations (ELM) + +The `az devops migrations` command group manages enterprise live migrations for repositories. + +## Prerequisites + +- Azure DevOps CLI with the Azure DevOps extension installed. +- Sign in using `az login` or `az devops login`. +- Use `--org` to specify your Azure DevOps org URL (e.g., `https://dev.azure.com/myorg`). + +## Required inputs + +- `--repository-id` is the Azure Repos repository GUID. +- `--target-repository` is the target repository URL. +- `--target-owner-user-id` is required for create. +- `--agent-pool` is required for create. +- `--cutover-date` / `--date` must be ISO 8601, for example: `2030-12-31T11:59:00Z`. + +## Command reference + +- `list`: List migrations for the org. Use `--include-inactive` to include completed/failed/suspended migrations. +- `status`: Show migration status for a repository GUID. +- `create`: Create a migration. Use `--validate-only` for pre-migration checks only. +- `pause`: Pause an active migration. +- `resume`: Resume a stopped (paused, failed) migration. Optional flags: + - `--validate-only`: Resume in validate-only mode. + - `--migration`: Continue the migration (clears validate-only mode). + If a migration is active, pause it before resuming. +- `cutover set` / `cutover cancel`: Schedule or cancel cutover. +- `abandon`: Abandon and delete a migration. + +## Common workflows + +### List migrations + +```bash +az devops migrations list --org https://dev.azure.com/myorg +``` + +### List all migrations including inactive + +```bash +az devops migrations list --org https://dev.azure.com/myorg --include-inactive +``` + +### Check migration status + +```bash +az devops migrations status --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 +``` + +### Create a migration + +```bash +az devops migrations create --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 \ + --target-repository https://example.ghe.com/OrgName/RepoName \ + --target-owner-user-id OwnerId \ + --agent-pool MigrationPool +``` + +### Create a validate-only migration + +```bash +az devops migrations create --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 \ + --target-repository https://example.ghe.com/OrgName/RepoName \ + --target-owner-user-id OwnerId \ + --agent-pool MigrationPool \ + --validate-only +``` + +### Pause and resume + +```bash +az devops migrations pause --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 + +az devops migrations resume --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 + +az devops migrations resume --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 --validate-only + +az devops migrations resume --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 --migration +``` + +### Schedule or cancel cutover + +```bash +az devops migrations cutover set --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 \ + --date 2030-12-31T11:59:00Z + +az devops migrations cutover cancel --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 +``` + +### Abandon a migration + +```bash +az devops migrations abandon --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 +```