Container Copy Job implementation for SQL accounts (#2241)

* Initial dev for container copy

* remove padding from label

* Added Copy Job prerequisites screen

* Added hooks to evaluate reader role access

* added copyjob pre-requsite screen along with it's validations

* Added monitor copy job list screen

* added copy job list refresh and reset functionality

* remove arm token dependency

* fetch account details from account id instead of context

* Fix lint & typescript checks

* show copyjob screen from portal navigation

* adding copy job details screen

* remove duplicate code & show sql accounts only

* ui fixes for list job page

* pending icon

* copy job details screen ui

* reset .vscode/settings.json

* Fixed existing UTs

* disabling action buttons until it's in progress

* fixed formatting

* Adding loader on submit button and show job creation errors in the panel itself

* updating disabling action menu item logic

* added custom pager

* fix lint and ts errors

* updating file names and removing comments

* remove comments

* modularize the arom common code

* Adding content and removing tooltip

* updating job details screen

* updating online copy enabled screen

* Adding below changes
- Don't show permission screen for same account in offline mode
- Don't show identity permissions for same account in online mode
- Show error message if selected containers are identical
- Update abort signal messages

* added feedback code from explorer

* Add tooltips and long polling
- Added tooltips to permission sections
- Implemented long polling for PITR and online copy enabled sections
- Long polling automatically stops after 15 minutes
- After polling ends, a refresh button will be displayed

---------

Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
This commit is contained in:
BChoudhury-ms
2025-11-05 22:54:00 +05:30
committed by GitHub
parent 3718f5a16a
commit 2417da152d
78 changed files with 4152 additions and 36 deletions

115
src/Utils/arm/RbacUtils.ts Normal file
View File

@@ -0,0 +1,115 @@
import { configContext } from "ConfigContext";
import { buildArmUrl } from "Utils/arm/armUtils";
import { armRequest } from "Utils/arm/request";
import { getCopyJobAuthorizationHeader } from "../CopyJobAuthUtils";
export type FetchAccountDetailsParams = {
subscriptionId: string;
resourceGroupName: string;
accountName: string;
};
export type RoleAssignmentPropertiesType = {
roleDefinitionId: string;
principalId: string;
scope: string;
};
export type RoleAssignmentType = {
id: string;
name: string;
properties: RoleAssignmentPropertiesType;
type: string;
};
type RoleDefinitionDataActions = {
dataActions: string[];
};
export type RoleDefinitionType = {
assignableScopes: string[];
id: string;
name: string;
permissions: RoleDefinitionDataActions[];
resourceGroup: string;
roleName: string;
type: string;
typePropertiesType: string;
};
const apiVersion = "2025-04-15";
const handleResponse = async (response: Response, context: string) => {
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`Failed to fetch ${context}. Status: ${response.status}. ${body || ""}`);
}
return response.json();
};
export const fetchRoleAssignments = async (
subscriptionId: string,
resourceGroupName: string,
accountName: string,
principalId: string,
): Promise<RoleAssignmentType[]> => {
const uri = buildArmUrl(
`/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/sqlRoleAssignments`,
apiVersion,
);
const response = await fetch(uri, { method: "GET", headers: getCopyJobAuthorizationHeader() });
const data = await handleResponse(response, "role assignments");
return (data.value || []).filter(
(assignment: RoleAssignmentType) => assignment?.properties?.principalId === principalId,
);
};
export const fetchRoleDefinitions = async (roleAssignments: RoleAssignmentType[]): Promise<RoleDefinitionType[]> => {
const roleDefinitionIds = roleAssignments.map((assignment) => assignment.properties.roleDefinitionId);
const uniqueRoleDefinitionIds = Array.from(new Set(roleDefinitionIds));
const headers = getCopyJobAuthorizationHeader();
const roleDefinitionUris = uniqueRoleDefinitionIds.map((id) => buildArmUrl(id, apiVersion));
const promises = roleDefinitionUris.map((url) => fetch(url, { method: "GET", headers }));
const responses = await Promise.all(promises);
const roleDefinitions = await Promise.all(
responses.map((res, i) => handleResponse(res, `role definition ${uniqueRoleDefinitionIds[i]}`)),
);
return roleDefinitions;
};
export const assignRole = async (
subscriptionId: string,
resourceGroupName: string,
accountName: string,
principalId: string,
): Promise<RoleAssignmentType | null> => {
if (!principalId) {
return null;
}
const accountScope = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`;
const roleDefinitionId = `${accountScope}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000001`;
const roleAssignmentName = crypto.randomUUID();
const path = `${accountScope}/sqlRoleAssignments/${roleAssignmentName}`;
const body = {
properties: {
roleDefinitionId,
scope: `${accountScope}/`,
principalId,
},
};
const response: RoleAssignmentType = await armRequest({
host: configContext.ARM_ENDPOINT,
path,
method: "PUT",
apiVersion,
body,
});
return response;
};

15
src/Utils/arm/armUtils.ts Normal file
View File

@@ -0,0 +1,15 @@
import { configContext } from "ConfigContext";
const getArmBaseUrl = (): string => {
const base = configContext.ARM_ENDPOINT;
return base.endsWith("/") ? base.slice(0, -1) : base;
};
const buildArmUrl = (path: string, apiVersion: string): string => {
if (!path || !apiVersion) {
return "";
}
return `${getArmBaseUrl()}${path}?api-version=${apiVersion}`;
};
export { buildArmUrl, getArmBaseUrl };

View File

@@ -0,0 +1,36 @@
import { DatabaseAccount } from "Contracts/DataModels";
import { userContext } from "UserContext";
import { buildArmUrl } from "Utils/arm/armUtils";
const apiVersion = "2025-04-15";
export type FetchAccountDetailsParams = {
subscriptionId: string;
resourceGroupName: string;
accountName: string;
};
const buildUrl = (params: FetchAccountDetailsParams): string => {
const { subscriptionId, resourceGroupName, accountName } = params;
return buildArmUrl(
`/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`,
apiVersion,
);
};
export async function fetchDatabaseAccount(subscriptionId: string, resourceGroupName: string, accountName: string) {
if (!userContext.authorizationToken) {
return Promise.reject("Authorization token is missing");
}
const headers = new Headers();
headers.append("Authorization", userContext.authorizationToken);
headers.append("Content-Type", "application/json");
const uri = buildUrl({ subscriptionId, resourceGroupName, accountName });
const response = await fetch(uri, { method: "GET", headers: headers });
if (!response.ok) {
throw new Error(`Error fetching database account: ${response.statusText}`);
}
const account: DatabaseAccount = await response.json();
return account;
}

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2023-11-15-preview/dataTransferService.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-05-01-preview/dataTransferService.json
*/
import { configContext } from "../../../../ConfigContext";
import { armRequest } from "../../request";
import * as Types from "./types";
const apiVersion = "2023-11-15-preview";
const apiVersion = "2025-05-01-preview";
/* Creates a Data Transfer Job. */
export async function create(
@@ -67,12 +67,24 @@ export async function cancel(
return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "POST", apiVersion });
}
/* Completes a Data Transfer Online Job. */
export async function complete(
subscriptionId: string,
resourceGroupName: string,
accountName: string,
jobName: string,
): Promise<Types.DataTransferJobGetResults> {
const path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/dataTransferJobs/${jobName}/complete`;
return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "POST", apiVersion });
}
/* Get a list of Data Transfer jobs. */
export async function listByDatabaseAccount(
subscriptionId: string,
resourceGroupName: string,
accountName: string,
signal?: AbortSignal,
): Promise<Types.DataTransferJobFeedResults> {
const path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/dataTransferJobs`;
return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "GET", apiVersion });
return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "GET", apiVersion, signal });
}

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2023-11-15-preview/dataTransferService.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-05-01-preview/dataTransferService.json
*/
/* Base class for all DataTransfer source/sink */
export interface DataTransferDataSourceSink {
/* undocumented */
component: "CosmosDBCassandra" | "CosmosDBMongo" | "CosmosDBSql" | "AzureBlobStorage";
component: "CosmosDBCassandra" | "CosmosDBMongo" | "CosmosDBMongoVCore" | "CosmosDBSql" | "AzureBlobStorage";
}
/* A base CosmosDB data source/sink */
@@ -34,6 +34,18 @@ export type CosmosMongoDataTransferDataSourceSink = BaseCosmosDataTransferDataSo
collectionName: string;
};
/* A CosmosDB Mongo vCore API data source/sink */
export type CosmosMongoVCoreDataTransferDataSourceSink = DataTransferDataSourceSink & {
/* undocumented */
databaseName: string;
/* undocumented */
collectionName: string;
/* undocumented */
hostName?: string;
/* undocumented */
connectionStringKeyVaultUri?: string;
};
/* A CosmosDB No Sql API data source/sink */
export type CosmosSqlDataTransferDataSourceSink = BaseCosmosDataTransferDataSourceSink & {
/* undocumented */

View File

@@ -0,0 +1,57 @@
import { DatabaseAccount } from "Contracts/DataModels";
import { configContext } from "../../ConfigContext";
import { fetchDatabaseAccount } from "./databaseAccountUtils";
import { armRequest } from "./request";
const apiVersion = "2025-04-15";
const updateIdentity = async (
subscriptionId: string,
resourceGroupName: string,
accountName: string,
body: object,
): Promise<DatabaseAccount | null> => {
const path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`;
const response: { status: string } = await armRequest({
host: configContext.ARM_ENDPOINT,
path,
method: "PATCH",
apiVersion,
body,
});
if (response.status === "Succeeded") {
const account = await fetchDatabaseAccount(subscriptionId, resourceGroupName, accountName);
return account;
}
return null;
};
const updateSystemIdentity = async (
subscriptionId: string,
resourceGroupName: string,
accountName: string,
): Promise<DatabaseAccount | null> => {
const body = {
identity: {
type: "SystemAssigned",
},
};
const updatedAccount = await updateIdentity(subscriptionId, resourceGroupName, accountName, body);
return updatedAccount;
};
const updateDefaultIdentity = async (
subscriptionId: string,
resourceGroupName: string,
accountName: string,
): Promise<DatabaseAccount | null> => {
const body = {
properties: {
defaultIdentity: "SystemAssignedIdentity",
},
};
const updatedAccount = await updateIdentity(subscriptionId, resourceGroupName, accountName, body);
return updatedAccount;
};
export { updateDefaultIdentity, updateSystemIdentity };

View File

@@ -48,6 +48,7 @@ interface Options {
queryParams?: ARMQueryParams;
contentType?: string;
customHeaders?: Record<string, string>;
signal?: AbortSignal;
}
export async function armRequestWithoutPolling<T>({
@@ -59,6 +60,7 @@ export async function armRequestWithoutPolling<T>({
queryParams,
contentType,
customHeaders,
signal,
}: Options): Promise<{ result: T; operationStatusUrl: string }> {
const url = new URL(path, host);
url.searchParams.append("api-version", configContext.armAPIVersion || apiVersion);
@@ -81,6 +83,7 @@ export async function armRequestWithoutPolling<T>({
method,
headers,
body: requestBody ? JSON.stringify(requestBody) : undefined,
signal,
});
if (!response.ok) {
@@ -116,6 +119,7 @@ export async function armRequest<T>({
queryParams,
contentType,
customHeaders,
signal,
}: Options): Promise<T> {
const armRequestResult = await armRequestWithoutPolling<T>({
host,
@@ -126,6 +130,7 @@ export async function armRequest<T>({
queryParams,
contentType,
customHeaders,
signal,
});
const operationStatusUrl = armRequestResult.operationStatusUrl;
if (operationStatusUrl) {