Compare commits

...
10 changed files with 302 additions and 44 deletions
@@ -22,6 +22,9 @@ import {
ConnectionTestListTablesRes, ConnectionTestListTablesRes,
GetTableMetadataRes, GetTableMetadataRes,
GetTableMetadataReq, GetTableMetadataReq,
RefreshCatalogReq,
RefreshCatalogRes,
RefreshCatalogStatusReq,
} from './dto/connection-test'; } from './dto/connection-test';
import { DadosferaLogger } from '@dadosfera/dadosfera-logs'; import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
import { Authenticated } from 'src/decorators/authentication.decorator'; import { Authenticated } from 'src/decorators/authentication.decorator';
@@ -121,4 +124,35 @@ export class ConnectionTestController {
user, user,
); );
} }
@Post('refresh-catalog')
@ApiOkResponse({ type: RefreshCatalogRes })
@HttpCode(HttpStatus.ACCEPTED)
async refreshCatalog(
@User() user: RequestUser,
@Body(new ValidationPipe()) body: RefreshCatalogReq,
) {
this.logger.info('/connection-test/refresh-catalog', {
user: user.user_id,
customer: user.customer_name,
connection: body.connection_id,
});
return this.connectionTestService.refreshCatalog(body, user);
}
@Post('refresh-catalog/status')
@ApiOkResponse({ type: RefreshCatalogRes })
@HttpCode(HttpStatus.OK)
async refreshCatalogStatus(
@User() user: RequestUser,
@Body(new ValidationPipe()) body: RefreshCatalogStatusReq,
) {
this.logger.info('/connection-test/refresh-catalog/status', {
user: user.user_id,
customer: user.customer_name,
connection: body.connection_id,
session: body.session_id,
});
return this.connectionTestService.refreshCatalogStatus(body, user);
}
} }
@@ -6,6 +6,7 @@ import { ClientsModule } from '@nestjs/microservices';
import { ConnectionTestClientConfiguration } from './connection-test-client.config'; import { ConnectionTestClientConfiguration } from './connection-test-client.config';
import { ConnectionModule } from '../connection/connection.module'; import { ConnectionModule } from '../connection/connection.module';
import { ConnectionsApiModule } from '../connections-api/connections-api.module'; import { ConnectionsApiModule } from '../connections-api/connections-api.module';
import { PlatformApiModule } from '../platform-api/platform-api.module';
const client = new ConnectionTestClientConfiguration(); const client = new ConnectionTestClientConfiguration();
@Module({ @Module({
controllers: [ConnectionTestController], controllers: [ConnectionTestController],
@@ -14,6 +15,7 @@ const client = new ConnectionTestClientConfiguration();
ClientsModule.register([client.providerOptions]), ClientsModule.register([client.providerOptions]),
ConnectionModule, ConnectionModule,
ConnectionsApiModule, ConnectionsApiModule,
PlatformApiModule,
], ],
}) })
export class ConnectionTestModule {} export class ConnectionTestModule {}
@@ -16,6 +16,7 @@ describe('ConnectionTestService catalog cache', () => {
const grpcClient = { getService: jest.fn().mockReturnValue({}) }; const grpcClient = { getService: jest.fn().mockReturnValue({}) };
const connectionsService = {}; const connectionsService = {};
const connectionsApiService = { proxy: jest.fn() }; const connectionsApiService = { proxy: jest.fn() };
const platformApiService = { proxy: jest.fn() };
let service: ConnectionTestService; let service: ConnectionTestService;
beforeEach(() => { beforeEach(() => {
@@ -24,6 +25,7 @@ describe('ConnectionTestService catalog cache', () => {
grpcClient as any, grpcClient as any,
connectionsService as any, connectionsService as any,
connectionsApiService as any, connectionsApiService as any,
platformApiService as any,
); );
}); });
@@ -106,4 +108,98 @@ describe('ConnectionTestService catalog cache', () => {
user, user,
); );
}); });
it('submits a catalog refresh without holding the request open', async () => {
platformApiService.proxy.mockResolvedValue({
session_id: 'session-id',
date: '20260731',
});
await expect(
service.refreshCatalog(
{ connection_id: 'config-id', plugin: 'postgresql' },
user,
),
).resolves.toEqual({
operation_result: true,
status: 'PENDING',
session_id: 'session-id',
date: '20260731',
});
expect(platformApiService.proxy).toHaveBeenCalledWith(
'POST',
'/connection_test',
user,
{
customer_id: user.customer_name,
plugin: 'postgresql',
task: {
task_type: 'refresh_catalog',
connection: {
provider: 'connection_manager',
config_id: 'config-id',
},
},
},
);
});
it('keeps polling without changing the catalog pointer while pending', async () => {
platformApiService.proxy.mockResolvedValue({ status: 'PENDING' });
await expect(
service.refreshCatalogStatus(
{
connection_id: 'config-id',
plugin: 'postgresql',
session_id: 'session-id',
date: '20260731',
},
user,
),
).resolves.toEqual({
operation_result: false,
status: 'PENDING',
session_id: 'session-id',
date: '20260731',
});
expect(connectionsApiService.proxy).not.toHaveBeenCalled();
});
it('publishes the catalog pointer after the refresh finishes', async () => {
platformApiService.proxy.mockResolvedValue({ status: 'DONE' });
connectionsApiService.proxy.mockResolvedValue({
last_catalog_refresh_status: 'SUCCESS',
});
await expect(
service.refreshCatalogStatus(
{
connection_id: 'config/id',
plugin: 'postgresql',
session_id: 'session-id',
date: '20260731',
},
user,
),
).resolves.toEqual({
operation_result: true,
status: 'DONE',
session_id: 'session-id',
date: '20260731',
});
expect(connectionsApiService.proxy).toHaveBeenCalledWith(
'PUT',
'/connection_config/config%2Fid/catalog_metadata',
user,
{
last_catalog_refresh_status: 'SUCCESS',
last_catalog_connection_test_date: '20260731',
last_catalog_connection_test_session_id: 'session-id',
},
);
});
}); });
@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices'; import { ClientGrpc } from '@nestjs/microservices';
import { ConnectionTest } from '@dadosfera/protospack-v2'; import { ConnectionTest } from '@dadosfera/protospack-v2';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
@@ -13,6 +13,9 @@ import {
ConnectionTestPingRes, ConnectionTestPingRes,
GetTableMetadataReq, GetTableMetadataReq,
GetTableMetadataRes, GetTableMetadataRes,
RefreshCatalogReq,
RefreshCatalogRes,
RefreshCatalogStatusReq,
} from './dto/connection-test'; } from './dto/connection-test';
import { ConnectionClientService } from '../connection/client.service'; import { ConnectionClientService } from '../connection/client.service';
import { import {
@@ -22,6 +25,7 @@ import {
import { RequestUser } from 'src/decorators/user.decorator'; import { RequestUser } from 'src/decorators/user.decorator';
import { PackTheMetadata } from 'src/utils/PackTheMetadata'; import { PackTheMetadata } from 'src/utils/PackTheMetadata';
import { ConnectionsApiService } from '../connections-api/connections-api.service'; import { ConnectionsApiService } from '../connections-api/connections-api.service';
import { PlatformApiService } from '../platform-api/platform-api.service';
@Injectable() @Injectable()
export class ConnectionTestService { export class ConnectionTestService {
@@ -30,6 +34,7 @@ export class ConnectionTestService {
@Inject('ConnectionTestGrpcClient') private readonly grpcClient: ClientGrpc, @Inject('ConnectionTestGrpcClient') private readonly grpcClient: ClientGrpc,
private connectionsService: ConnectionClientService, private connectionsService: ConnectionClientService,
private connectionsApiService: ConnectionsApiService, private connectionsApiService: ConnectionsApiService,
private platformApiService: PlatformApiService,
) { ) {
this.connectionTestReadClient = this.connectionTestReadClient =
grpcClient.getService<ConnectionTest.ReadService.ConnectionTestReadServices>( grpcClient.getService<ConnectionTest.ReadService.ConnectionTestReadServices>(
@@ -204,4 +209,82 @@ export class ConnectionTestService {
); );
return { operation_result: true, tables_metadata }; return { operation_result: true, tables_metadata };
} }
async refreshCatalog(
body: RefreshCatalogReq,
user: RequestUser,
): Promise<RefreshCatalogRes> {
const task = await this.platformApiService.proxy(
'POST',
'/connection_test',
user,
{
customer_id: user.customer_name,
plugin: body.plugin,
task: {
task_type: 'refresh_catalog',
connection: {
provider: 'connection_manager',
config_id: body.connection_id,
},
},
},
);
if (!task.session_id || !task.date) {
throw new HttpException(
'Platform API did not return a catalog refresh task identifier',
HttpStatus.BAD_GATEWAY,
);
}
return {
operation_result: true,
status: 'PENDING',
session_id: task.session_id,
date: task.date,
};
}
async refreshCatalogStatus(
body: RefreshCatalogStatusReq,
user: RequestUser,
): Promise<RefreshCatalogRes> {
const result = await this.platformApiService.proxy(
'POST',
'/connection_test/status',
user,
{
session_id: body.session_id,
date: body.date,
},
);
if (result.status === 'DONE') {
await this.connectionsApiService.proxy(
'PUT',
`/connection_config/${encodeURIComponent(
body.connection_id,
)}/catalog_metadata`,
user,
{
last_catalog_refresh_status: 'SUCCESS',
last_catalog_connection_test_date: body.date,
last_catalog_connection_test_session_id: body.session_id,
},
);
} else if (result.status === 'ERROR' || result.status === 'EXPIRED') {
throw new HttpException(
`Catalog refresh finished with status ${result.status}`,
HttpStatus.BAD_GATEWAY,
);
}
return {
operation_result: result.status === 'DONE',
status: result.status,
session_id: body.session_id,
date: body.date,
};
}
} }
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import { IsString, IsOptional } from 'class-validator'; import { IsIn, IsString, IsOptional } from 'class-validator';
import { DatabaseConnectionPropertiesDto } from 'src/modules/connection/dtos/connection'; import { DatabaseConnectionPropertiesDto } from 'src/modules/connection/dtos/connection';
import { CreateConnectionDto } from 'src/modules/connection/dtos/connection'; import { CreateConnectionDto } from 'src/modules/connection/dtos/connection';
export class ColumnDto { export class ColumnDto {
@@ -133,3 +133,37 @@ export class GetTableMetadataRes {
@ApiProperty({ type: [TableMetadataDto] }) @ApiProperty({ type: [TableMetadataDto] })
tables_metadata: TableMetadataDto[]; tables_metadata: TableMetadataDto[];
} }
export class RefreshCatalogReq {
@ApiProperty()
@IsString()
connection_id: string;
@ApiProperty({ enum: ['oracle', 'mysql', 'postgresql', 'sqlserver'] })
@IsIn(['oracle', 'mysql', 'postgresql', 'sqlserver'])
plugin: string;
}
export class RefreshCatalogStatusReq extends RefreshCatalogReq {
@ApiProperty()
@IsString()
session_id: string;
@ApiProperty()
@IsString()
date: string;
}
export class RefreshCatalogRes {
@ApiProperty()
operation_result: boolean;
@ApiProperty()
status: string;
@ApiProperty()
session_id: string;
@ApiProperty()
date: string;
}
@@ -27,9 +27,19 @@ export class ConnectionsApiService {
method: string, method: string,
path: string, path: string,
user: RequestUser, user: RequestUser,
body?: any,
query?: Record<string, string>,
): Promise<any> { ): Promise<any> {
const baseUrl = CONNECTIONS_API_CONFIG.getUrl(); const baseUrl = CONNECTIONS_API_CONFIG.getUrl();
const url = new URL(`${baseUrl}${path}`); const url = new URL(`${baseUrl}${path}`);
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
});
}
const headers: Record<string, string> = { const headers: Record<string, string> = {
host: url.hostname, host: url.hostname,
'content-type': 'application/json', 'content-type': 'application/json',
@@ -45,8 +55,9 @@ export class ConnectionsApiService {
protocol: url.protocol, protocol: url.protocol,
hostname: url.hostname, hostname: url.hostname,
port: url.port ? parseInt(url.port, 10) : undefined, port: url.port ? parseInt(url.port, 10) : undefined,
path: url.pathname, path: url.pathname + url.search,
headers, headers,
body: body ? JSON.stringify(body) : undefined,
}; };
try { try {
@@ -55,6 +66,7 @@ export class ConnectionsApiService {
method: method as Method, method: method as Method,
url: url.href, url: url.href,
headers: signedRequest.headers as Record<string, string>, headers: signedRequest.headers as Record<string, string>,
data: body,
timeout: CONNECTIONS_API_CONFIG.timeout, timeout: CONNECTIONS_API_CONFIG.timeout,
validateStatus: () => true, validateStatus: () => true,
}); });
+5 -4
View File
@@ -23,6 +23,7 @@ import {
} from '@dadosfera/protospack-v2/dist/lib/Input/interfaces/messages'; } from '@dadosfera/protospack-v2/dist/lib/Input/interfaces/messages';
import { Info } from '@dadosfera/protospack-v2/dist/lib/Input/interfaces/entities'; import { Info } from '@dadosfera/protospack-v2/dist/lib/Input/interfaces/entities';
import { CreateInputReq } from './dtos/input.model'; import { CreateInputReq } from './dtos/input.model';
import { Metadata } from '@grpc/grpc-js';
@Injectable() @Injectable()
@@ -73,10 +74,10 @@ export class InputsService {
objectCamelToSnake(createInputResponse); objectCamelToSnake(createInputResponse);
return createInputResponse; return createInputResponse;
}, },
update: async (updateInputDTO: UpdateInputRequest): Promise<InputUpdateResponse> => { update: async (updateInputDTO: UpdateInputRequest, metadata: Metadata): Promise<InputUpdateResponse> => {
this.logger.info('InputClientService - Update' + JSON.stringify(updateInputDTO)); this.logger.info('InputClientService - Update' + JSON.stringify(updateInputDTO));
const updateInputResponse = await lastValueFrom( const updateInputResponse = await lastValueFrom(
this.inputWriteService.InputUpdate(updateInputDTO), this.inputWriteService.InputUpdate(updateInputDTO, metadata),
); );
return updateInputResponse; return updateInputResponse;
@@ -206,7 +207,7 @@ export class InputsService {
return findOneInputResponse; return findOneInputResponse;
} }
async update(id: string, data, info: Info) { async update(id: string, data, info: Info, metadata?: Metadata) {
// this.validateCron({ ...data, info }); // this.validateCron({ ...data, info });
try { try {
const { const {
@@ -217,7 +218,7 @@ export class InputsService {
id, id,
...data, ...data,
info, info,
}); }, metadata);
const updateInputResponse = this.adjustInputPayload( const updateInputResponse = this.adjustInputPayload(
input, input,
@@ -15,7 +15,6 @@ import {
HttpException, HttpException,
BadRequestException, BadRequestException,
UseGuards, UseGuards,
Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiCreatedResponse, ApiCreatedResponse,
@@ -318,7 +317,6 @@ export class PipelinesController {
) { ) {
this.logger.info('PipelinesController - update', { user }); this.logger.info('PipelinesController - update', { user });
const { customer_id, customer_name, user_id, username } = user;
const info: Info = { const info: Info = {
user_id: user.user_id, user_id: user.user_id,
customer: user.customer_name, customer: user.customer_name,
@@ -326,13 +324,7 @@ export class PipelinesController {
pipeline_id: pipelineId pipeline_id: pipelineId
}; };
const metadata = PackTheMetadata({ const metadata = PackTheMetadata(user);
customer_id,
customer_name,
user_id,
username,
language,
});
const response = await this.pipelinesClientService.updatePipelineInput( const response = await this.pipelinesClientService.updatePipelineInput(
pipelineId, pipelineId,
+30 -27
View File
@@ -383,7 +383,8 @@ export class PipelinesService implements OnModuleInit {
const updateInputResponse = await this.inputsService.update( const updateInputResponse = await this.inputsService.update(
inputId, inputId,
updateInputDTO, updateInputDTO,
info info,
metadata
); );
const inputRollback = () => { const inputRollback = () => {
@@ -404,34 +405,36 @@ export class PipelinesService implements OnModuleInit {
const nimbusUpdates = updateInputResponse?.tablesUpdate || []; const nimbusUpdates = updateInputResponse?.tablesUpdate || [];
nimbusUpdates.forEach(update => { if (user.customer_modules.includes('catalog')) {
const nimbusRollback = () => { nimbusUpdates.forEach(update => {
return this.nimbusService.renameTable( const nimbusRollback = () => {
info.customer, return this.nimbusService.renameTable(
update.database, info.customer,
{ update.database,
table_name: update.table_name, {
table_schema: update.table_schema table_name: update.table_name,
}, table_schema: update.table_schema
{ },
table_name: update.old_table_name, {
table_schema: update.old_table_schema table_name: update.old_table_name,
} table_schema: update.old_table_schema
); }
} );
rollback.push(nimbusRollback); }
}); rollback.push(nimbusRollback);
});
try { try {
await this.updateNimbus(info.customer, nimbusUpdates); await this.updateNimbus(info.customer, nimbusUpdates);
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error);
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
this.logger.error(JSON.stringify(error.response.data)); this.logger.error(JSON.stringify(error.response.data));
} }
await this.executeRenameRollback(rollback); await this.executeRenameRollback(rollback);
throw new Error("Error Nimbus updating tables"); throw new Error("Error Nimbus updating tables");
}
} }
try { try {
+1
View File
@@ -9,6 +9,7 @@ interface IMetadata {
details?: string; details?: string;
sensitive?: string; sensitive?: string;
roles?: string[]; roles?: string[];
customer_modules?: string[];
is_data_manager?: boolean; is_data_manager?: boolean;
access_token?: string; access_token?: string;
host?: string; host?: string;