feat: add auditor to coreServices
Signed-off-by: Paul Schultz <pschultz@pobox.com> add auditor service Signed-off-by: Paul Schultz <pschultz@pobox.com> create eventAuditor and rootEventAuditor mockServices Signed-off-by: Paul Schultz <pschultz@pobox.com> run api report Signed-off-by: Paul Schultz <pschultz@pobox.com> rerun api report Signed-off-by: Paul Schultz <pschultz@pobox.com> apply requested changes Signed-off-by: Paul Schultz <pschultz@pobox.com> remove rootAuditor Signed-off-by: Paul Schultz <pschultz@pobox.com> run build:api-reports Signed-off-by: Paul Schultz <pschultz@pobox.com> misc fixes Signed-off-by: Paul Schultz <pschultz@pobox.com> misc fixes Signed-off-by: Paul Schultz <pschultz@pobox.com> add dynamic plugin auditor service factory Signed-off-by: Paul Schultz <pschultz@pobox.com> fix api report Signed-off-by: Paul Schultz <pschultz@pobox.com> rerun api-reports Signed-off-by: Paul Schultz <pschultz@pobox.com> add comments to auditor service Signed-off-by: Paul Schultz <pschultz@pobox.com> update api report Signed-off-by: Paul Schultz <pschultz@pobox.com> update api report Signed-off-by: Paul Schultz <pschultz@pobox.com> run api-reports Signed-off-by: Paul Schultz <pschultz@pobox.com> apply requested changes Signed-off-by: Paul Schultz <pschultz@pobox.com> run api-reports Signed-off-by: Paul Schultz <pschultz@pobox.com> fix tests Signed-off-by: Paul Schultz <pschultz@pobox.com> add auditor to the catalog plugin Signed-off-by: Paul Schultz <pschultz@pobox.com> fix tsc issues Signed-off-by: Paul Schultz <pschultz@pobox.com> update auditor service Signed-off-by: Paul Schultz <pschultz@pobox.com> rename 'args' to 'options' Signed-off-by: Paul Schultz <pschultz@pobox.com> update auditor event shape Signed-off-by: Paul Schultz <pschultz@pobox.com> add error option in auditor event shape Signed-off-by: Paul Schultz <pschultz@pobox.com> rename events Signed-off-by: Paul Schultz <pschultz@pobox.com> demo new api? Signed-off-by: Paul Schultz <pschultz@pobox.com> update service impl Signed-off-by: Paul Schultz <pschultz@pobox.com> update auditor api Signed-off-by: Paul Schultz <pschultz@pobox.com> update auditor api Signed-off-by: Paul Schultz <pschultz@pobox.com> add the auditor service to the scaffolder Signed-off-by: Paul Schultz <pschultz@pobox.com> update tests Signed-off-by: Paul Schultz <pschultz@pobox.com> run api-reports Signed-off-by: Paul Schultz <pschultz@pobox.com> revert config secret enumerator changes Signed-off-by: Paul Schultz <pschultz@pobox.com> clean up auditor api Signed-off-by: Paul Schultz <pschultz@pobox.com> add documentation Signed-off-by: Paul Schultz <pschultz@pobox.com> update auditor Signed-off-by: Paul Schultz <pschultz@pobox.com> add auditor to sign in/out Signed-off-by: Paul Schultz <pschultz@pobox.com> remove dynamic auditor Signed-off-by: Paul Schultz <pschultz@pobox.com> add winston dep back Signed-off-by: Paul Schultz <pschultz@pobox.com> run api-reports Signed-off-by: Paul Schultz <pschultz@pobox.com> run prettier Signed-off-by: Paul Schultz <pschultz@pobox.com> add the auditor service as a default service factory Signed-off-by: Paul Schultz <pschultz@pobox.com> fix eslint errror Signed-off-by: Paul Schultz <pschultz@pobox.com> revert auth changes Signed-off-by: Paul Schultz <pschultz@pobox.com>
This commit is contained in:
@@ -87,6 +87,25 @@ yarn start
|
||||
This will launch both frontend and backend in the same window, populated with
|
||||
some example entities.
|
||||
|
||||
## Audit Events
|
||||
|
||||
- **`ancestry-fetch`**: Tracks `GET` requests to the `/entities/by-name/:kind/:namespace/:name/ancestry` endpoint which return the ancestry of an entity.
|
||||
- **`batch-fetch`**: Tracks `POST` requests to the `/entities/by-refs` endpoint which return a batch of entities.
|
||||
- **`delete`**: Tracks `DELETE` requests to the `/entities/by-uid/:uid` endpoint which delete an entity. Note: this will not be a permanent deletion and the entity will be restored if the parent location is still present in the catalog.
|
||||
- **`facet-fetch`**: Tracks `GET` requests to the `/entity-facets` endpoint which return the facets of an entity.
|
||||
- **`fetch`**: Tracks `GET` requests to the `/entities` endpoint which returns a list of entities.
|
||||
- **`fetch-by-name`**: Tracks `GET` requests to the `/entities/by-name/:kind/:namespace/:name` endpoint which return an entity matching the specified entity ref.
|
||||
- **`fetch-by-uid`**: Tracks `GET` requests to the `/entities/by-uid/:uid` endpoint which return an entity matching the specified entity uid.
|
||||
- **`fetch-by-query`**: Tracks `GET` requests to the `/entities/by-query` endpoint which returns a list of entities matching the specified query.
|
||||
- **`refresh`**: Tracks `POST` requests to the `/entities/refresh` endpoint which schedules the specified entity to be refreshed.
|
||||
- **`validate`**: Tracks `POST` requests to the `/entities/validate` endpoint which validates the specified entity.
|
||||
- **`location-analyze`**: Tracks `POST` requests to the `/locations/analyze` endpoint which analyzes the specified location.
|
||||
- **`location-create`**: Tracks `POST` requests to the `/locations` endpoint which creates a location.
|
||||
- **`location-delete`**: Tracks `DELETE` requests to the `/locations/:id` endpoint which deletes a location as well as all child entities associated with it.
|
||||
- **`location-fetch`**: Tracks `GET` requests to the `/locations` endpoint which returns a list of locations.
|
||||
- **`location-fetch-by-entity-ref`**: Tracks `GET` requests to the `/locations/by-entity` endpoint which returns a list of locations associated with the specified entity ref.
|
||||
- **`location-fetch-by-id`**: Tracks `GET` requests to the `/locations/:id` endpoint which returns a location matching the specified location id.
|
||||
|
||||
## Links
|
||||
|
||||
- [catalog](https://github.com/backstage/backstage/tree/master/plugins/catalog)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AnalyzeLocationGenerateEntity as AnalyzeLocationGenerateEntity_2 } from
|
||||
import { AnalyzeLocationRequest as AnalyzeLocationRequest_2 } from '@backstage/plugin-catalog-common';
|
||||
import { AnalyzeLocationResponse as AnalyzeLocationResponse_2 } from '@backstage/plugin-catalog-common';
|
||||
import { AnalyzeOptions as AnalyzeOptions_2 } from '@backstage/plugin-catalog-node';
|
||||
import { AuditorService } from '@backstage/backend-plugin-api';
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
@@ -206,6 +207,7 @@ export type CatalogEnvironment = {
|
||||
discovery?: DiscoveryService;
|
||||
auth?: AuthService;
|
||||
httpAuth?: HttpAuthService;
|
||||
auditor?: AuditorService;
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -36,15 +36,61 @@ import { createHash } from 'crypto';
|
||||
import { Router } from 'express';
|
||||
import lodash, { keyBy } from 'lodash';
|
||||
|
||||
import {
|
||||
AuditorService,
|
||||
AuthService,
|
||||
DatabaseService,
|
||||
DiscoveryService,
|
||||
HttpAuthService,
|
||||
LoggerService,
|
||||
PermissionsRegistryService,
|
||||
PermissionsService,
|
||||
RootConfigService,
|
||||
SchedulerService,
|
||||
UrlReaderService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Config, readDurationFromConfig } from '@backstage/config';
|
||||
import {
|
||||
catalogPermissions,
|
||||
RESOURCE_TYPE_CATALOG_ENTITY,
|
||||
} from '@backstage/plugin-catalog-common/alpha';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
CatalogProcessorParser,
|
||||
EntitiesSearchFilter,
|
||||
EntityProvider,
|
||||
PlaceholderResolver,
|
||||
LocationAnalyzer,
|
||||
PlaceholderResolver,
|
||||
ScmLocationAnalyzer,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
import { EventBroker, EventsService } from '@backstage/plugin-events-node';
|
||||
import {
|
||||
Permission,
|
||||
PermissionAuthorizer,
|
||||
PermissionRuleParams,
|
||||
toPermissionEvaluator,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
createConditionTransformer,
|
||||
createPermissionIntegrationRouter,
|
||||
PermissionRule,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
import { durationToMilliseconds } from '@backstage/types';
|
||||
import { DefaultCatalogDatabase } from '../database/DefaultCatalogDatabase';
|
||||
import { DefaultProcessingDatabase } from '../database/DefaultProcessingDatabase';
|
||||
import { DefaultProviderDatabase } from '../database/DefaultProviderDatabase';
|
||||
import { applyDatabaseMigrations } from '../database/migrations';
|
||||
import { DefaultCatalogRulesEnforcer } from '../ingestion/CatalogRules';
|
||||
import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer';
|
||||
import { permissionRules as catalogPermissionRules } from '../permissions/rules';
|
||||
import {
|
||||
CatalogProcessingEngine,
|
||||
createRandomProcessingInterval,
|
||||
ProcessingIntervalFunction,
|
||||
} from '../processing';
|
||||
import { connectEntityProviders } from '../processing/connectEntityProviders';
|
||||
import { DefaultCatalogProcessingEngine } from '../processing/DefaultCatalogProcessingEngine';
|
||||
import { DefaultCatalogProcessingOrchestrator } from '../processing/DefaultCatalogProcessingOrchestrator';
|
||||
import {
|
||||
AnnotateLocationEntityProcessor,
|
||||
BuiltinKindsEntityProcessor,
|
||||
@@ -53,69 +99,24 @@ import {
|
||||
PlaceholderProcessor,
|
||||
UrlReaderProcessor,
|
||||
} from '../processors';
|
||||
import { ConfigLocationEntityProvider } from '../providers/ConfigLocationEntityProvider';
|
||||
import { DefaultLocationStore } from '../providers/DefaultLocationStore';
|
||||
import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer';
|
||||
import { AuthorizedLocationAnalyzer } from './AuthorizedLocationAnalyzer';
|
||||
import {
|
||||
jsonPlaceholderResolver,
|
||||
textPlaceholderResolver,
|
||||
yamlPlaceholderResolver,
|
||||
} from '../processors/PlaceholderProcessor';
|
||||
import { defaultEntityDataParser } from '../util/parse';
|
||||
import {
|
||||
CatalogProcessingEngine,
|
||||
createRandomProcessingInterval,
|
||||
ProcessingIntervalFunction,
|
||||
} from '../processing';
|
||||
import { DefaultProcessingDatabase } from '../database/DefaultProcessingDatabase';
|
||||
import { applyDatabaseMigrations } from '../database/migrations';
|
||||
import { DefaultCatalogProcessingEngine } from '../processing/DefaultCatalogProcessingEngine';
|
||||
import { DefaultLocationService } from './DefaultLocationService';
|
||||
import { DefaultEntitiesCatalog } from './DefaultEntitiesCatalog';
|
||||
import { DefaultCatalogProcessingOrchestrator } from '../processing/DefaultCatalogProcessingOrchestrator';
|
||||
import { ConfigLocationEntityProvider } from '../providers/ConfigLocationEntityProvider';
|
||||
import { DefaultLocationStore } from '../providers/DefaultLocationStore';
|
||||
import { DefaultStitcher } from '../stitching/DefaultStitcher';
|
||||
import { createRouter } from './createRouter';
|
||||
import { DefaultRefreshService } from './DefaultRefreshService';
|
||||
import { AuthorizedRefreshService } from './AuthorizedRefreshService';
|
||||
import { DefaultCatalogRulesEnforcer } from '../ingestion/CatalogRules';
|
||||
import { Config, readDurationFromConfig } from '@backstage/config';
|
||||
import { connectEntityProviders } from '../processing/connectEntityProviders';
|
||||
import {
|
||||
Permission,
|
||||
PermissionAuthorizer,
|
||||
PermissionRuleParams,
|
||||
toPermissionEvaluator,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { permissionRules as catalogPermissionRules } from '../permissions/rules';
|
||||
import {
|
||||
createConditionTransformer,
|
||||
createPermissionIntegrationRouter,
|
||||
PermissionRule,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
import { defaultEntityDataParser } from '../util/parse';
|
||||
import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog';
|
||||
import { basicEntityFilter } from './request';
|
||||
import {
|
||||
catalogPermissions,
|
||||
RESOURCE_TYPE_CATALOG_ENTITY,
|
||||
} from '@backstage/plugin-catalog-common/alpha';
|
||||
import { AuthorizedLocationAnalyzer } from './AuthorizedLocationAnalyzer';
|
||||
import { AuthorizedLocationService } from './AuthorizedLocationService';
|
||||
import { DefaultProviderDatabase } from '../database/DefaultProviderDatabase';
|
||||
import { DefaultCatalogDatabase } from '../database/DefaultCatalogDatabase';
|
||||
import { EventBroker, EventsService } from '@backstage/plugin-events-node';
|
||||
import { durationToMilliseconds } from '@backstage/types';
|
||||
import {
|
||||
AuthService,
|
||||
DatabaseService,
|
||||
DiscoveryService,
|
||||
HttpAuthService,
|
||||
LoggerService,
|
||||
PermissionsService,
|
||||
RootConfigService,
|
||||
UrlReaderService,
|
||||
SchedulerService,
|
||||
PermissionsRegistryService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { AuthorizedRefreshService } from './AuthorizedRefreshService';
|
||||
import { createRouter } from './createRouter';
|
||||
import { DefaultEntitiesCatalog } from './DefaultEntitiesCatalog';
|
||||
import { DefaultLocationService } from './DefaultLocationService';
|
||||
import { DefaultRefreshService } from './DefaultRefreshService';
|
||||
import { basicEntityFilter } from './request';
|
||||
import { entitiesResponseToObjects } from './response';
|
||||
|
||||
/**
|
||||
@@ -136,12 +137,14 @@ export type CatalogEnvironment = {
|
||||
database: DatabaseService;
|
||||
config: RootConfigService;
|
||||
reader: UrlReaderService;
|
||||
// TODO: Require all services once `backend-legacy` is removed
|
||||
permissions: PermissionsService | PermissionAuthorizer;
|
||||
permissionsRegistry?: PermissionsRegistryService;
|
||||
scheduler?: SchedulerService;
|
||||
discovery?: DiscoveryService;
|
||||
auth?: AuthService;
|
||||
httpAuth?: HttpAuthService;
|
||||
auditor?: AuditorService;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -482,6 +485,7 @@ export class CatalogBuilder {
|
||||
scheduler,
|
||||
permissionsRegistry,
|
||||
discovery = HostDiscovery.fromConfig(config),
|
||||
auditor,
|
||||
} = this.env;
|
||||
|
||||
const { auth, httpAuth } = createLegacyAuthAdapters({
|
||||
@@ -653,6 +657,7 @@ export class CatalogBuilder {
|
||||
auth,
|
||||
httpAuth,
|
||||
permissionsService,
|
||||
auditor,
|
||||
disableRelationsCompatibility,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,20 +17,8 @@ import {
|
||||
coreServices,
|
||||
createBackendPlugin,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { eventsServiceRef } from '@backstage/plugin-events-node';
|
||||
import { Entity, Validators } from '@backstage/catalog-model';
|
||||
import { CatalogBuilder, CatalogPermissionRuleInput } from './CatalogBuilder';
|
||||
import {
|
||||
catalogAnalysisExtensionPoint,
|
||||
CatalogModelExtensionPoint,
|
||||
catalogModelExtensionPoint,
|
||||
CatalogPermissionExtensionPoint,
|
||||
catalogPermissionExtensionPoint,
|
||||
CatalogProcessingExtensionPoint,
|
||||
catalogProcessingExtensionPoint,
|
||||
CatalogLocationsExtensionPoint,
|
||||
catalogLocationsExtensionPoint,
|
||||
} from '@backstage/plugin-catalog-node/alpha';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
CatalogProcessorParser,
|
||||
@@ -39,9 +27,21 @@ import {
|
||||
PlaceholderResolver,
|
||||
ScmLocationAnalyzer,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
import { merge } from 'lodash';
|
||||
import {
|
||||
catalogAnalysisExtensionPoint,
|
||||
CatalogLocationsExtensionPoint,
|
||||
catalogLocationsExtensionPoint,
|
||||
CatalogModelExtensionPoint,
|
||||
catalogModelExtensionPoint,
|
||||
CatalogPermissionExtensionPoint,
|
||||
catalogPermissionExtensionPoint,
|
||||
CatalogProcessingExtensionPoint,
|
||||
catalogProcessingExtensionPoint,
|
||||
} from '@backstage/plugin-catalog-node/alpha';
|
||||
import { eventsServiceRef } from '@backstage/plugin-events-node';
|
||||
import { Permission } from '@backstage/plugin-permission-common';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
import { merge } from 'lodash';
|
||||
import { CatalogBuilder, CatalogPermissionRuleInput } from './CatalogBuilder';
|
||||
|
||||
class CatalogLocationsExtensionPointImpl
|
||||
implements CatalogLocationsExtensionPoint
|
||||
@@ -235,6 +235,7 @@ export const catalogPlugin = createBackendPlugin({
|
||||
discovery: coreServices.discovery,
|
||||
auth: coreServices.auth,
|
||||
httpAuth: coreServices.httpAuth,
|
||||
auditor: coreServices.auditor,
|
||||
events: eventsServiceRef,
|
||||
},
|
||||
async init({
|
||||
@@ -250,6 +251,7 @@ export const catalogPlugin = createBackendPlugin({
|
||||
discovery,
|
||||
auth,
|
||||
httpAuth,
|
||||
auditor,
|
||||
events,
|
||||
}) {
|
||||
const builder = await CatalogBuilder.create({
|
||||
@@ -263,6 +265,7 @@ export const catalogPlugin = createBackendPlugin({
|
||||
discovery,
|
||||
auth,
|
||||
httpAuth,
|
||||
auditor,
|
||||
});
|
||||
|
||||
builder.setEventBroker(events);
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter';
|
||||
import { wrapServer } from '@backstage/backend-openapi-utils';
|
||||
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
|
||||
import type { Location } from '@backstage/catalog-client';
|
||||
import {
|
||||
ANNOTATION_LOCATION,
|
||||
@@ -23,26 +24,25 @@ import {
|
||||
Entity,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { Cursor, EntitiesCatalog } from '../catalog/types';
|
||||
import { LocationInput, LocationService, RefreshService } from './types';
|
||||
import { basicEntityFilter } from './request';
|
||||
import { createRouter } from './createRouter';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common/alpha';
|
||||
import { LocationAnalyzer } from '@backstage/plugin-catalog-node';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
createPermissionIntegrationRouter,
|
||||
createPermissionRule,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common/alpha';
|
||||
import { CatalogProcessingOrchestrator } from '../processing/types';
|
||||
import { z } from 'zod';
|
||||
import { decodeCursor, encodeCursor } from './util';
|
||||
import { wrapServer } from '@backstage/backend-openapi-utils';
|
||||
import express from 'express';
|
||||
import { Server } from 'http';
|
||||
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
|
||||
import { LocationAnalyzer } from '@backstage/plugin-catalog-node';
|
||||
import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter';
|
||||
import request from 'supertest';
|
||||
import { z } from 'zod';
|
||||
import { Cursor, EntitiesCatalog } from '../catalog/types';
|
||||
import { CatalogProcessingOrchestrator } from '../processing/types';
|
||||
import { createRouter } from './createRouter';
|
||||
import { basicEntityFilter } from './request';
|
||||
import { LocationInput, LocationService, RefreshService } from './types';
|
||||
import { decodeCursor, encodeCursor } from './util';
|
||||
|
||||
const middleware = MiddlewareFactory.create({
|
||||
logger: mockServices.logger.mock(),
|
||||
@@ -91,6 +91,7 @@ describe('createRouter readonly disabled', () => {
|
||||
httpAuth: mockServices.httpAuth(),
|
||||
locationAnalyzer,
|
||||
permissionsService,
|
||||
auditor: mockServices.auditor.mock(),
|
||||
});
|
||||
router.use(middleware.error());
|
||||
app = await wrapServer(express().use(router));
|
||||
@@ -971,6 +972,7 @@ describe('createRouter readonly and raw json enabled', () => {
|
||||
auth: mockServices.auth(),
|
||||
httpAuth: mockServices.httpAuth(),
|
||||
permissionsService,
|
||||
auditor: mockServices.auditor.mock(),
|
||||
});
|
||||
router.use(middleware.error());
|
||||
app = express().use(router);
|
||||
@@ -1190,6 +1192,7 @@ describe('NextRouter permissioning', () => {
|
||||
auth: mockServices.auth(),
|
||||
httpAuth: mockServices.httpAuth(),
|
||||
permissionsService,
|
||||
auditor: mockServices.auditor.mock(),
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AuditorService,
|
||||
AuthService,
|
||||
HttpAuthService,
|
||||
LoggerService,
|
||||
PermissionsService,
|
||||
SchedulerService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
ANNOTATION_LOCATION,
|
||||
ANNOTATION_ORIGIN_LOCATION,
|
||||
@@ -23,12 +31,15 @@ import {
|
||||
} from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { InputError, serializeError } from '@backstage/errors';
|
||||
import { LocationAnalyzer } from '@backstage/plugin-catalog-node';
|
||||
import express from 'express';
|
||||
import yn from 'yn';
|
||||
import { z } from 'zod';
|
||||
import { Cursor, EntitiesCatalog } from '../catalog/types';
|
||||
import { CatalogProcessingOrchestrator } from '../processing/types';
|
||||
import { validateEntityEnvelope } from '../processing/util';
|
||||
import { createOpenApiRouter } from '../schema/openapi';
|
||||
import { AuthorizedValidationService } from './AuthorizedValidationService';
|
||||
import {
|
||||
basicEntityFilter,
|
||||
entitiesBatchRequest,
|
||||
@@ -38,6 +49,12 @@ import {
|
||||
} from './request';
|
||||
import { parseEntityFacetParams } from './request/parseEntityFacetParams';
|
||||
import { parseEntityOrderParams } from './request/parseEntityOrderParams';
|
||||
import { parseEntityPaginationParams } from './request/parseEntityPaginationParams';
|
||||
import {
|
||||
createEntityArrayJsonStream,
|
||||
writeEntitiesResponse,
|
||||
writeSingleEntityResponse,
|
||||
} from './response';
|
||||
import { LocationService, RefreshService } from './types';
|
||||
import {
|
||||
disallowReadonlyMode,
|
||||
@@ -45,22 +62,6 @@ import {
|
||||
locationInput,
|
||||
validateRequestBody,
|
||||
} from './util';
|
||||
import { createOpenApiRouter } from '../schema/openapi';
|
||||
import { parseEntityPaginationParams } from './request/parseEntityPaginationParams';
|
||||
import {
|
||||
AuthService,
|
||||
HttpAuthService,
|
||||
LoggerService,
|
||||
SchedulerService,
|
||||
PermissionsService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { LocationAnalyzer } from '@backstage/plugin-catalog-node';
|
||||
import { AuthorizedValidationService } from './AuthorizedValidationService';
|
||||
import {
|
||||
createEntityArrayJsonStream,
|
||||
writeEntitiesResponse,
|
||||
writeSingleEntityResponse,
|
||||
} from './response';
|
||||
|
||||
/**
|
||||
* Options used by {@link createRouter}.
|
||||
@@ -81,6 +82,8 @@ export interface RouterOptions {
|
||||
auth: AuthService;
|
||||
httpAuth: HttpAuthService;
|
||||
permissionsService: PermissionsService;
|
||||
// TODO: Require AuditorService once `backend-legacy` is removed
|
||||
auditor?: AuditorService;
|
||||
disableRelationsCompatibility?: boolean;
|
||||
}
|
||||
|
||||
@@ -109,6 +112,7 @@ export async function createRouter(
|
||||
permissionsService,
|
||||
auth,
|
||||
httpAuth,
|
||||
auditor,
|
||||
disableRelationsCompatibility = false,
|
||||
} = options;
|
||||
|
||||
@@ -119,18 +123,34 @@ export async function createRouter(
|
||||
}
|
||||
|
||||
if (refreshService) {
|
||||
// TODO: Potentially find a way to track the ancestor that gets refreshed to refresh this entity (as well as the child of that ancestor?)
|
||||
router.post('/refresh', async (req, res) => {
|
||||
const { authorizationToken, ...restBody } = req.body;
|
||||
|
||||
const credentials = authorizationToken
|
||||
? await auth.authenticate(authorizationToken)
|
||||
: await httpAuth.credentials(req);
|
||||
|
||||
await refreshService.refresh({
|
||||
...restBody,
|
||||
credentials,
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'refresh',
|
||||
meta: {
|
||||
entityRef: restBody.entityRef,
|
||||
},
|
||||
request: req,
|
||||
});
|
||||
res.status(200).end();
|
||||
|
||||
try {
|
||||
const credentials = authorizationToken
|
||||
? await auth.authenticate(authorizationToken)
|
||||
: await httpAuth.credentials(req);
|
||||
|
||||
await refreshService.refresh({
|
||||
...restBody,
|
||||
credentials,
|
||||
});
|
||||
|
||||
await auditorEvent?.success();
|
||||
res.status(200).end();
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({ error: err });
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,95 +161,115 @@ export async function createRouter(
|
||||
if (entitiesCatalog) {
|
||||
router
|
||||
.get('/entities', async (req, res) => {
|
||||
const filter = parseEntityFilterParams(req.query);
|
||||
const fields = parseEntityTransformParams(req.query);
|
||||
const order = parseEntityOrderParams(req.query);
|
||||
const pagination = parseEntityPaginationParams(req.query);
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
|
||||
// When pagination parameters are passed in, use the legacy slow path
|
||||
// that loads all entities into memory
|
||||
|
||||
if (pagination || disableRelationsCompatibility !== true) {
|
||||
const { entities, pageInfo } = await entitiesCatalog.entities({
|
||||
filter,
|
||||
fields,
|
||||
order,
|
||||
pagination,
|
||||
credentials,
|
||||
});
|
||||
|
||||
// Add a Link header to the next page
|
||||
if (pageInfo.hasNextPage) {
|
||||
const url = new URL(`http://ignored${req.url}`);
|
||||
url.searchParams.delete('offset');
|
||||
url.searchParams.set('after', pageInfo.endCursor);
|
||||
res.setHeader('link', `<${url.pathname}${url.search}>; rel="next"`);
|
||||
}
|
||||
|
||||
await writeEntitiesResponse({
|
||||
res,
|
||||
items: entities,
|
||||
alwaysUseObjectMode: !disableRelationsCompatibility,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const responseStream = createEntityArrayJsonStream(res);
|
||||
const limit = 10000;
|
||||
let cursor: Cursor | undefined;
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'CatalogEntityFetch',
|
||||
request: req,
|
||||
});
|
||||
|
||||
try {
|
||||
let currentWrite: Promise<boolean> | undefined = undefined;
|
||||
do {
|
||||
const result = await entitiesCatalog.queryEntities(
|
||||
!cursor
|
||||
? {
|
||||
credentials,
|
||||
fields,
|
||||
limit,
|
||||
filter,
|
||||
orderFields: order,
|
||||
skipTotalItems: true,
|
||||
}
|
||||
: { credentials, fields, limit, cursor },
|
||||
);
|
||||
const filter = parseEntityFilterParams(req.query);
|
||||
const fields = parseEntityTransformParams(req.query);
|
||||
const order = parseEntityOrderParams(req.query);
|
||||
const pagination = parseEntityPaginationParams(req.query);
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
|
||||
// Wait for previous write to complete
|
||||
if (await currentWrite) {
|
||||
return; // Client closed connection
|
||||
// When pagination parameters are passed in, use the legacy slow path
|
||||
// that loads all entities into memory
|
||||
|
||||
if (pagination || disableRelationsCompatibility !== true) {
|
||||
const { entities, pageInfo } = await entitiesCatalog.entities({
|
||||
filter,
|
||||
fields,
|
||||
order,
|
||||
pagination,
|
||||
credentials,
|
||||
});
|
||||
|
||||
// Add a Link header to the next page
|
||||
if (pageInfo.hasNextPage) {
|
||||
const url = new URL(`http://ignored${req.url}`);
|
||||
url.searchParams.delete('offset');
|
||||
url.searchParams.set('after', pageInfo.endCursor);
|
||||
res.setHeader(
|
||||
'link',
|
||||
`<${url.pathname}${url.search}>; rel="next"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.items.entities.length) {
|
||||
currentWrite = responseStream.send(result.items);
|
||||
}
|
||||
await auditorEvent?.success();
|
||||
|
||||
cursor = result.pageInfo?.nextCursor;
|
||||
} while (cursor);
|
||||
await writeEntitiesResponse({
|
||||
res,
|
||||
items: entities,
|
||||
alwaysUseObjectMode: !disableRelationsCompatibility,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for last write to complete
|
||||
await currentWrite;
|
||||
const responseStream = createEntityArrayJsonStream(res);
|
||||
const limit = 10000;
|
||||
let cursor: Cursor | undefined;
|
||||
|
||||
responseStream.complete();
|
||||
} finally {
|
||||
responseStream.close();
|
||||
try {
|
||||
let currentWrite: Promise<boolean> | undefined = undefined;
|
||||
do {
|
||||
const result = await entitiesCatalog.queryEntities(
|
||||
!cursor
|
||||
? {
|
||||
credentials,
|
||||
fields,
|
||||
limit,
|
||||
filter,
|
||||
orderFields: order,
|
||||
skipTotalItems: true,
|
||||
}
|
||||
: { credentials, fields, limit, cursor },
|
||||
);
|
||||
|
||||
// Wait for previous write to complete
|
||||
if (await currentWrite) {
|
||||
return; // Client closed connection
|
||||
}
|
||||
|
||||
if (result.items.entities.length) {
|
||||
currentWrite = responseStream.send(result.items);
|
||||
}
|
||||
|
||||
cursor = result.pageInfo?.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
// Wait for last write to complete
|
||||
await currentWrite;
|
||||
|
||||
await auditorEvent?.success();
|
||||
|
||||
responseStream.complete();
|
||||
} finally {
|
||||
responseStream.close();
|
||||
}
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
.get('/entities/by-query', async (req, res) => {
|
||||
const { items, pageInfo, totalItems } =
|
||||
await entitiesCatalog.queryEntities({
|
||||
limit: req.query.limit,
|
||||
offset: req.query.offset,
|
||||
...parseQueryEntitiesParams(req.query),
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'fetch-by-query',
|
||||
request: req,
|
||||
});
|
||||
|
||||
await writeEntitiesResponse({
|
||||
res,
|
||||
items,
|
||||
alwaysUseObjectMode: !disableRelationsCompatibility,
|
||||
responseWrapper: entities => ({
|
||||
items: entities,
|
||||
try {
|
||||
const { items, pageInfo, totalItems } =
|
||||
await entitiesCatalog.queryEntities({
|
||||
limit: req.query.limit,
|
||||
offset: req.query.offset,
|
||||
...parseQueryEntitiesParams(req.query),
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
|
||||
const meta = {
|
||||
totalItems,
|
||||
pageInfo: {
|
||||
...(pageInfo.nextCursor && {
|
||||
@@ -239,71 +279,217 @@ export async function createRouter(
|
||||
prevCursor: encodeCursor(pageInfo.prevCursor),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
await auditorEvent?.success({
|
||||
// Let's not log out the entities since this can make the log very big
|
||||
meta,
|
||||
});
|
||||
|
||||
await writeEntitiesResponse({
|
||||
res,
|
||||
items,
|
||||
alwaysUseObjectMode: !disableRelationsCompatibility,
|
||||
responseWrapper: entities => ({
|
||||
items: entities,
|
||||
...meta,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
.get('/entities/by-uid/:uid', async (req, res) => {
|
||||
const { uid } = req.params;
|
||||
const { entities } = await entitiesCatalog.entities({
|
||||
filter: basicEntityFilter({ 'metadata.uid': uid }),
|
||||
credentials: await httpAuth.credentials(req),
|
||||
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'fetch-by-uid',
|
||||
request: req,
|
||||
meta: {
|
||||
uid: uid,
|
||||
},
|
||||
});
|
||||
writeSingleEntityResponse(res, entities, `No entity with uid ${uid}`);
|
||||
|
||||
try {
|
||||
const { entities } = await entitiesCatalog.entities({
|
||||
filter: basicEntityFilter({ 'metadata.uid': uid }),
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
|
||||
await auditorEvent?.success({
|
||||
meta: {
|
||||
entities: entities,
|
||||
},
|
||||
});
|
||||
|
||||
writeSingleEntityResponse(res, entities, `No entity with uid ${uid}`);
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
.delete('/entities/by-uid/:uid', async (req, res) => {
|
||||
const { uid } = req.params;
|
||||
await entitiesCatalog.removeEntityByUid(uid, {
|
||||
credentials: await httpAuth.credentials(req),
|
||||
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'delete',
|
||||
severityLevel: 'medium',
|
||||
request: req,
|
||||
meta: {
|
||||
uid: uid,
|
||||
},
|
||||
});
|
||||
res.status(204).end();
|
||||
|
||||
try {
|
||||
await entitiesCatalog.removeEntityByUid(uid, {
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
|
||||
await auditorEvent?.success();
|
||||
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
.get('/entities/by-name/:kind/:namespace/:name', async (req, res) => {
|
||||
const { kind, namespace, name } = req.params;
|
||||
const { items } = await entitiesCatalog.entitiesBatch({
|
||||
entityRefs: [stringifyEntityRef({ kind, namespace, name })],
|
||||
credentials: await httpAuth.credentials(req),
|
||||
const entityRef = stringifyEntityRef({ kind, namespace, name });
|
||||
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'fetch-by-name',
|
||||
request: req,
|
||||
meta: {
|
||||
entityRef: entityRef,
|
||||
},
|
||||
});
|
||||
writeSingleEntityResponse(
|
||||
res,
|
||||
items,
|
||||
`No entity named '${name}' found, with kind '${kind}' in namespace '${namespace}'`,
|
||||
);
|
||||
|
||||
try {
|
||||
const { items } = await entitiesCatalog.entitiesBatch({
|
||||
entityRefs: [stringifyEntityRef({ kind, namespace, name })],
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
|
||||
await auditorEvent?.success();
|
||||
|
||||
writeSingleEntityResponse(
|
||||
res,
|
||||
items,
|
||||
`No entity named '${name}' found, with kind '${kind}' in namespace '${namespace}'`,
|
||||
);
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
.get(
|
||||
'/entities/by-name/:kind/:namespace/:name/ancestry',
|
||||
async (req, res) => {
|
||||
const { kind, namespace, name } = req.params;
|
||||
const entityRef = stringifyEntityRef({ kind, namespace, name });
|
||||
const response = await entitiesCatalog.entityAncestry(entityRef, {
|
||||
credentials: await httpAuth.credentials(req),
|
||||
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'ancestry-fetch',
|
||||
request: req,
|
||||
meta: {
|
||||
entityRef: entityRef,
|
||||
},
|
||||
});
|
||||
res.status(200).json(response);
|
||||
|
||||
try {
|
||||
const response = await entitiesCatalog.entityAncestry(entityRef, {
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
|
||||
await auditorEvent?.success({
|
||||
meta: {
|
||||
rootEntityRef: response.rootEntityRef,
|
||||
ancestry: response.items.map(ancestryLink => {
|
||||
return {
|
||||
entityRef: stringifyEntityRef(ancestryLink.entity),
|
||||
parentEntityRefs: ancestryLink.parentEntityRefs,
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
)
|
||||
.post('/entities/by-refs', async (req, res) => {
|
||||
const request = entitiesBatchRequest(req);
|
||||
const { items } = await entitiesCatalog.entitiesBatch({
|
||||
entityRefs: request.entityRefs,
|
||||
filter: parseEntityFilterParams(req.query),
|
||||
fields: parseEntityTransformParams(req.query, request.fields),
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
await writeEntitiesResponse({
|
||||
res,
|
||||
items,
|
||||
alwaysUseObjectMode: !disableRelationsCompatibility,
|
||||
responseWrapper: entities => ({
|
||||
items: entities,
|
||||
}),
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'batch-fetch',
|
||||
request: req,
|
||||
});
|
||||
|
||||
try {
|
||||
const request = entitiesBatchRequest(req);
|
||||
const { items } = await entitiesCatalog.entitiesBatch({
|
||||
entityRefs: request.entityRefs,
|
||||
filter: parseEntityFilterParams(req.query),
|
||||
fields: parseEntityTransformParams(req.query, request.fields),
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
|
||||
await auditorEvent?.success({
|
||||
meta: {
|
||||
...request,
|
||||
},
|
||||
});
|
||||
|
||||
await writeEntitiesResponse({
|
||||
res,
|
||||
items,
|
||||
alwaysUseObjectMode: !disableRelationsCompatibility,
|
||||
responseWrapper: entities => ({
|
||||
items: entities,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
.get('/entity-facets', async (req, res) => {
|
||||
const response = await entitiesCatalog.facets({
|
||||
filter: parseEntityFilterParams(req.query),
|
||||
facets: parseEntityFacetParams(req.query),
|
||||
credentials: await httpAuth.credentials(req),
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'facet-fetch',
|
||||
request: req,
|
||||
});
|
||||
res.status(200).json(response);
|
||||
|
||||
try {
|
||||
const response = await entitiesCatalog.facets({
|
||||
filter: parseEntityFilterParams(req.query),
|
||||
facets: parseEntityFacetParams(req.query),
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
|
||||
await auditorEvent?.success();
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -313,79 +499,212 @@ export async function createRouter(
|
||||
const location = await validateRequestBody(req, locationInput);
|
||||
const dryRun = yn(req.query.dryRun, { default: false });
|
||||
|
||||
// when in dryRun addLocation is effectively a read operation so we don't
|
||||
// need to disallow readonly
|
||||
if (!dryRun) {
|
||||
disallowReadonlyMode(readonlyEnabled);
|
||||
}
|
||||
|
||||
const output = await locationService.createLocation(location, dryRun, {
|
||||
credentials: await httpAuth.credentials(req),
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'location-create',
|
||||
severityLevel: dryRun ? 'low' : 'medium',
|
||||
request: req,
|
||||
meta: {
|
||||
location: location,
|
||||
isDryRun: dryRun,
|
||||
},
|
||||
});
|
||||
res.status(201).json(output);
|
||||
|
||||
try {
|
||||
// when in dryRun addLocation is effectively a read operation so we don't
|
||||
// need to disallow readonly
|
||||
if (!dryRun) {
|
||||
disallowReadonlyMode(readonlyEnabled);
|
||||
}
|
||||
|
||||
const output = await locationService.createLocation(
|
||||
location,
|
||||
dryRun,
|
||||
{
|
||||
credentials: await httpAuth.credentials(req),
|
||||
},
|
||||
);
|
||||
|
||||
await auditorEvent?.success({
|
||||
meta: {
|
||||
location: output.location,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(output);
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
meta: {
|
||||
location: location,
|
||||
isDryRun: dryRun,
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
.get('/locations', async (req, res) => {
|
||||
const locations = await locationService.listLocations({
|
||||
credentials: await httpAuth.credentials(req),
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'location-fetch',
|
||||
request: req,
|
||||
});
|
||||
res.status(200).json(locations.map(l => ({ data: l })));
|
||||
|
||||
try {
|
||||
const locations = await locationService.listLocations({
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
|
||||
await auditorEvent?.success();
|
||||
|
||||
res.status(200).json(locations.map(l => ({ data: l })));
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
|
||||
.get('/locations/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const output = await locationService.getLocation(id, {
|
||||
credentials: await httpAuth.credentials(req),
|
||||
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'location-fetch-by-id',
|
||||
request: req,
|
||||
meta: {
|
||||
id: id,
|
||||
},
|
||||
});
|
||||
res.status(200).json(output);
|
||||
|
||||
try {
|
||||
const output = await locationService.getLocation(id, {
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
|
||||
await auditorEvent?.success({
|
||||
meta: {
|
||||
output: output,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json(output);
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
.delete('/locations/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'location-delete',
|
||||
severityLevel: 'medium',
|
||||
request: req,
|
||||
meta: {
|
||||
id: id,
|
||||
},
|
||||
});
|
||||
|
||||
disallowReadonlyMode(readonlyEnabled);
|
||||
|
||||
const { id } = req.params;
|
||||
await locationService.deleteLocation(id, {
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
res.status(204).end();
|
||||
try {
|
||||
await locationService.deleteLocation(id, {
|
||||
credentials: await httpAuth.credentials(req),
|
||||
});
|
||||
|
||||
await auditorEvent?.success();
|
||||
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
.get('/locations/by-entity/:kind/:namespace/:name', async (req, res) => {
|
||||
const { kind, namespace, name } = req.params;
|
||||
const output = await locationService.getLocationByEntity(
|
||||
{ kind, namespace, name },
|
||||
{ credentials: await httpAuth.credentials(req) },
|
||||
);
|
||||
res.status(200).json(output);
|
||||
const locationRef = `${kind}:${namespace}/${name}`;
|
||||
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'location-fetch-by-entity-ref',
|
||||
request: req,
|
||||
meta: {
|
||||
locationRef: locationRef,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const output = await locationService.getLocationByEntity(
|
||||
{ kind, namespace, name },
|
||||
{ credentials: await httpAuth.credentials(req) },
|
||||
);
|
||||
|
||||
await auditorEvent?.success({
|
||||
meta: {
|
||||
output: output,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json(output);
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (locationAnalyzer) {
|
||||
router.post('/analyze-location', async (req, res) => {
|
||||
const body = await validateRequestBody(
|
||||
req,
|
||||
z.object({
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'location-analyze',
|
||||
request: req,
|
||||
});
|
||||
|
||||
try {
|
||||
const body = await validateRequestBody(
|
||||
req,
|
||||
z.object({
|
||||
location: locationInput,
|
||||
catalogFilename: z.string().optional(),
|
||||
}),
|
||||
);
|
||||
const schema = z.object({
|
||||
location: locationInput,
|
||||
catalogFilename: z.string().optional(),
|
||||
}),
|
||||
);
|
||||
const schema = z.object({
|
||||
location: locationInput,
|
||||
catalogFilename: z.string().optional(),
|
||||
});
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
const parsedBody = schema.parse(body);
|
||||
try {
|
||||
const output = await locationAnalyzer.analyzeLocation(
|
||||
parsedBody,
|
||||
credentials,
|
||||
);
|
||||
res.status(200).json(output);
|
||||
} catch (err) {
|
||||
if (
|
||||
// Catch errors from parse-url library.
|
||||
err.name === 'Error' &&
|
||||
'subject_url' in err
|
||||
) {
|
||||
throw new InputError('The given location.target is not a URL');
|
||||
});
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
const parsedBody = schema.parse(body);
|
||||
try {
|
||||
const output = await locationAnalyzer.analyzeLocation(
|
||||
parsedBody,
|
||||
credentials,
|
||||
);
|
||||
|
||||
await auditorEvent?.success({
|
||||
meta: {
|
||||
output: output,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json(output);
|
||||
} catch (err) {
|
||||
if (
|
||||
// Catch errors from parse-url library.
|
||||
err.name === 'Error' &&
|
||||
'subject_url' in err
|
||||
) {
|
||||
throw new InputError('The given location.target is not a URL');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
@@ -393,55 +712,81 @@ export async function createRouter(
|
||||
|
||||
if (orchestrator) {
|
||||
router.post('/validate-entity', async (req, res) => {
|
||||
const bodySchema = z.object({
|
||||
entity: z.unknown(),
|
||||
location: z.string(),
|
||||
const auditorEvent = await auditor?.createEvent({
|
||||
eventId: 'validate',
|
||||
request: req,
|
||||
});
|
||||
|
||||
let body: z.infer<typeof bodySchema>;
|
||||
let entity: Entity;
|
||||
let location: { type: string; target: string };
|
||||
try {
|
||||
body = await validateRequestBody(req, bodySchema);
|
||||
entity = validateEntityEnvelope(body.entity);
|
||||
location = parseLocationRef(body.location);
|
||||
if (location.type !== 'url')
|
||||
throw new TypeError(
|
||||
`Invalid location ref ${body.location}, only 'url:<target>' is supported, e.g. url:https://host/path`,
|
||||
);
|
||||
} catch (err) {
|
||||
return res.status(400).json({
|
||||
errors: [serializeError(err)],
|
||||
const bodySchema = z.object({
|
||||
entity: z.unknown(),
|
||||
location: z.string(),
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
const authorizedValidationService = new AuthorizedValidationService(
|
||||
orchestrator,
|
||||
permissionsService,
|
||||
);
|
||||
const processingResult = await authorizedValidationService.process(
|
||||
{
|
||||
entity: {
|
||||
...entity,
|
||||
metadata: {
|
||||
...entity.metadata,
|
||||
annotations: {
|
||||
[ANNOTATION_LOCATION]: body.location,
|
||||
[ANNOTATION_ORIGIN_LOCATION]: body.location,
|
||||
...entity.metadata.annotations,
|
||||
let body: z.infer<typeof bodySchema>;
|
||||
let entity: Entity;
|
||||
let location: { type: string; target: string };
|
||||
try {
|
||||
body = await validateRequestBody(req, bodySchema);
|
||||
entity = validateEntityEnvelope(body.entity);
|
||||
location = parseLocationRef(body.location);
|
||||
if (location.type !== 'url')
|
||||
throw new TypeError(
|
||||
`Invalid location ref ${body.location}, only 'url:<target>' is supported, e.g. url:https://host/path`,
|
||||
);
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
|
||||
return res.status(400).json({
|
||||
errors: [serializeError(err)],
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
const authorizedValidationService = new AuthorizedValidationService(
|
||||
orchestrator,
|
||||
permissionsService,
|
||||
);
|
||||
const processingResult = await authorizedValidationService.process(
|
||||
{
|
||||
entity: {
|
||||
...entity,
|
||||
metadata: {
|
||||
...entity.metadata,
|
||||
annotations: {
|
||||
[ANNOTATION_LOCATION]: body.location,
|
||||
[ANNOTATION_ORIGIN_LOCATION]: body.location,
|
||||
...entity.metadata.annotations,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
credentials,
|
||||
);
|
||||
|
||||
if (!processingResult.ok)
|
||||
res.status(400).json({
|
||||
errors: processingResult.errors.map(e => serializeError(e)),
|
||||
if (!processingResult.ok) {
|
||||
const errors = processingResult.errors.map(e => serializeError(e));
|
||||
|
||||
await auditorEvent?.fail({
|
||||
errors: errors,
|
||||
});
|
||||
|
||||
res.status(400).json({
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
await auditorEvent?.success();
|
||||
|
||||
return res.status(200).end();
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({
|
||||
error: err,
|
||||
});
|
||||
return res.status(200).end();
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -63,3 +63,18 @@ you will not have any templates available to use. These need to be [added to the
|
||||
|
||||
To get up and running and try out some templates quickly, you can or copy the
|
||||
catalog locations from the [create-app template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/app-config.yaml.hbs).
|
||||
|
||||
## Audit Events
|
||||
|
||||
- **`parameter-schema-fetch`**: Tracks`GET` requests to the `/v2/templates/:namespace/:kind/:name/parameter-schema` endpoint which return template parameter schemas
|
||||
- **`installed-actions-fetch`**: Tracks`GET` requests to the `/v2/actions` endpoint which grabs the list of installed actions
|
||||
- **`task-creation`**: Tracks`POST` requests to the `/v2/tasks` endpoint which creates tasks that the scaffolder executes
|
||||
- **`task-list-fetch`**: Tracks`GET` requests to the `/v2/tasks` endpoint which fetches details of all tasks in the scaffolder.
|
||||
- **`task-fetch`**: Tracks`GET` requests to the `/v2/tasks/:taskId` endpoint which fetches details of a specified task `:taskId`
|
||||
- **`task-cancellation`**: Tracks`POST` requests to the `/v2/tasks/:taskId/cancel` endpoint which cancels a running task
|
||||
- **`task-retry`**: Tracks`POST` requests to the `/v2/tasks/:taskId/retry` endpoint which retries a failed task
|
||||
- **`task-stream`**: Tracks`GET` requests to the `/v2/tasks/:taskId/eventstream` endpoint which returns an event stream of the task logs of task `:taskId`
|
||||
- **`task-event-fetch`**: Tracks`GET` requests to the `/v2/tasks/:taskId/events` endpoint which returns a snapshot of the task logs of task `:taskId`
|
||||
- **`task-dry-run`**: Tracks`POST` requests to the `/v2/dry-run` endpoint which creates a dry-run task. All audit logs for events associated with dry runs have the `meta.isDryLog` flag set to `true`.
|
||||
- **`stale-task-cancellation`**: Tracks automated cancellation of stale tasks
|
||||
- **`task-execution`**: Tracks the`initiation` and `completion` of a real scaffolder task execution (will not occur during dry runs)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { ActionContext as ActionContext_2 } from '@backstage/plugin-scaffolder-node';
|
||||
import { AuditorService } from '@backstage/backend-plugin-api';
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { AutocompleteHandler } from '@backstage/plugin-scaffolder-node/alpha';
|
||||
import * as azure from '@backstage/plugin-scaffolder-backend-module-azure';
|
||||
@@ -407,6 +408,7 @@ export type CreateWorkerOptions = {
|
||||
integrations: ScmIntegrations;
|
||||
workingDirectory: string;
|
||||
logger: Logger;
|
||||
auditor?: AuditorService;
|
||||
additionalTemplateFilters?: Record<string, TemplateFilter_2>;
|
||||
concurrentTasksLimit?: number;
|
||||
additionalTemplateGlobals?: Record<string, TemplateGlobal_2>;
|
||||
@@ -541,6 +543,8 @@ export interface RouterOptions {
|
||||
// (undocumented)
|
||||
additionalWorkspaceProviders?: Record<string, WorkspaceProvider>;
|
||||
// (undocumented)
|
||||
auditor?: AuditorService;
|
||||
// (undocumented)
|
||||
auth?: AuthService;
|
||||
// (undocumented)
|
||||
autocompleteHandlers?: Record<string, AutocompleteHandler>;
|
||||
@@ -630,6 +634,7 @@ export class TaskManager implements TaskContext_2 {
|
||||
auth?: AuthService,
|
||||
config?: Config,
|
||||
additionalWorkspaceProviders?: Record<string, WorkspaceProvider>,
|
||||
auditor?: AuditorService,
|
||||
): TaskManager;
|
||||
// (undocumented)
|
||||
get createdBy(): string | undefined;
|
||||
|
||||
@@ -14,13 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { loggerToWinstonLogger } from '@backstage/backend-common';
|
||||
import {
|
||||
coreServices,
|
||||
createBackendPlugin,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { loggerToWinstonLogger } from '@backstage/backend-common';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { catalogServiceRef } from '@backstage/plugin-catalog-node/alpha';
|
||||
import { eventsServiceRef } from '@backstage/plugin-events-node';
|
||||
import {
|
||||
TaskBroker,
|
||||
TemplateAction,
|
||||
@@ -46,12 +47,11 @@ import {
|
||||
createFetchTemplateAction,
|
||||
createFetchTemplateFileAction,
|
||||
createFilesystemDeleteAction,
|
||||
createFilesystemRenameAction,
|
||||
createFilesystemReadDirAction,
|
||||
createFilesystemRenameAction,
|
||||
createWaitAction,
|
||||
} from './scaffolder';
|
||||
import { createRouter } from './service/router';
|
||||
import { eventsServiceRef } from '@backstage/plugin-events-node';
|
||||
|
||||
/**
|
||||
* Scaffolder plugin
|
||||
@@ -115,6 +115,7 @@ export const scaffolderPlugin = createBackendPlugin({
|
||||
discovery: coreServices.discovery,
|
||||
httpRouter: coreServices.httpRouter,
|
||||
httpAuth: coreServices.httpAuth,
|
||||
auditor: coreServices.auditor,
|
||||
catalogClient: catalogServiceRef,
|
||||
events: eventsServiceRef,
|
||||
},
|
||||
@@ -131,6 +132,7 @@ export const scaffolderPlugin = createBackendPlugin({
|
||||
catalogClient,
|
||||
permissions,
|
||||
events,
|
||||
auditor,
|
||||
}) {
|
||||
const log = loggerToWinstonLogger(logger);
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
@@ -195,6 +197,7 @@ export const scaffolderPlugin = createBackendPlugin({
|
||||
autocompleteHandlers,
|
||||
additionalWorkspaceProviders,
|
||||
events,
|
||||
auditor,
|
||||
});
|
||||
httpRouter.use(router);
|
||||
},
|
||||
|
||||
@@ -14,29 +14,32 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AuditorService,
|
||||
BackstageCredentials,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import type { UserEntity } from '@backstage/catalog-model';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import { TaskSpec, TemplateInfo } from '@backstage/plugin-scaffolder-common';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
createTemplateAction,
|
||||
TaskSecrets,
|
||||
TemplateFilter,
|
||||
TemplateGlobal,
|
||||
deserializeDirectoryContents,
|
||||
SerializedFile,
|
||||
serializeDirectoryContents,
|
||||
TaskSecrets,
|
||||
TemplateFilter,
|
||||
TemplateGlobal,
|
||||
} from '@backstage/plugin-scaffolder-node';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Logger } from 'winston';
|
||||
import { TemplateActionRegistry } from '../actions';
|
||||
import { NunjucksWorkflowRunner } from '../tasks/NunjucksWorkflowRunner';
|
||||
import { DecoratedActionsRegistry } from './DecoratedActionsRegistry';
|
||||
import fs from 'fs-extra';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import { BackstageCredentials } from '@backstage/backend-plugin-api';
|
||||
import type { UserEntity } from '@backstage/catalog-model';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface DryRunInput {
|
||||
spec: TaskSpec;
|
||||
@@ -59,6 +62,7 @@ interface DryRunResult {
|
||||
/** @internal */
|
||||
export type TemplateTesterCreateOptions = {
|
||||
logger: Logger;
|
||||
auditor?: AuditorService;
|
||||
integrations: ScmIntegrations;
|
||||
actionRegistry: TemplateActionRegistry;
|
||||
workingDirectory: string;
|
||||
@@ -109,6 +113,7 @@ export function createDryRunner(options: TemplateTesterCreateOptions) {
|
||||
|
||||
const abortSignal = new AbortController().signal;
|
||||
const result = await workflowRunner.execute({
|
||||
taskId: dryRunId,
|
||||
spec: {
|
||||
...input.spec,
|
||||
steps: [
|
||||
|
||||
@@ -14,48 +14,51 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { TaskTrackType, WorkflowResponse, WorkflowRunner } from './types';
|
||||
import * as winston from 'winston';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import nunjucks from 'nunjucks';
|
||||
import { JsonArray, JsonObject, JsonValue } from '@backstage/types';
|
||||
import { InputError, NotAllowedError, stringifyError } from '@backstage/errors';
|
||||
import { PassThrough } from 'stream';
|
||||
import { generateExampleOutput, isTruthy } from './helper';
|
||||
import { validate as validateJsonSchema } from 'jsonschema';
|
||||
import { TemplateActionRegistry } from '../actions';
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import {
|
||||
SecureTemplater,
|
||||
SecureTemplateRenderer,
|
||||
} from '../../lib/templating/SecureTemplater';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import {
|
||||
TaskRecovery,
|
||||
TaskSpec,
|
||||
TaskSpecV1beta3,
|
||||
TaskStep,
|
||||
} from '@backstage/plugin-scaffolder-common';
|
||||
|
||||
import { JsonArray, JsonObject, JsonValue } from '@backstage/types';
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import fs from 'fs-extra';
|
||||
import { validate as validateJsonSchema } from 'jsonschema';
|
||||
import nunjucks from 'nunjucks';
|
||||
import path from 'path';
|
||||
import { PassThrough } from 'stream';
|
||||
import * as winston from 'winston';
|
||||
import {
|
||||
TemplateAction,
|
||||
TemplateFilter,
|
||||
TemplateGlobal,
|
||||
TaskContext,
|
||||
} from '@backstage/plugin-scaffolder-node';
|
||||
import { createConditionAuthorizer } from '@backstage/plugin-permission-node';
|
||||
SecureTemplater,
|
||||
SecureTemplateRenderer,
|
||||
} from '../../lib/templating/SecureTemplater';
|
||||
import { TemplateActionRegistry } from '../actions';
|
||||
import { generateExampleOutput, isTruthy } from './helper';
|
||||
import { TaskTrackType, WorkflowResponse, WorkflowRunner } from './types';
|
||||
|
||||
import { loggerToWinstonLogger } from '@backstage/backend-common';
|
||||
import type {
|
||||
AuditorService,
|
||||
PermissionsService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
import { createCounterMetric, createHistogramMetric } from '../../util/metrics';
|
||||
import { createDefaultFilters } from '../../lib/templating/filters';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PolicyDecision,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { scaffolderActionRules } from '../../service/rules';
|
||||
import { createConditionAuthorizer } from '@backstage/plugin-permission-node';
|
||||
import { actionExecutePermission } from '@backstage/plugin-scaffolder-common/alpha';
|
||||
import { PermissionsService } from '@backstage/backend-plugin-api';
|
||||
import { loggerToWinstonLogger } from '@backstage/backend-common';
|
||||
import {
|
||||
TaskContext,
|
||||
TemplateAction,
|
||||
TemplateFilter,
|
||||
TemplateGlobal,
|
||||
} from '@backstage/plugin-scaffolder-node';
|
||||
import { createDefaultFilters } from '../../lib/templating/filters';
|
||||
import { scaffolderActionRules } from '../../service/rules';
|
||||
import { createCounterMetric, createHistogramMetric } from '../../util/metrics';
|
||||
import { BackstageLoggerTransport, WinstonLogger } from './logger';
|
||||
|
||||
type NunjucksWorkflowRunnerOptions = {
|
||||
@@ -63,6 +66,7 @@ type NunjucksWorkflowRunnerOptions = {
|
||||
actionRegistry: TemplateActionRegistry;
|
||||
integrations: ScmIntegrations;
|
||||
logger: winston.Logger;
|
||||
auditor?: AuditorService;
|
||||
additionalTemplateFilters?: Record<string, TemplateFilter>;
|
||||
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
|
||||
permissions?: PermissionsService;
|
||||
@@ -127,7 +131,7 @@ const createStepLogger = ({
|
||||
// Initially this stream used to be the only way to write to the client logs, but that
|
||||
// has changed over time, there's not really a need for this anymore.
|
||||
// You can just create a simple wrapper like the below in your action to write to the main logger.
|
||||
// This way we also get recactions for free.
|
||||
// This way we also get redactions for free.
|
||||
const streamLogger = new PassThrough();
|
||||
streamLogger.on('data', async data => {
|
||||
const message = data.toString().trim();
|
||||
@@ -245,7 +249,9 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
const stepTrack = await this.tracker.stepStart(task, step);
|
||||
|
||||
if (task.cancelSignal.aborted) {
|
||||
throw new Error(`Step ${step.name} has been cancelled.`);
|
||||
throw new Error(
|
||||
`Step ${step.id} (${step.name}) of task ${task.taskId} has been cancelled.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -454,7 +460,9 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
context.steps[step.id] = { output: stepOutput };
|
||||
|
||||
if (task.cancelSignal.aborted) {
|
||||
throw new Error(`Step ${step.name} has been cancelled.`);
|
||||
throw new Error(
|
||||
`Step ${step.id} (${step.name}) of task ${task.taskId} has been cancelled.`,
|
||||
);
|
||||
}
|
||||
|
||||
await stepTrack.markSuccessful();
|
||||
|
||||
@@ -14,16 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AuditorService,
|
||||
AuthService,
|
||||
BackstageCredentials,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
|
||||
import {
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
Observable,
|
||||
createDeferred,
|
||||
} from '@backstage/types';
|
||||
import { Logger } from 'winston';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
import {
|
||||
SerializedTask,
|
||||
SerializedTaskEvent,
|
||||
@@ -34,14 +31,18 @@ import {
|
||||
TaskSecrets,
|
||||
TaskStatus,
|
||||
} from '@backstage/plugin-scaffolder-node';
|
||||
import { InternalTaskSecrets, TaskStore } from './types';
|
||||
import { readDuration } from './helper';
|
||||
import {
|
||||
AuthService,
|
||||
BackstageCredentials,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { DefaultWorkspaceService, WorkspaceService } from './WorkspaceService';
|
||||
import { WorkspaceProvider } from '@backstage/plugin-scaffolder-node/alpha';
|
||||
import {
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
Observable,
|
||||
createDeferred,
|
||||
} from '@backstage/types';
|
||||
import { Logger } from 'winston';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
import { readDuration } from './helper';
|
||||
import { InternalTaskSecrets, TaskStore } from './types';
|
||||
import { DefaultWorkspaceService, WorkspaceService } from './WorkspaceService';
|
||||
|
||||
type TaskState = {
|
||||
checkpoints: {
|
||||
@@ -74,6 +75,7 @@ export class TaskManager implements TaskContext {
|
||||
auth?: AuthService,
|
||||
config?: Config,
|
||||
additionalWorkspaceProviders?: Record<string, WorkspaceProvider>,
|
||||
auditor?: AuditorService,
|
||||
) {
|
||||
const workspaceService = DefaultWorkspaceService.create(
|
||||
task,
|
||||
@@ -89,6 +91,7 @@ export class TaskManager implements TaskContext {
|
||||
logger,
|
||||
workspaceService,
|
||||
auth,
|
||||
auditor,
|
||||
);
|
||||
agent.startTimeout();
|
||||
return agent;
|
||||
@@ -102,6 +105,7 @@ export class TaskManager implements TaskContext {
|
||||
private readonly logger: Logger,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly auth?: AuthService,
|
||||
private readonly auditor?: AuditorService,
|
||||
) {}
|
||||
|
||||
get spec() {
|
||||
@@ -200,6 +204,25 @@ export class TaskManager implements TaskContext {
|
||||
if (this.heartbeatTimeoutId) {
|
||||
clearTimeout(this.heartbeatTimeoutId);
|
||||
}
|
||||
|
||||
const auditorEvent = await this.auditor?.createEvent({
|
||||
eventId: 'task-execution',
|
||||
severityLevel: 'medium',
|
||||
meta: {
|
||||
taskId: this.task.taskId,
|
||||
taskParameters: this.task.spec.parameters,
|
||||
},
|
||||
// The initial event is created in TaskWorker
|
||||
suppressInitialEvent: true,
|
||||
});
|
||||
|
||||
if (result === 'failed') {
|
||||
await auditorEvent?.fail({
|
||||
error: metadata?.error as any,
|
||||
});
|
||||
} else {
|
||||
await auditorEvent?.success();
|
||||
}
|
||||
}
|
||||
|
||||
private startTimeout() {
|
||||
@@ -275,6 +298,7 @@ export class StorageTaskBroker implements TaskBroker {
|
||||
string,
|
||||
WorkspaceProvider
|
||||
>,
|
||||
private readonly auditor?: AuditorService,
|
||||
) {}
|
||||
|
||||
async list(options?: {
|
||||
@@ -329,10 +353,7 @@ export class StorageTaskBroker implements TaskBroker {
|
||||
|
||||
public async recoverTasks(): Promise<void> {
|
||||
const enabled =
|
||||
(this.config &&
|
||||
this.config.getOptionalBoolean(
|
||||
'scaffolder.EXPERIMENTAL_recoverTasks',
|
||||
)) ??
|
||||
this.config?.getOptionalBoolean('scaffolder.EXPERIMENTAL_recoverTasks') ??
|
||||
false;
|
||||
|
||||
if (enabled) {
|
||||
@@ -374,6 +395,7 @@ export class StorageTaskBroker implements TaskBroker {
|
||||
this.auth,
|
||||
this.config,
|
||||
this.additionalWorkspaceProviders,
|
||||
this.auditor,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -449,6 +471,13 @@ export class StorageTaskBroker implements TaskBroker {
|
||||
const { tasks } = await this.storage.listStaleTasks(options);
|
||||
await Promise.all(
|
||||
tasks.map(async task => {
|
||||
const auditorEvent = await this.auditor?.createEvent({
|
||||
eventId: 'stale-task-cancellation',
|
||||
severityLevel: 'medium',
|
||||
meta: {
|
||||
taskId: task.taskId,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await this.storage.completeTask({
|
||||
taskId: task.taskId,
|
||||
@@ -458,8 +487,10 @@ export class StorageTaskBroker implements TaskBroker {
|
||||
'The task was cancelled because the task worker lost connection to the task broker',
|
||||
},
|
||||
});
|
||||
await auditorEvent?.success();
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to cancel task '${task.taskId}', ${error}`);
|
||||
await auditorEvent?.fail({ error: error });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -14,20 +14,21 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { WorkflowRunner } from './types';
|
||||
import { AuditorService } from '@backstage/backend-plugin-api';
|
||||
import { assertError, stringifyError } from '@backstage/errors';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
TaskContext,
|
||||
TaskBroker,
|
||||
TaskContext,
|
||||
TemplateFilter,
|
||||
TemplateGlobal,
|
||||
} from '@backstage/plugin-scaffolder-node';
|
||||
import PQueue from 'p-queue';
|
||||
import { NunjucksWorkflowRunner } from './NunjucksWorkflowRunner';
|
||||
import { Logger } from 'winston';
|
||||
import { TemplateActionRegistry } from '../actions';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { assertError, stringifyError } from '@backstage/errors';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import { NunjucksWorkflowRunner } from './NunjucksWorkflowRunner';
|
||||
import { WorkflowRunner } from './types';
|
||||
|
||||
/**
|
||||
* TaskWorkerOptions
|
||||
@@ -42,6 +43,7 @@ export type TaskWorkerOptions = {
|
||||
concurrentTasksLimit: number;
|
||||
permissions?: PermissionEvaluator;
|
||||
logger?: Logger;
|
||||
auditor?: AuditorService;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -55,6 +57,7 @@ export type CreateWorkerOptions = {
|
||||
integrations: ScmIntegrations;
|
||||
workingDirectory: string;
|
||||
logger: Logger;
|
||||
auditor?: AuditorService;
|
||||
additionalTemplateFilters?: Record<string, TemplateFilter>;
|
||||
/**
|
||||
* The number of tasks that can be executed at the same time by the worker
|
||||
@@ -81,11 +84,13 @@ export type CreateWorkerOptions = {
|
||||
export class TaskWorker {
|
||||
private taskQueue: PQueue;
|
||||
private logger: Logger | undefined;
|
||||
private auditor: AuditorService | undefined;
|
||||
private stopWorkers: boolean;
|
||||
|
||||
private constructor(private readonly options: TaskWorkerOptions) {
|
||||
this.stopWorkers = false;
|
||||
this.logger = options.logger;
|
||||
this.auditor = options.auditor;
|
||||
this.taskQueue = new PQueue({
|
||||
concurrency: options.concurrentTasksLimit,
|
||||
});
|
||||
@@ -95,6 +100,7 @@ export class TaskWorker {
|
||||
const {
|
||||
taskBroker,
|
||||
logger,
|
||||
auditor,
|
||||
actionRegistry,
|
||||
integrations,
|
||||
workingDirectory,
|
||||
@@ -108,6 +114,7 @@ export class TaskWorker {
|
||||
actionRegistry,
|
||||
integrations,
|
||||
logger,
|
||||
auditor,
|
||||
workingDirectory,
|
||||
additionalTemplateFilters,
|
||||
additionalTemplateGlobals,
|
||||
@@ -119,6 +126,7 @@ export class TaskWorker {
|
||||
runners: { workflowRunner },
|
||||
concurrentTasksLimit,
|
||||
permissions,
|
||||
auditor,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -166,6 +174,16 @@ export class TaskWorker {
|
||||
}
|
||||
|
||||
async runOneTask(task: TaskContext) {
|
||||
await this.auditor?.createEvent({
|
||||
eventId: 'task-execution',
|
||||
severityLevel: 'medium',
|
||||
meta: {
|
||||
taskId: task.taskId,
|
||||
taskParameters: task.spec.parameters,
|
||||
templateRef: task.spec.templateInfo?.entityRef,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
if (task.spec.apiVersion !== 'scaffolder.backstage.io/v1beta3') {
|
||||
throw new Error(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -405,6 +405,8 @@ export interface TaskContext {
|
||||
// (undocumented)
|
||||
spec: TaskSpec;
|
||||
// (undocumented)
|
||||
taskId?: string;
|
||||
// (undocumented)
|
||||
updateCheckpoint?(
|
||||
options:
|
||||
| {
|
||||
|
||||
@@ -110,6 +110,7 @@ export type TaskBrokerDispatchOptions = {
|
||||
* @public
|
||||
*/
|
||||
export interface TaskContext {
|
||||
taskId?: string;
|
||||
cancelSignal: AbortSignal;
|
||||
spec: TaskSpec;
|
||||
secrets?: TaskSecrets;
|
||||
|
||||
Reference in New Issue
Block a user