mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-20 01:11:25 +00:00
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:
115
src/Utils/arm/RbacUtils.ts
Normal file
115
src/Utils/arm/RbacUtils.ts
Normal 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
15
src/Utils/arm/armUtils.ts
Normal 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 };
|
||||
36
src/Utils/arm/databaseAccountUtils.ts
Normal file
36
src/Utils/arm/databaseAccountUtils.ts
Normal 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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
57
src/Utils/arm/identityUtils.ts
Normal file
57
src/Utils/arm/identityUtils.ts
Normal 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 };
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user