From bee64f19a2a2e3c5949f9a2de2eb725b182a582c Mon Sep 17 00:00:00 2001 From: sandornagy517 <67263678+sandornagy517@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:38:14 +0100 Subject: [PATCH 1/2] Release custom fields mapping (#169) * feat: add migration for pagerduty_custom_fields table (#140) * feat: add CustomFieldsTab component to PagerDutyPage (#141) * feat: add CustomFieldsTab component to PagerDutyPage * refactor: remove unused MAX_MAPPINGS constant and related logic from CustomFieldsTab * feat: implement custom fields management for PagerDuty integration (#145) * feat: implement custom fields management for PagerDuty integration * feat: enhance error handling for custom fields in PagerDuty integration * feat: remove unused custom field retrieval methods and limit checks in CustomFieldsController * feat: update error handling for custom field creation and limit checks in CustomFieldsController and CustomFieldsTab * feat: add tests for custom fields creation and retrieval endpoints * fix: remove type duplication * fix import and mock api * fix unit tests * remove type duplication * fix unit tests * fix unit tests and error messages * implement insertCustomField method with error handling and retrieval * feat: enhance custom field methods to support account filtering and improve error handling * feat: add AddCustomFieldModal component and integrate it into CustomFieldsTab; update getCustomFields test * feat: update createCustomField method to return structured result; enhance error handling and update related tests * DEVECO-533: Edit custom fields (#148) * feat: add updateCustomField functionality to PagerDuty integration - Implemented updateCustomField method in the PagerDuty API to allow updating existing custom fields. - Enhanced error handling for name and entity path conflicts during updates. - Added corresponding tests for updateCustomField functionality in both frontend and backend. - Updated AddCustomFieldModal to support editing existing custom fields. - Integrated update functionality into CustomFieldsTab with a new action menu for editing custom fields. * feat: refactor custom fields controller and add helper functions - Introduced customFieldsHelpers module to encapsulate validation and error handling logic. - Refactored createCustomField and updateCustomField methods to utilize new helper functions for input validation and error management. - Enhanced error handling for entity path uniqueness and improved response messages. - Normalized description handling for custom fields during creation and updates. * feat: update CustomFieldsTab UI and functionality - Replaced Link component with a Button for adding new custom fields, enhancing user interaction. - Updated the section subtitle to better reflect the purpose of the tab. - Added a new Description column in the custom fields table for improved data visibility. - Adjusted the empty state message to accommodate the new column layout. - Swapped MoreVertIcon for MoreHorizIcon in the action menu for consistency. * feat: enhance error handling in PagerDutyClient for custom fields * feat: add CustomFieldModal component for managing custom fields - Replaced AddCustomFieldModal references in CustomFieldsTab with the new CustomFieldModal. * refactor: remove unused disabled menu items from CustomFieldsTab - Removed "Disable" and "Delete" menu items from the action menu in CustomFieldsTab to streamline the UI and eliminate unnecessary options. * fix: prevent race conditions in CustomFieldsTab update logic - Captured field ID early in the update process to avoid potential race conditions when updating custom fields. - Updated references to use the captured field ID instead of directly accessing the menuField ID. * fix: improve error handling in handlePagerDutyError function - Updated error handling logic to provide more specific messages for different HTTP error statuses (400, 401, 403, 404, 409, 429). - Enhanced clarity of error messages related to custom field limits and authentication issues in PagerDuty integration. * refactor: update CustomFieldModal and CustomFieldsTab components - Refactored CustomFieldModal to use new dialog components from @backstage/ui, improving UI consistency. * feat: add unique indexes for custom fields in migrations - Introduced a new migration to add unique indexes on the `pagerduty_custom_fields` table for `backstageEntityMappingPath` and `pagerdutySubdomain`, as well as `pagerdutyCustomFieldDisplayName` and `pagerdutySubdomain`. - Removed the `findCustomFieldByEntityPath` method from the `PagerDutyBackendStore` interface and its implementation to streamline the codebase. - Updated the `CustomFieldsController` to handle database errors more effectively during custom field creation and updates, improving overall error management. * refactor: consolidate error handling and validation in CustomFieldsController - Removed the customFieldsHelpers module and integrated its functionality directly into the CustomFieldsController. - Updated methods to use class methods for validation and error handling, improving code organization and readability. - Enhanced error management for database and PagerDuty interactions, providing clearer responses for various error scenarios. - Added private methods for input validation, sanitization, and description normalization to streamline custom field processing. * fix: prevent unnecessary form resets in CustomFieldModal * refactor: improve state management and modal handling in CustomFieldsTab - Renamed state variable for clarity from `menuField` to `selectedCustomField`. - Consolidated modal handling logic into a single function to streamline opening and closing of modals. - Updated the CustomFieldModal to handle both editing and adding custom fields more efficiently. - Enhanced error handling and state management during custom field updates. * feat: implement uniqueness validation for custom fields in CustomFieldsController - Added a new method to validate uniqueness constraints for display names and entity paths before updating PagerDuty, preventing synchronization issues. - Enhanced error handling to return appropriate conflict messages when duplicates are detected. - Introduced tests to ensure validation logic works correctly and prevents invalid updates. * feat: enhance custom field management with delete and update functionality - Added methods to delete custom fields and update their PagerDuty IDs in the PagerDutyBackendDatabase. - Updated CustomFieldsController to handle the creation and updating of custom fields with rollback mechanisms for error handling. - Implemented tests to ensure proper rollback behavior when PagerDuty operations fail, maintaining data consistency between Backstage and PagerDuty. * fix: Rebase after service mappings release * fix: Remove inline styles * feat: Push custom fields to PagerDuty during catalog processing (#162) * feat: Push custom fields to PD during catalog processing * refactor: Extract custom fields push into dedicated processor Split custom field value sync out of PagerDutyEntityProcessor into a new PagerDutyCustomFieldsProcessor, return the PagerDuty response body from the sync endpoint, and consolidate DB row mapping via a helper. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * feat: sync logs tab + record sync failures/skips (#168) * feat: add sync logs tab to display custom field sync history Co-Authored-By: Claude Sonnet 4.6 * feat: record custom field sync failures and skips in sync logs Catalog processor now writes a sync log entry whenever a custom field push to PagerDuty fails or a field is skipped because its entity path does not resolve. Writes are fire-and-forget so they don't slow down catalog processing. Co-Authored-By: Claude Sonnet 4.6 * refactor: group custom fields components and extract shared error mapper Move CustomFields-related components into a dedicated subfolder and share the toFieldErrors helper through a new customFieldErrors util to remove the duplicated implementation. Co-Authored-By: Claude Sonnet 4.6 * test: add smoke tests and polish sync logs tab Add frontend smoke tests for ButtonTabs, CustomFieldsTabPanel, SyncLogsFilters, SyncLogsTab, and the CustomFields wrapper. Switch SyncLogsTab to receive filters through useTable's filter prop so pagination resets on filter changes, display full date+time in the Timestamp column, and widen that column so values fit on one line. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: classify INVALID_PATH sync logs as warning instead of error Path-resolution failures are expected when entities lack an optional field, so they shouldn't be surfaced as errors in the sync logs tab. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: add visual empty state for custom fields and sync logs tables Replaces plain text empty states with a centered layout. The custom fields empty state shows two disabled input placeholders with an arrow icon to hint at the mapping concept; the sync logs empty state is centered using a Flex wrapper. Styles use makeStyles throughout. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 * fix: remove unused AutoMatchEntityMappingsResponse import Co-Authored-By: Claude Opus 4.8 (1M context) * feat: enable/disable custom fields (#170) Add a toggle to enable or disable a custom field from the Custom Fields tab. Disabled fields are filtered out of the enabled view and do not count against PagerDuty's custom field hard limit. Backend: - Add setCustomFieldEnabled to the DB store - Add PATCH /custom-fields/:id/enabled route + controller using the DB-first -> PagerDuty -> rollback-on-failure pattern - Add BackstageCustomFieldToggleEnabledRequest to common types - Add router tests covering disable, validation, 404, and rollback Frontend: - Add setCustomFieldEnabled to PagerDutyApi/PagerDutyClient + mock - Add Enable/Disable menu action with error snackbar and disabled-row styling in CustomFieldsTabPanel Co-authored-by: Claude Opus 4.8 (1M context) * feat: start/stop custom field data sync (DEVECO-537) (#171) * feat: start/stop custom field data sync (DEVECO-537) Wire up the org-wide data sync toggle so customers must opt in before any custom field values are pushed to PagerDuty during catalog processing. - Persist the toggle in the settings table under settings::data-sync (value enabled/disabled), validated separately by id in the router so the dependency-strategy setting is untouched. - Gate PagerDutyCustomFieldsProcessor on isDataSyncEnabled(); default to disabled when the setting was never saved (opt-in). - Wire the frontend Switch to React Query (useQuery read + useMutation write with optimistic update/rollback); unsaved is treated as disabled with no write on load. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: prevent SSRF via origin allow-list and path segment encoding Add an allow-list origin guard in fetchWithRetries that validates every outgoing request URL against configured PagerDuty API origins, rejecting any URL whose host isn't in the server-controlled set. Also encodeURIComponent each user-controlled path segment (serviceId, fieldId) and rebuild the getSerivcesByIdsAndAccount query string with URLSearchParams to eliminate raw interpolation sinks, satisfying CodeQL js/request-forgery (alert #8). Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.8 (1M context) * feat: delete custom field (DEVECO-534) (#172) Implements the delete custom field feature end-to-end: DELETE endpoint on the backend, PagerDuty API call to remove the global custom field, and a self-contained DeleteCustomFieldDialog component in the frontend that owns its own state and logic. Co-authored-by: Claude Sonnet 4.6 * feat: automated cleanup for custom field sync logs (#173) * feat: automated cleanup for custom field sync logs Adds a scheduled background task that keeps the sync logs table bounded: rows older than 30 days are deleted and a global hard cap of 500k rows trims the oldest entries beyond it. Deletes run in 10k batches via id-subquery (portable across SQLite and Postgres) so concurrent sync inserts are never blocked, and the task runs with a global scheduler lock so only one replica executes it. All thresholds are configurable under pagerDuty.customFieldsSyncLogs. Also adds a Refresh button next to Export CSV on the Sync Logs tab, a bare timestamp index needed by the TTL deletes, and fixes two pre-existing lint errors (duplicate describe title, explicit any). Co-Authored-By: Claude Fable 5 * fix: address PR review feedback for sync logs cleanup - Fix getSyncLogs total returning NaN (count property access lost when removing `as any`); type the count query via a generic instead. - Extract repeated batch sleep delay into BATCH_SLEEP_MS constant. - Make the batch-budget-exhausted warning name the config knobs to tune. - Extract SYNC_LOGS_CLEANUP_TASK_ID / timeout / initial-delay constants and share the task id between plugin.ts and plugin.test.ts. - Add coverage for the age-empties-table / cap-no-op path. - Fix stale olderThanMinutes -> olderThanDays in DB test options. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Fable 5 * refactor: adjust for Backstage 1.51.2 and port PagerDutyPage to @backstage/ui (#174) * refactor: adjust for Backstage 1.51.2 upgrade Bump Backstage version 1.47.3 -> 1.51.2 and adapt to breaking changes: - Move catalogProcessingExtensionPoint import out of /alpha - Drop removed variant="gridItem" prop from Entity* cards - Switch @backstage/ui to managed backstage:^ version - Add explicit blueprint defineParams type annotations in dev - Consolidate PagerDuty nav item into PageBlueprint title/icon params Co-Authored-By: Claude Opus 4.8 (1M context) * refactor: restructure PagerDuty page into Configuration and ServiceMapping pages Split the monolithic PagerDutyPage into three SubPageBlueprint extensions (Service Mapping, Custom Fields, Configuration) so each tab is lazily loaded and independently navigable via the Backstage frontend plugin system. Also switches `tableProps.loading` to `tableProps.isPending` in MappingsTableContent and SyncLogsTab to align with the @backstage/ui@0.15.0 API (loading is deprecated). Co-Authored-By: Claude Sonnet 4.6 * chore: bump ui-refactors prerelease versions to .8 Co-Authored-By: Claude Sonnet 4.6 * refactor: reposition alerts and tidy styles on PagerDuty pages Co-Authored-By: Claude Opus 4.8 (1M context) * chore: Fix edited package jsons * refactor: add PagerDuty service caching and batch insert chunking Wire a 20-minute CacheService layer into getAllServices and getServiceById so repeated calls within a request (auto-match, confirm, bulk mapping) hit the cache rather than the PagerDuty API. Also batches getSerivcesByIdsAndAccount into 50-id chunks to stay within API gateway URL-length limits, and chunks bulkInsertEntityMappings into 200-row transactions to avoid SQLite's 500-term compound SELECT limit. Co-Authored-By: Claude Sonnet 4.6 * fix: surface freshly-created mappings before processor writes annotations back The entity list read path computed status only when a PagerDuty service could be resolved from the entity's catalog annotations. A just-created mapping has a DB row but no annotation yet (the entity processor writes it back on the next catalog processing cycle), so the entity showed NotMapped despite a matching mapping row. When a stored mapping is matched by entityRef but the service can't be resolved from the (still-empty) annotations, fall back to the mapping's serviceId so the mapping appears immediately as OutOfSync and progresses to InSync once the annotation lands. Resolution reuses the already-fetched current-page services, so no extra API or DB calls are made. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: drop malformed service ids before batch PagerDuty lookup The entity-mapping page returned 400 "Failed to get service. Caller provided invalid arguments." whenever the status filter was set. Status is computed at request time, so that filter is the only path that calls getServicesByIds, which packed every collected service id into a single GET /services?id[]=... request. PagerDuty rejects the whole batch with a 400 if any one id is malformed (a pasted service URL, trailing whitespace, a typo), so one bad annotation failed the entire page. - Add isValidServiceId and sanitize ids in getSerivcesByIdsAndAccount before batching (removes the 400 trigger). - Make getServicesByIdsBatch treat a list-endpoint 400 as no matches rather than throwing; single-service lookups still throw on 400. - Fix getServicesByIds to accumulate results across accounts instead of overwriting (multi-account bug). - Thread a logger into the mappings controller and warn about malformed annotation ids so operators know which entity to fix. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: evict service cache after integration creation to prevent duplicates Pass the cache through to createServiceIntegration and delete the per-service and all-services cache entries immediately after a new integration is created, so the next read re-fetches fresh data instead of returning a stale integrations array that could cause duplicate Backstage integrations within the cache TTL. Co-Authored-By: Claude Sonnet 4.6 * fix: restore Custom Fields and Configuration tabs on legacy PagerDutyPage The @backstage/ui port reduced the legacy PagerDutyPage routable extension to rendering only ServiceMappingPage, silently dropping the Custom Fields and Configuration tabs for any consumer still on the old Frontend System. Restore all three tabs via TabbedLayout at their original paths (/service-mapping, /custom-fields, /settings), reusing the new @backstage/ui page components so the legacy path stays consistent with the alpha SubPageBlueprint path. Co-Authored-By: Claude Opus 4.8 (1M context) * feat: link mapping table entity names to their catalog page The Name column on the service-mapping page rendered the entity name as plain text. Add a NameCell that links to the entity's catalog page, mirroring ServiceCell's underlined link styling. Co-Authored-By: Claude Opus 4.8 (1M context) * feat: client-side nav for mapping names; handle 404 on custom-field delete NameCell now renders the entity name via core-components' router-aware Link (to=) instead of CellText's href, which emitted a plain and forced a full page reload when navigating to the catalog entity. customFieldsController treats a 404 from PagerDuty on delete as already-deleted and proceeds to remove the Backstage record so the two sides don't drift, with tests covering the success, missing-record, PD-404, and PD-500 paths. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: clarify mapping toast wording about async catalog re-processing Co-Authored-By: Claude Opus 4.8 (1M context) * feat: add "info" sync-log type for successful custom-field syncs Records a per-field SYNC_SUCCESS log when the entity processor pushes custom field values to PagerDuty, giving operators positive sync signal instead of only failures. Severity is computed from errorCode (not stored), so the binary error/warning derivation is reworked to three-way: warning now excludes both error and info codes in the backend query and the frontend badge. Adds a blue "Info" badge and a matching option in the Severity filter. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: address PR review findings on service caching and config save Resolves four findings from the PR #174 review: - getServicesByIdsBatch: set `limit` to the batch size on the `id[]` query so a full 50-id batch isn't truncated to PagerDuty's default 25-item page (services 26+ were silently dropped, leaving entities NotMapped). - getAllServices: treat an empty cached list as a valid hit (`cached != null`) so zero-service accounts use the cache instead of re-fetching the full paginated list on every call. - router POST /services/:serviceId/integration/:vendorId: pass `cache` to createServiceIntegration so the per-service and all-services cache entries are evicted after a standalone integration is created, preventing a stale read within the TTL from creating a duplicate. - ConfigurationPage: await storeSettings and, on failure, roll back the optimistic radio selection and surface an error alert instead of silently dropping the rejected promise. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * chore: Generate changese * fix: Linter issues --------- Co-authored-by: Renan Oliveira <97949554+renannoliveira@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- .changeset/wet-melons-swim.md | 8 + .yarn/plugins/@yarnpkg/plugin-backstage.cjs | 2 +- .yarnrc.yml | 4 +- backstage.json | 2 +- packages/app/package.json | 2 +- .../app/src/components/catalog/EntityPage.tsx | 41 +- packages/app/src/theme/tokens.css | 1 + plugins/backstage-plugin-backend/config.d.ts | 43 + ...13251_add_pagerduty_custom_fields_table.js | 31 + ...260313_add_custom_fields_unique_indexes.js | 27 + ...260515_add_custom_field_sync_logs_table.js | 32 + .../20260612_add_sync_logs_timestamp_index.js | 19 + .../src/apis/pagerduty.test.ts | 288 ++ .../src/apis/pagerduty.ts | 609 ++- .../controllers/mappings-controller.test.ts | 174 + .../src/controllers/mappings-controller.ts | 88 +- .../src/db/PagerDutyBackendDatabase.ts | 440 +- .../backstage-plugin-backend/src/db/index.ts | 6 +- .../src/plugin.test.ts | 41 + .../backstage-plugin-backend/src/plugin.ts | 26 + .../src/service/customFieldsController.ts | 627 +++ .../src/service/router.test.ts | 821 +++- .../src/service/router.ts | 93 +- .../src/services/autoMatchRunner.ts | 7 +- .../src/services/dataLoader.ts | 10 +- .../src/services/syncLogsCleanup.test.ts | 177 + .../src/services/syncLogsCleanup.ts | 107 + plugins/backstage-plugin-common/src/types.ts | 144 + .../src/apis/client.ts | 185 + .../src/module.ts | 7 +- .../PagerDutyCustomFieldsProcessor.test.ts | 172 + .../PagerDutyCustomFieldsProcessor.ts | 141 + .../src/processor/PagerDutyEntityProcessor.ts | 5 +- .../src/processor/extractEntityValue.test.ts | 97 + .../src/processor/extractEntityValue.ts | 80 + .../src/processor/index.ts | 1 + plugins/backstage-plugin/dev/index.tsx | 5 +- .../backstage-plugin/dev/mockPagerDutyApi.ts | 78 +- plugins/backstage-plugin/package.json | 2 +- .../backstage-plugin/src/alpha/nav-items.tsx | 14 - plugins/backstage-plugin/src/alpha/pages.tsx | 55 +- plugins/backstage-plugin/src/alpha/plugin.ts | 12 +- .../backstage-plugin/src/api/client.test.ts | 360 +- plugins/backstage-plugin/src/api/client.ts | 188 + plugins/backstage-plugin/src/api/types.ts | 60 + .../PagerDutyPage/ConfigurationPage.tsx | 131 + .../PagerDutyPage/CustomFieldModal.tsx | 204 + .../PagerDutyPage/CustomFields.test.tsx | 145 + .../components/PagerDutyPage/CustomFields.tsx | 238 + .../CustomFields/ButtonTabs.test.tsx | 31 + .../PagerDutyPage/CustomFields/ButtonTabs.tsx | 69 + .../CustomFields/CustomFieldsEmptyState.tsx | 52 + .../CustomFieldsTabPanel.test.tsx | 94 + .../CustomFields/CustomFieldsTabPanel.tsx | 170 + .../CustomFields/DeleteCustomFieldDialog.tsx | 85 + .../CustomFields/SyncLogsFilters.test.tsx | 103 + .../CustomFields/SyncLogsFilters.tsx | 144 + .../CustomFields/SyncLogsTab.test.tsx | 146 + .../CustomFields/SyncLogsTab.tsx | 325 ++ .../CustomFields/customFieldErrors.ts | 12 + .../MappingsTable/MappingsTable.tsx | 2 +- .../MappingsTable/MappingsTableContent.tsx | 17 +- .../PagerDutyPage/MappingsTable/NameCell.tsx | 50 + .../MappingsTable/TableSkeleton.tsx | 72 +- .../PagerDutyPage/ServiceMappingPage.tsx | 19 + .../src/components/PagerDutyPage/index.tsx | 191 +- yarn.lock | 3875 ++++++++--------- 67 files changed, 9206 insertions(+), 2301 deletions(-) create mode 100644 .changeset/wet-melons-swim.md create mode 100644 plugins/backstage-plugin-backend/migrations/20260225113251_add_pagerduty_custom_fields_table.js create mode 100644 plugins/backstage-plugin-backend/migrations/20260313_add_custom_fields_unique_indexes.js create mode 100644 plugins/backstage-plugin-backend/migrations/20260515_add_custom_field_sync_logs_table.js create mode 100644 plugins/backstage-plugin-backend/migrations/20260612_add_sync_logs_timestamp_index.js create mode 100644 plugins/backstage-plugin-backend/src/controllers/mappings-controller.test.ts create mode 100644 plugins/backstage-plugin-backend/src/plugin.test.ts create mode 100644 plugins/backstage-plugin-backend/src/service/customFieldsController.ts create mode 100644 plugins/backstage-plugin-backend/src/services/syncLogsCleanup.test.ts create mode 100644 plugins/backstage-plugin-backend/src/services/syncLogsCleanup.ts create mode 100644 plugins/backstage-plugin-entity-processor/src/processor/PagerDutyCustomFieldsProcessor.test.ts create mode 100644 plugins/backstage-plugin-entity-processor/src/processor/PagerDutyCustomFieldsProcessor.ts create mode 100644 plugins/backstage-plugin-entity-processor/src/processor/extractEntityValue.test.ts create mode 100644 plugins/backstage-plugin-entity-processor/src/processor/extractEntityValue.ts delete mode 100644 plugins/backstage-plugin/src/alpha/nav-items.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/ConfigurationPage.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFieldModal.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFields.test.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFields.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFields/ButtonTabs.test.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFields/ButtonTabs.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFields/CustomFieldsEmptyState.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFields/CustomFieldsTabPanel.test.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFields/CustomFieldsTabPanel.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFields/DeleteCustomFieldDialog.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFields/SyncLogsFilters.test.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFields/SyncLogsFilters.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFields/SyncLogsTab.test.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFields/SyncLogsTab.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/CustomFields/customFieldErrors.ts create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/MappingsTable/NameCell.tsx create mode 100644 plugins/backstage-plugin/src/components/PagerDutyPage/ServiceMappingPage.tsx diff --git a/.changeset/wet-melons-swim.md b/.changeset/wet-melons-swim.md new file mode 100644 index 0000000..10be4e7 --- /dev/null +++ b/.changeset/wet-melons-swim.md @@ -0,0 +1,8 @@ +--- +'@pagerduty/backstage-plugin-entity-processor': minor +'@pagerduty/backstage-plugin-backend': minor +'@pagerduty/backstage-plugin-common': minor +'@pagerduty/backstage-plugin': minor +--- + +Release the service mappings feature diff --git a/.yarn/plugins/@yarnpkg/plugin-backstage.cjs b/.yarn/plugins/@yarnpkg/plugin-backstage.cjs index b1dc26c..9b52e31 100644 --- a/.yarn/plugins/@yarnpkg/plugin-backstage.cjs +++ b/.yarn/plugins/@yarnpkg/plugin-backstage.cjs @@ -3,7 +3,7 @@ module.exports = { name: "@yarnpkg/plugin-backstage", factory: function (require) { -"use strict";var plugin=(()=>{var cr=Object.create;var A=Object.defineProperty;var ur=Object.getOwnPropertyDescriptor;var pr=Object.getOwnPropertyNames;var fr=Object.getPrototypeOf,lr=Object.prototype.hasOwnProperty;var u=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(r,t)=>(typeof require<"u"?require:r)[t]}):e)(function(e){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+e+'" is not supported')});var l=(e,r)=>()=>(r||e((r={exports:{}}).exports,r),r.exports),mr=(e,r)=>{for(var t in r)A(e,t,{get:r[t],enumerable:!0})},ae=(e,r,t,n)=>{if(r&&typeof r=="object"||typeof r=="function")for(let o of pr(r))!lr.call(e,o)&&o!==t&&A(e,o,{get:()=>r[o],enumerable:!(n=ur(r,o))||n.enumerable});return e};var B=(e,r,t)=>(t=e!=null?cr(fr(e)):{},ae(r||!e||!e.__esModule?A(t,"default",{value:e,enumerable:!0}):t,e)),gr=e=>ae(A({},"__esModule",{value:!0}),e);var me=l((pt,le)=>{le.exports=fe;fe.sync=Er;var ue=u("fs");function yr(e,r){var t=r.pathExt!==void 0?r.pathExt:process.env.PATHEXT;if(!t||(t=t.split(";"),t.indexOf("")!==-1))return!0;for(var n=0;n{ye.exports=de;de.sync=wr;var ge=u("fs");function de(e,r,t){ge.stat(e,function(n,o){t(n,n?!1:he(o,r))})}function wr(e,r){return he(ge.statSync(e),r)}function he(e,r){return e.isFile()&&xr(e,r)}function xr(e,r){var t=e.mode,n=e.uid,o=e.gid,s=r.uid!==void 0?r.uid:process.getuid&&process.getuid(),a=r.gid!==void 0?r.gid:process.getgid&&process.getgid(),i=parseInt("100",8),c=parseInt("010",8),p=parseInt("001",8),h=i|c,m=t&p||t&c&&o===a||t&i&&n===s||t&h&&s===0;return m}});var xe=l((mt,we)=>{var lt=u("fs"),N;process.platform==="win32"||global.TESTING_WINDOWS?N=me():N=Ee();we.exports=V;V.sync=kr;function V(e,r,t){if(typeof r=="function"&&(t=r,r={}),!t){if(typeof Promise!="function")throw new TypeError("callback not provided");return new Promise(function(n,o){V(e,r||{},function(s,a){s?o(s):n(a)})})}N(e,r||{},function(n,o){n&&(n.code==="EACCES"||r&&r.ignoreErrors)&&(n=null,o=!1),t(n,o)})}function kr(e,r){try{return N.sync(e,r||{})}catch(t){if(r&&r.ignoreErrors||t.code==="EACCES")return!1;throw t}}});var Oe=l((gt,Se)=>{var x=process.platform==="win32"||process.env.OSTYPE==="cygwin"||process.env.OSTYPE==="msys",ke=u("path"),vr=x?";":":",ve=xe(),be=e=>Object.assign(new Error(`not found: ${e}`),{code:"ENOENT"}),Re=(e,r)=>{let t=r.colon||vr,n=e.match(/\//)||x&&e.match(/\\/)?[""]:[...x?[process.cwd()]:[],...(r.path||process.env.PATH||"").split(t)],o=x?r.pathExt||process.env.PATHEXT||".EXE;.CMD;.BAT;.COM":"",s=x?o.split(t):[""];return x&&e.indexOf(".")!==-1&&s[0]!==""&&s.unshift(""),{pathEnv:n,pathExt:s,pathExtExe:o}},Pe=(e,r,t)=>{typeof r=="function"&&(t=r,r={}),r||(r={});let{pathEnv:n,pathExt:o,pathExtExe:s}=Re(e,r),a=[],i=p=>new Promise((h,m)=>{if(p===n.length)return r.all&&a.length?h(a):m(be(e));let g=n[p],b=/^".*"$/.test(g)?g.slice(1,-1):g,w=ke.join(b,e),U=!b&&/^\.[\\\/]/.test(e)?e.slice(0,2)+w:w;h(c(U,p,0))}),c=(p,h,m)=>new Promise((g,b)=>{if(m===o.length)return g(i(h+1));let w=o[m];ve(p+w,{pathExt:s},(U,ar)=>{if(!U&&ar)if(r.all)a.push(p+w);else return g(p+w);return g(c(p,h,m+1))})});return t?i(0).then(p=>t(null,p),t):i(0)},br=(e,r)=>{r=r||{};let{pathEnv:t,pathExt:n,pathExtExe:o}=Re(e,r),s=[];for(let a=0;a{"use strict";var Te=(e={})=>{let r=e.env||process.env;return(e.platform||process.platform)!=="win32"?"PATH":Object.keys(r).reverse().find(n=>n.toUpperCase()==="PATH")||"Path"};F.exports=Te;F.exports.default=Te});var Ne=l((ht,Be)=>{"use strict";var $e=u("path"),Rr=Oe(),Pr=Ce();function Ae(e,r){let t=e.options.env||process.env,n=process.cwd(),o=e.options.cwd!=null,s=o&&process.chdir!==void 0&&!process.chdir.disabled;if(s)try{process.chdir(e.options.cwd)}catch{}let a;try{a=Rr.sync(e.command,{path:t[Pr({env:t})],pathExt:r?$e.delimiter:void 0})}catch{}finally{s&&process.chdir(n)}return a&&(a=$e.resolve(o?e.options.cwd:"",a)),a}function Sr(e){return Ae(e)||Ae(e,!0)}Be.exports=Sr});var De=l((yt,W)=>{"use strict";var M=/([()\][%!^"`<>&|;, *?])/g;function Or(e){return e=e.replace(M,"^$1"),e}function Tr(e,r){return e=`${e}`,e=e.replace(/(?=(\\+?)?)\1"/g,'$1$1\\"'),e=e.replace(/(?=(\\+?)?)\1$/,"$1$1"),e=`"${e}"`,e=e.replace(M,"^$1"),r&&(e=e.replace(M,"^$1")),e}W.exports.command=Or;W.exports.argument=Tr});var je=l((Et,Le)=>{"use strict";Le.exports=/^#!(.*)/});var Ie=l((wt,Ue)=>{"use strict";var Cr=je();Ue.exports=(e="")=>{let r=e.match(Cr);if(!r)return null;let[t,n]=r[0].replace(/#! ?/,"").split(" "),o=t.split("/").pop();return o==="env"?n:n?`${o} ${n}`:o}});var Ve=l((xt,_e)=>{"use strict";var H=u("fs"),$r=Ie();function Ar(e){let t=Buffer.alloc(150),n;try{n=H.openSync(e,"r"),H.readSync(n,t,0,150,0),H.closeSync(n)}catch{}return $r(t.toString())}_e.exports=Ar});var He=l((kt,We)=>{"use strict";var Br=u("path"),Fe=Ne(),Me=De(),Nr=Ve(),Dr=process.platform==="win32",Lr=/\.(?:com|exe)$/i,jr=/node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i;function Ur(e){e.file=Fe(e);let r=e.file&&Nr(e.file);return r?(e.args.unshift(e.file),e.command=r,Fe(e)):e.file}function Ir(e){if(!Dr)return e;let r=Ur(e),t=!Lr.test(r);if(e.options.forceShell||t){let n=jr.test(r);e.command=Br.normalize(e.command),e.command=Me.command(e.command),e.args=e.args.map(s=>Me.argument(s,n));let o=[e.command].concat(e.args).join(" ");e.args=["/d","/s","/c",`"${o}"`],e.command=process.env.comspec||"cmd.exe",e.options.windowsVerbatimArguments=!0}return e}function _r(e,r,t){r&&!Array.isArray(r)&&(t=r,r=null),r=r?r.slice(0):[],t=Object.assign({},t);let n={command:e,args:r,options:t,file:void 0,original:{command:e,args:r}};return t.shell?n:Ir(n)}We.exports=_r});var ze=l((vt,Ge)=>{"use strict";var q=process.platform==="win32";function G(e,r){return Object.assign(new Error(`${r} ${e.command} ENOENT`),{code:"ENOENT",errno:"ENOENT",syscall:`${r} ${e.command}`,path:e.command,spawnargs:e.args})}function Vr(e,r){if(!q)return;let t=e.emit;e.emit=function(n,o){if(n==="exit"){let s=qe(o,r);if(s)return t.call(e,"error",s)}return t.apply(e,arguments)}}function qe(e,r){return q&&e===1&&!r.file?G(r.original,"spawn"):null}function Fr(e,r){return q&&e===1&&!r.file?G(r.original,"spawnSync"):null}Ge.exports={hookChildProcess:Vr,verifyENOENT:qe,verifyENOENTSync:Fr,notFoundError:G}});var Ke=l((bt,k)=>{"use strict";var Je=u("child_process"),z=He(),J=ze();function Ye(e,r,t){let n=z(e,r,t),o=Je.spawn(n.command,n.args,n.options);return J.hookChildProcess(o,n),o}function Mr(e,r,t){let n=z(e,r,t),o=Je.spawnSync(n.command,n.args,n.options);return o.error=o.error||J.verifyENOENTSync(o.status,n),o}k.exports=Ye;k.exports.spawn=Ye;k.exports.sync=Mr;k.exports._parse=z;k.exports._enoent=J});var Qe=l((Pt,Xe)=>{"use strict";var Y=class e extends Error{constructor(r){super(e._prepareSuperMessage(r)),Object.defineProperty(this,"name",{value:"NonError",configurable:!0,writable:!0}),Error.captureStackTrace&&Error.captureStackTrace(this,e)}static _prepareSuperMessage(r){try{return JSON.stringify(r)}catch{return String(r)}}},Wr=[{property:"name",enumerable:!1},{property:"message",enumerable:!1},{property:"stack",enumerable:!1},{property:"code",enumerable:!0}],K=Symbol(".toJSON called"),Hr=e=>{e[K]=!0;let r=e.toJSON();return delete e[K],r},X=({from:e,seen:r,to_:t,forceEnumerable:n,maxDepth:o,depth:s})=>{let a=t||(Array.isArray(e)?[]:{});if(r.push(e),s>=o)return a;if(typeof e.toJSON=="function"&&e[K]!==!0)return Hr(e);for(let[i,c]of Object.entries(e)){if(typeof Buffer=="function"&&Buffer.isBuffer(c)){a[i]="[object Buffer]";continue}if(typeof c!="function"){if(!c||typeof c!="object"){a[i]=c;continue}if(!r.includes(e[i])){s++,a[i]=X({from:e[i],seen:r.slice(),forceEnumerable:n,maxDepth:o,depth:s});continue}a[i]="[Circular]"}}for(let{property:i,enumerable:c}of Wr)typeof e[i]=="string"&&Object.defineProperty(a,i,{value:e[i],enumerable:n?!0:c,configurable:!0,writable:!0});return a},qr=(e,r={})=>{let{maxDepth:t=Number.POSITIVE_INFINITY}=r;return typeof e=="object"&&e!==null?X({from:e,seen:[],forceEnumerable:!0,maxDepth:t,depth:0}):typeof e=="function"?`[Function: ${e.name||"anonymous"}]`:e},Gr=(e,r={})=>{let{maxDepth:t=Number.POSITIVE_INFINITY}=r;if(e instanceof Error)return e;if(typeof e=="object"&&e!==null&&!Array.isArray(e)){let n=new Error;return X({from:e,seen:[],to_:n,maxDepth:t,depth:0}),n}return new Y(e)};Xe.exports={serializeError:qr,deserializeError:Gr}});var st={};mr(st,{default:()=>ot});var j=u("@yarnpkg/core");var C=u("@yarnpkg/core");var Q=B(u("assert")),nr=u("semver"),L=u("@yarnpkg/fslib");var R=B(u("fs")),d=u("path");function ce(e,r){let t=e;for(let n=0;n<1e3;n++){let o=(0,d.resolve)(t,"package.json");if(R.default.existsSync(o)&&r(o))return t;let a=(0,d.dirname)(t);if(a===t)return;t=a}throw new Error(`Iteration limit reached when searching for root package.json at ${e}`)}function dr(e){let r=ce(e,()=>!0);if(!r)throw new Error(`No package.json found while searching for package root of ${e}`);return r}function hr(e){if(!R.default.existsSync((0,d.resolve)(e,"src")))throw new Error("Tried to access monorepo package root dir outside of Backstage repository");return(0,d.resolve)(e,"../..")}function I(e){let r=dr(e),t=R.default.realpathSync(process.cwd()).replace(/^[a-z]:/,i=>i.toLocaleUpperCase("en-US")),n="",o=()=>(n||(n=hr(r)),n),s="",a=()=>(s||(s=ce(t,i=>{try{let c=R.default.readFileSync(i,"utf8");return!!JSON.parse(c).workspaces}catch(c){throw new Error(`Failed to parse package.json file while searching for root, ${c}`)}})??t),s);return{ownDir:r,get ownRoot(){return o()},targetDir:t,get targetRoot(){return a()},resolveOwn:(...i)=>(0,d.resolve)(r,...i),resolveOwnRoot:(...i)=>(0,d.resolve)(o(),...i),resolveTarget:(...i)=>(0,d.resolve)(t,...i),resolveTargetRoot:(...i)=>(0,d.resolve)(a(),...i)}}var _="backstage.json";var Jr=B(Ke());function v(e){if(typeof e!="object"||e===null||Array.isArray(e))return!1;let r=e;return!(typeof r.name!="string"||r.name===""||typeof r.message!="string")}var Ze=B(Qe());function er(e){if(v(e)){let r=String(e);return r!=="[object Object]"?r:`${e.name}: ${e.message}`}return`unknown error '${e}'`}var D=class extends Error{cause;constructor(r,t){let n=r;if(t!==void 0){let o=er(t);n?n+=`; caused by ${o}`:n=`caused by ${o}`}super(n),Error.captureStackTrace?.(this,this.constructor),(!this.name||this.name==="Error")&&this.constructor.name!=="Error"&&(this.name=this.constructor.name),this.cause=v(t)?t:void 0}};var P=class extends D{constructor(r,t){super(r,t),this.name=v(t)?t.name:"Error"}};var rr=e=>{let r=!1,t;return()=>(r||(t=e(),r=!0),t)};var S=u("@yarnpkg/fslib");var tr=()=>S.npath.toPortablePath(I(S.npath.fromPortablePath(S.ppath.cwd())).targetRoot);var O=rr(()=>{let e=L.ppath.join(tr(),_),r=null;try{let t=L.xfs.readJsonSync(e).version;(0,Q.default)(t!==void 0,"Version field is missing"),r=(0,nr.valid)(t),(0,Q.default)(r!==null,"Version exists but is not valid semver")}catch(t){throw new P("Valid version string not found in backstage.json",t)}return r});var T=u("@yarnpkg/core"),or=u("@yarnpkg/fslib");var Yr="https://versions.backstage.io",Kr="https://raw.githubusercontent.com/backstage/versions/main";function Xr(e,r){return new Promise((t,n)=>{let o=setTimeout(()=>{r.aborted||t()},e);r.addEventListener("abort",()=>{clearTimeout(o),n(new Error("Aborted"))})})}async function Qr(e,r,t){let n=new AbortController,o=new AbortController,s=e(n.signal).then(i=>(o.abort(),i)),a=Xr(t,o.signal).then(()=>r(o.signal)).then(i=>(n.abort(),i));return Promise.any([s,a]).catch(()=>s)}async function Z(e){let r=encodeURIComponent(e.version),t=e.fetch??fetch,n=e.versionsBaseUrl??Yr,o=e.gitHubRawBaseUrl??Kr,s=await Qr(a=>t(`${n}/v1/releases/${r}/manifest.json`,{signal:a}),a=>t(`${o}/v1/releases/${r}/manifest.json`,{signal:a}),500);if(s.status===404)throw new Error(`No release found for ${e.version} version`);if(s.status!==200)throw new Error(`Unexpected response status ${s.status} when fetching release from ${s.url}.`);return s.json()}var f="backstage:";var ee=u("process"),E=async(e,r)=>{let t=T.structUtils.stringifyIdent(e),n=T.structUtils.parseRange(e.range);if(n.protocol!==f)throw new Error(`Unsupported version protocol in version range "${e.range}" for package ${t}`);if(n.selector!=="^")throw new Error(`Unexpected version selector "${n.selector}" for package ${t}`);let o=O(),s=ee.env.BACKSTAGE_MANIFEST_FILE,i=(s?await or.xfs.readJsonSync(s):await Z({version:o,versionsBaseUrl:ee.env.BACKSTAGE_VERSIONS_BASE_URL,fetch:async c=>{let p=await T.httpUtils.get(c,{configuration:r,jsonResponse:!0});return{status:200,url:c,json:()=>p}}})).packages.find(c=>c.name===t);if(!i)throw new Error(`Package ${t} not found in manifest for Backstage v${o}. This means the specified package is not included in this Backstage release. This may imply the package has been replaced with an alternative - please review the documentation for the package. If you need to continue using this package, it will be necessary to switch to manually managing its version.`);return i.version};var Zr=e=>C.structUtils.parseRange(e).protocol===f,et=(e,r,t)=>e!=="dependencies"?e:t.manifest.ensureDependencyMeta(C.structUtils.makeDescriptor(r,"unknown")).optional?"optionalDependencies":e,re=async(e,r)=>{for(let t of["dependencies","devDependencies"]){let n=Array.from(e.manifest.getForScope(t).values()).filter(o=>o.range.startsWith(f));for(let o of n){let s=C.structUtils.stringifyIdent(o);if(C.structUtils.parseRange(o.range).selector!=="^")throw new Error(`Unexpected version range "${o.range}" for dependency on "${s}"`);let i=et(t,o,e);r[i][s]=`^${await E(o,e.project.configuration)}`}}if(["dependencies","devDependencies","optionalDependencies"].some(t=>Object.values(r[t]??{}).some(Zr)))throw new Error(`Failed to replace all "backstage:" ranges in manifest for ${r.name}`)};var te=u("@yarnpkg/core");var ne=async(e,r)=>{let t=te.structUtils.parseRange(e.range);if(t.protocol!==f)return e;if(t.selector!=="^")throw new Error(`Invalid backstage: version range found: ${e.range}`);return te.structUtils.bindDescriptor(e,{backstage:O(),npm:await E(e,r.configuration)})};var sr=u("@yarnpkg/core");var oe=async(e,r,t,n)=>{let o=sr.structUtils.parseRange(t.range);if(t.scope==="backstage"&&o.protocol!==f){let s=t.range;try{t.range=`${f}^`,await E(t,e.project.configuration),console.info(`Setting ${t.scope}/${t.name} to ${f}^`)}catch{t.range=s}}};var ir=u("@yarnpkg/core");var se=async(e,r,t,n)=>{let o=ir.structUtils.parseRange(n.range);n.scope==="backstage"&&o.protocol!==f&&console.warn(`${n.name} should be set to "${f}^" instead of "${n.range}". Make sure this change is intentional and not a mistake.`)};var y=u("@yarnpkg/core"),ie=u("@yarnpkg/plugin-npm");var $=class e{static protocol=f;supportsDescriptor=r=>r.range.startsWith(e.protocol);async getCandidates(r,t,n){let o=y.structUtils.parseRange(r.range).params?.npm;if(!o||Array.isArray(o))throw new Error(`Missing npm parameter on backstage: range "${r.range}"`);return new ie.NpmSemverResolver().getCandidates(y.structUtils.makeDescriptor(r,`npm:^${o}`),t,n)}getResolutionDependencies(r){let t=y.structUtils.parseRange(r.range).params?.npm;if(!t)throw new Error(`Missing npm parameter on backstage: range "${r.range}".`);return{[y.structUtils.stringifyIdent(r)]:y.structUtils.makeDescriptor(r,`npm:^${t}`)}}async getSatisfying(r,t,n,o){let s=r,a=y.structUtils.parseRange(s.range);if(a.protocol===f){let i=a.params?.npm;s=y.structUtils.makeDescriptor(r,`npm:^${i}`)}return new ie.NpmSemverResolver().getSatisfying(s,t,n,o)}bindDescriptor=r=>r;supportsLocator=()=>!1;shouldPersistResolution=()=>{throw new Error("Unreachable: BackstageNpmResolver should never persist resolution as it uses npm: protocol")};resolve=async()=>{throw new Error("Unreachable: BackstageNpmResolver should never resolve as it uses npm: protocol")}};var rt="\x1B[31;1m",tt="\x1B[0m";j.semverUtils.satisfiesWithPrereleases(j.YarnVersion,"^4.1.1")||(console.error(),console.error(`${rt}Unsupported yarn version${tt}: The Backstage yarn plugin only works with yarn ^4.1.1. Please upgrade yarn, or remove this plugin with "yarn plugin remove @yarnpkg/plugin-backstage".`),console.error());var nt={hooks:{afterWorkspaceDependencyAddition:oe,afterWorkspaceDependencyReplacement:se,reduceDependency:ne,beforeWorkspacePacking:re},resolvers:[$]},ot=nt;return gr(st);})(); +"use strict";var plugin=(()=>{var nr=Object.create;var C=Object.defineProperty;var or=Object.getOwnPropertyDescriptor;var sr=Object.getOwnPropertyNames;var ir=Object.getPrototypeOf,ar=Object.prototype.hasOwnProperty;var a=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(r,t)=>(typeof require<"u"?require:r)[t]}):e)(function(e){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+e+'" is not supported')});var l=(e,r)=>()=>(r||e((r={exports:{}}).exports,r),r.exports),cr=(e,r)=>{for(var t in r)C(e,t,{get:r[t],enumerable:!0})},se=(e,r,t,n)=>{if(r&&typeof r=="object"||typeof r=="function")for(let o of sr(r))!ar.call(e,o)&&o!==t&&C(e,o,{get:()=>r[o],enumerable:!(n=or(r,o))||n.enumerable});return e};var _=(e,r,t)=>(t=e!=null?nr(ir(e)):{},se(r||!e||!e.__esModule?C(t,"default",{value:e,enumerable:!0}):t,e)),ur=e=>se(C({},"__esModule",{value:!0}),e);var pe=l((et,ue)=>{ue.exports=ce;ce.sync=lr;var ie=a("fs");function fr(e,r){var t=r.pathExt!==void 0?r.pathExt:process.env.PATHEXT;if(!t||(t=t.split(";"),t.indexOf("")!==-1))return!0;for(var n=0;n{me.exports=le;le.sync=gr;var fe=a("fs");function le(e,r,t){fe.stat(e,function(n,o){t(n,n?!1:ge(o,r))})}function gr(e,r){return ge(fe.statSync(e),r)}function ge(e,r){return e.isFile()&&mr(e,r)}function mr(e,r){var t=e.mode,n=e.uid,o=e.gid,s=r.uid!==void 0?r.uid:process.getuid&&process.getuid(),i=r.gid!==void 0?r.gid:process.getgid&&process.getgid(),c=parseInt("100",8),f=parseInt("010",8),u=parseInt("001",8),d=c|f,g=t&u||t&f&&o===i||t&c&&n===s||t&d&&s===0;return g}});var we=l((nt,he)=>{var tt=a("fs"),A;process.platform==="win32"||global.TESTING_WINDOWS?A=pe():A=de();he.exports=F;F.sync=dr;function F(e,r,t){if(typeof r=="function"&&(t=r,r={}),!t){if(typeof Promise!="function")throw new TypeError("callback not provided");return new Promise(function(n,o){F(e,r||{},function(s,i){s?o(s):n(i)})})}A(e,r||{},function(n,o){n&&(n.code==="EACCES"||r&&r.ignoreErrors)&&(n=null,o=!1),t(n,o)})}function dr(e,r){try{return A.sync(e,r||{})}catch(t){if(r&&r.ignoreErrors||t.code==="EACCES")return!1;throw t}}});var Re=l((ot,Pe)=>{var x=process.platform==="win32"||process.env.OSTYPE==="cygwin"||process.env.OSTYPE==="msys",Ee=a("path"),hr=x?";":":",ye=we(),ve=e=>Object.assign(new Error(`not found: ${e}`),{code:"ENOENT"}),xe=(e,r)=>{let t=r.colon||hr,n=e.match(/\//)||x&&e.match(/\\/)?[""]:[...x?[process.cwd()]:[],...(r.path||process.env.PATH||"").split(t)],o=x?r.pathExt||process.env.PATHEXT||".EXE;.CMD;.BAT;.COM":"",s=x?o.split(t):[""];return x&&e.indexOf(".")!==-1&&s[0]!==""&&s.unshift(""),{pathEnv:n,pathExt:s,pathExtExe:o}},ke=(e,r,t)=>{typeof r=="function"&&(t=r,r={}),r||(r={});let{pathEnv:n,pathExt:o,pathExtExe:s}=xe(e,r),i=[],c=u=>new Promise((d,g)=>{if(u===n.length)return r.all&&i.length?d(i):g(ve(e));let m=n[u],P=/^".*"$/.test(m)?m.slice(1,-1):m,y=Ee.join(P,e),L=!P&&/^\.[\\\/]/.test(e)?e.slice(0,2)+y:y;d(f(L,u,0))}),f=(u,d,g)=>new Promise((m,P)=>{if(g===o.length)return m(c(d+1));let y=o[g];ye(u+y,{pathExt:s},(L,tr)=>{if(!L&&tr)if(r.all)i.push(u+y);else return m(u+y);return m(f(u,d,g+1))})});return t?c(0).then(u=>t(null,u),t):c(0)},wr=(e,r)=>{r=r||{};let{pathEnv:t,pathExt:n,pathExtExe:o}=xe(e,r),s=[];for(let i=0;i{"use strict";var Oe=(e={})=>{let r=e.env||process.env;return(e.platform||process.platform)!=="win32"?"PATH":Object.keys(r).reverse().find(n=>n.toUpperCase()==="PATH")||"Path"};I.exports=Oe;I.exports.default=Oe});var $e=l((it,Ce)=>{"use strict";var be=a("path"),Er=Re(),yr=Se();function Te(e,r){let t=e.options.env||process.env,n=process.cwd(),o=e.options.cwd!=null,s=o&&process.chdir!==void 0&&!process.chdir.disabled;if(s)try{process.chdir(e.options.cwd)}catch{}let i;try{i=Er.sync(e.command,{path:t[yr({env:t})],pathExt:r?be.delimiter:void 0})}catch{}finally{s&&process.chdir(n)}return i&&(i=be.resolve(o?e.options.cwd:"",i)),i}function vr(e){return Te(e)||Te(e,!0)}Ce.exports=vr});var Ae=l((at,M)=>{"use strict";var W=/([()\][%!^"`<>&|;, *?])/g;function xr(e){return e=e.replace(W,"^$1"),e}function kr(e,r){return e=`${e}`,e=e.replace(/(?=(\\+?)?)\1"/g,'$1$1\\"'),e=e.replace(/(?=(\\+?)?)\1$/,"$1$1"),e=`"${e}"`,e=e.replace(W,"^$1"),r&&(e=e.replace(W,"^$1")),e}M.exports.command=xr;M.exports.argument=kr});var Ne=l((ct,De)=>{"use strict";De.exports=/^#!(.*)/});var Le=l((ut,Be)=>{"use strict";var Pr=Ne();Be.exports=(e="")=>{let r=e.match(Pr);if(!r)return null;let[t,n]=r[0].replace(/#! ?/,"").split(" "),o=t.split("/").pop();return o==="env"?n:n?`${o} ${n}`:o}});var Ue=l((pt,_e)=>{"use strict";var H=a("fs"),Rr=Le();function Or(e){let t=Buffer.alloc(150),n;try{n=H.openSync(e,"r"),H.readSync(n,t,0,150,0),H.closeSync(n)}catch{}return Rr(t.toString())}_e.exports=Or});var Ie=l((ft,Fe)=>{"use strict";var Sr=a("path"),je=$e(),Ve=Ae(),br=Ue(),Tr=process.platform==="win32",Cr=/\.(?:com|exe)$/i,$r=/node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i;function Ar(e){e.file=je(e);let r=e.file&&br(e.file);return r?(e.args.unshift(e.file),e.command=r,je(e)):e.file}function Dr(e){if(!Tr)return e;let r=Ar(e),t=!Cr.test(r);if(e.options.forceShell||t){let n=$r.test(r);e.command=Sr.normalize(e.command),e.command=Ve.command(e.command),e.args=e.args.map(s=>Ve.argument(s,n));let o=[e.command].concat(e.args).join(" ");e.args=["/d","/s","/c",`"${o}"`],e.command=process.env.comspec||"cmd.exe",e.options.windowsVerbatimArguments=!0}return e}function Nr(e,r,t){r&&!Array.isArray(r)&&(t=r,r=null),r=r?r.slice(0):[],t=Object.assign({},t);let n={command:e,args:r,options:t,file:void 0,original:{command:e,args:r}};return t.shell?n:Dr(n)}Fe.exports=Nr});var He=l((lt,Me)=>{"use strict";var G=process.platform==="win32";function q(e,r){return Object.assign(new Error(`${r} ${e.command} ENOENT`),{code:"ENOENT",errno:"ENOENT",syscall:`${r} ${e.command}`,path:e.command,spawnargs:e.args})}function Br(e,r){if(!G)return;let t=e.emit;e.emit=function(n,o){if(n==="exit"){let s=We(o,r);if(s)return t.call(e,"error",s)}return t.apply(e,arguments)}}function We(e,r){return G&&e===1&&!r.file?q(r.original,"spawn"):null}function Lr(e,r){return G&&e===1&&!r.file?q(r.original,"spawnSync"):null}Me.exports={hookChildProcess:Br,verifyENOENT:We,verifyENOENTSync:Lr,notFoundError:q}});var Xe=l((gt,k)=>{"use strict";var Ge=a("child_process"),X=Ie(),Y=He();function qe(e,r,t){let n=X(e,r,t),o=Ge.spawn(n.command,n.args,n.options);return Y.hookChildProcess(o,n),o}function _r(e,r,t){let n=X(e,r,t),o=Ge.spawnSync(n.command,n.args,n.options);return o.error=o.error||Y.verifyENOENTSync(o.status,n),o}k.exports=qe;k.exports.spawn=qe;k.exports.sync=_r;k.exports._parse=X;k.exports._enoent=Y});var Jr={};cr(Jr,{default:()=>Yr});var B=a("@yarnpkg/core");var b=a("@yarnpkg/core");var K=_(a("assert")),Qe=a("semver"),N=a("@yarnpkg/fslib");var $=_(a("fs")),v=a("path");function pr(e,r){let t=e;for(let n=0;n<1e3;n++){let o=(0,v.resolve)(t,"package.json");if($.default.existsSync(o)&&r(o))return t;let i=(0,v.dirname)(t);if(i===t)return;t=i}throw new Error(`Iteration limit reached when searching for root package.json at ${e}`)}var h;var U=class{#t;#r;#e;get dir(){if(h)return h.dir;let r=process.cwd();return this.#r!==void 0&&this.#t===r?this.#r:(this.#t=r,this.#e=void 0,this.#r=$.default.realpathSync(r).replace(/^[a-z]:/,t=>t.toLocaleUpperCase("en-US")),this.#r)}get rootDir(){if(h)return h.rootDir;let r=this.dir;return this.#e!==void 0?this.#e:(this.#e=pr(r,t=>{try{let n=$.default.readFileSync(t,"utf8");return!!JSON.parse(n).workspaces}catch(n){throw new Error(`Failed to parse package.json file while searching for root, ${n}`)}})??r,this.#e)}resolve=(...r)=>h?h.resolve(...r):(0,v.resolve)(this.dir,...r);resolveRoot=(...r)=>h?h.resolveRoot(...r):(0,v.resolve)(this.rootDir,...r)},j=new U;var V="backstage.json";var jr=_(Xe());function J(e){if(typeof e!="object"||e===null||Array.isArray(e))return!1;let r=e;return!(typeof r.name!="string"||r.name===""||typeof r.message!="string")}function Ye(e){if(J(e))return e;if(typeof e=="string")return new Error(e);try{return new Error(`unknown error '${e}'`)}catch{return new Error(`unknown error of type '${typeof e}'`)}}var D=class extends Error{cause;constructor(r,t){let n=t!==void 0?Ye(t):void 0,o=r;if(n!==void 0){let s=String(n),i=s!=="[object Object]"?s:`${n.name}: ${n.message}`;o?o+=`; caused by ${i}`:o=`caused by ${i}`}super(o),Error.captureStackTrace?.(this,this.constructor),(!this.name||this.name==="Error")&&this.constructor.name!=="Error"&&(this.name=this.constructor.name),this.cause=n}};var R=class extends D{constructor(r,t){super(r,t),this.name=J(t)?t.name:"Error"}};var Je=e=>{let r=!1,t;return()=>(r||(t=e(),r=!0),t)};var Ke=a("@yarnpkg/fslib");var ze=()=>Ke.npath.toPortablePath(j.rootDir);var O=Je(()=>{let e=N.ppath.join(ze(),V),r=null;try{let t=N.xfs.readJsonSync(e).version;(0,K.default)(t!==void 0,"Version field is missing"),r=(0,Qe.valid)(t),(0,K.default)(r!==null,"Version exists but is not valid semver")}catch(t){throw new R("Valid version string not found in backstage.json",t)}return r});var S=a("@yarnpkg/core"),Ze=a("@yarnpkg/fslib");var Vr="https://versions.backstage.io",Fr="https://raw.githubusercontent.com/backstage/versions/main";function Ir(e,r){return new Promise((t,n)=>{let o=setTimeout(()=>{r.aborted||t()},e);r.addEventListener("abort",()=>{clearTimeout(o),n(new Error("Aborted"))})})}async function Wr(e,r,t){let n=new AbortController,o=new AbortController,s=e(n.signal).then(c=>(o.abort(),c)),i=Ir(t,o.signal).then(()=>r(o.signal)).then(c=>(n.abort(),c));return Promise.any([s,i]).catch(()=>s)}async function z(e){let r=encodeURIComponent(e.version),t=e.fetch??fetch,n=e.versionsBaseUrl??Vr,o=e.gitHubRawBaseUrl??Fr,s=await Wr(i=>t(`${n}/v1/releases/${r}/manifest.json`,{signal:i}),i=>t(`${o}/v1/releases/${r}/manifest.json`,{signal:i}),500);if(s.status===404)throw new Error(`No release found for ${e.version} version`);if(s.status!==200)throw new Error(`Unexpected response status ${s.status} when fetching release from ${s.url}.`);return s.json()}var p="backstage:";var Q=a("process"),E=async(e,r)=>{let t=S.structUtils.stringifyIdent(e),n=S.structUtils.parseRange(e.range);if(n.protocol!==p)throw new Error(`Unsupported version protocol in version range "${e.range}" for package ${t}`);if(n.selector!=="^")throw new Error(`Unexpected version selector "${n.selector}" for package ${t}`);let o=O(),s=Q.env.BACKSTAGE_MANIFEST_FILE,c=(s?await Ze.xfs.readJsonSync(s):await z({version:o,versionsBaseUrl:Q.env.BACKSTAGE_VERSIONS_BASE_URL,fetch:async f=>{let u=await S.httpUtils.get(f,{configuration:r,jsonResponse:!0});return{status:200,url:f,json:()=>u}}})).packages.find(f=>f.name===t);if(!c)throw new Error(`Package ${t} not found in manifest for Backstage v${o}. This means the specified package is not included in this Backstage release. This may imply the package has been replaced with an alternative - please review the documentation for the package. If you need to continue using this package, it will be necessary to switch to manually managing its version.`);return c.version};var Mr=e=>b.structUtils.parseRange(e).protocol===p,Hr=(e,r,t)=>e!=="dependencies"?e:t.manifest.ensureDependencyMeta(b.structUtils.makeDescriptor(r,"unknown")).optional?"optionalDependencies":e,Z=async(e,r)=>{for(let t of["dependencies","devDependencies"]){let n=Array.from(e.manifest.getForScope(t).values()).filter(o=>o.range.startsWith(p));for(let o of n){let s=b.structUtils.stringifyIdent(o);if(b.structUtils.parseRange(o.range).selector!=="^")throw new Error(`Unexpected version range "${o.range}" for dependency on "${s}"`);let c=Hr(t,o,e);r[c][s]=`^${await E(o,e.project.configuration)}`}}if(["dependencies","devDependencies","optionalDependencies"].some(t=>Object.values(r[t]??{}).some(Mr)))throw new Error(`Failed to replace all "backstage:" ranges in manifest for ${r.name}`)};var ee=a("@yarnpkg/core");var re=async(e,r)=>{let t=ee.structUtils.parseRange(e.range);if(t.protocol!==p)return e;if(t.selector!=="^")throw new Error(`Invalid backstage: version range found: ${e.range}`);return ee.structUtils.bindDescriptor(e,{backstage:O(),npm:await E(e,r.configuration)})};var er=a("@yarnpkg/core");var te=async(e,r,t,n)=>{let o=er.structUtils.parseRange(t.range);if(t.scope==="backstage"&&o.protocol!==p){let s=t.range;try{t.range=`${p}^`,await E(t,e.project.configuration),console.info(`Setting ${t.scope}/${t.name} to ${p}^`)}catch{t.range=s}}};var rr=a("@yarnpkg/core");var ne=async(e,r,t,n)=>{let o=rr.structUtils.parseRange(n.range);n.scope==="backstage"&&o.protocol!==p&&console.warn(`${n.name} should be set to "${p}^" instead of "${n.range}". Make sure this change is intentional and not a mistake.`)};var w=a("@yarnpkg/core"),oe=a("@yarnpkg/plugin-npm");var T=class e{static protocol=p;supportsDescriptor=r=>r.range.startsWith(e.protocol);async getCandidates(r,t,n){let o=w.structUtils.parseRange(r.range).params?.npm;if(!o||Array.isArray(o))throw new Error(`Missing npm parameter on backstage: range "${r.range}"`);return new oe.NpmSemverResolver().getCandidates(w.structUtils.makeDescriptor(r,`npm:^${o}`),t,n)}getResolutionDependencies(r){let t=w.structUtils.parseRange(r.range).params?.npm;if(!t)throw new Error(`Missing npm parameter on backstage: range "${r.range}".`);return{[w.structUtils.stringifyIdent(r)]:w.structUtils.makeDescriptor(r,`npm:^${t}`)}}async getSatisfying(r,t,n,o){let s=r,i=w.structUtils.parseRange(s.range);if(i.protocol===p){let c=i.params?.npm;s=w.structUtils.makeDescriptor(r,`npm:^${c}`)}return new oe.NpmSemverResolver().getSatisfying(s,t,n,o)}bindDescriptor=r=>r;supportsLocator=()=>!1;shouldPersistResolution=()=>{throw new Error("Unreachable: BackstageNpmResolver should never persist resolution as it uses npm: protocol")};resolve=async()=>{throw new Error("Unreachable: BackstageNpmResolver should never resolve as it uses npm: protocol")}};var Gr="\x1B[31;1m",qr="\x1B[0m";B.semverUtils.satisfiesWithPrereleases(B.YarnVersion,"^4.1.1")||(console.error(),console.error(`${Gr}Unsupported yarn version${qr}: The Backstage yarn plugin only works with yarn ^4.1.1. Please upgrade yarn, or remove this plugin with "yarn plugin remove @yarnpkg/plugin-backstage".`),console.error());var Xr={hooks:{afterWorkspaceDependencyAddition:te,afterWorkspaceDependencyReplacement:ne,reduceDependency:re,beforeWorkspacePacking:Z},resolvers:[T]},Yr=Xr;return ur(Jr);})(); return plugin; } }; diff --git a/.yarnrc.yml b/.yarnrc.yml index 4ba3bcf..b4a8f04 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -9,9 +9,9 @@ nodeLinker: node-modules npmRegistryServer: "https://registry.npmjs.org/" plugins: - - checksum: b3b00465cee9a55ea92b7555876084a6dbfb4b9dd2ce7617a0bca1c138dec6b33befabdff7f4035b2a2ad70d59a05dad3a8faf3a34d3bec21fa7949a497fdf48 + - checksum: 0cfdc882d3c1395592aa6d4690958e70ebbc3a8ee1d888d523dc9e3c74796de26f744c37df66a69212280634f422a88074ba625dce0f7886fbdf2a904a0c38a2 path: .yarn/plugins/@yarnpkg/plugin-backstage.cjs - spec: "https://versions.backstage.io/v1/releases/1.47.3/yarn-plugin" + spec: "https://versions.backstage.io/v1/releases/1.51.2/yarn-plugin" pnpFallbackMode: none diff --git a/backstage.json b/backstage.json index 13d8d5f..84468dd 100644 --- a/backstage.json +++ b/backstage.json @@ -1,3 +1,3 @@ { - "version": "1.47.3" + "version": "1.51.2" } diff --git a/packages/app/package.json b/packages/app/package.json index ea2cafb..52a8c82 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -39,7 +39,7 @@ "@backstage/plugin-techdocs-react": "backstage:^", "@backstage/plugin-user-settings": "backstage:^", "@backstage/theme": "backstage:^", - "@backstage/ui": "^0.14.3", + "@backstage/ui": "backstage:^", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@pagerduty/backstage-plugin": "workspace:^", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index a639d09..5bfa794 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -133,17 +133,17 @@ const overviewContent = ( {entityWarningContent} - + - + - + @@ -187,10 +187,10 @@ const serviceEntityPage = ( - + - + @@ -222,10 +222,10 @@ const websiteEntityPage = ( - + - + @@ -278,7 +278,7 @@ const apiPage = ( - + @@ -310,10 +310,10 @@ const userPage = ( {entityWarningContent} - + - + @@ -326,10 +326,10 @@ const groupPage = ( {entityWarningContent} - + - + @@ -348,28 +348,27 @@ const systemPage = ( {entityWarningContent} - + - + - + - + - + {entityWarningContent} - + - + - + diff --git a/packages/app/src/theme/tokens.css b/packages/app/src/theme/tokens.css index 00f13bc..6ed8439 100644 --- a/packages/app/src/theme/tokens.css +++ b/packages/app/src/theme/tokens.css @@ -1,4 +1,5 @@ :root { --bui-bg-solid: #00a67e; --bui-bg-solid-hover: #008f6d; + --bui-bg-solid-disabled: #1b5c49; } diff --git a/plugins/backstage-plugin-backend/config.d.ts b/plugins/backstage-plugin-backend/config.d.ts index deeaf7c..708f076 100644 --- a/plugins/backstage-plugin-backend/config.d.ts +++ b/plugins/backstage-plugin-backend/config.d.ts @@ -50,5 +50,48 @@ export interface Config { * @deepVisibility secret */ accounts?: PagerDutyAccountConfig[]; + /** + * Optional retention and cleanup settings for custom field sync logs. + * @visibility backend + */ + customFieldsSyncLogs?: { + /** + * Number of days to keep sync logs before they are deleted. Defaults to 30. + * @visibility backend + */ + retentionDays?: number; + /** + * Maximum number of sync log rows to keep across all accounts; the + * oldest rows beyond this count are deleted. Defaults to 500000. + * @visibility backend + */ + maxRows?: number; + /** + * Settings for the scheduled cleanup task. + * @visibility backend + */ + cleanup?: { + /** + * Whether the scheduled cleanup task runs. Defaults to true. + * @visibility backend + */ + enabled?: boolean; + /** + * How often the cleanup task runs, in minutes. Defaults to 15. + * @visibility backend + */ + frequencyMinutes?: number; + /** + * Number of rows deleted per batch. Defaults to 10000. + * @visibility backend + */ + batchSize?: number; + /** + * Maximum number of delete batches per run. Defaults to 500. + * @visibility backend + */ + maxBatchesPerRun?: number; + }; + }; }; } diff --git a/plugins/backstage-plugin-backend/migrations/20260225113251_add_pagerduty_custom_fields_table.js b/plugins/backstage-plugin-backend/migrations/20260225113251_add_pagerduty_custom_fields_table.js new file mode 100644 index 0000000..67679f1 --- /dev/null +++ b/plugins/backstage-plugin-backend/migrations/20260225113251_add_pagerduty_custom_fields_table.js @@ -0,0 +1,31 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + + +exports.up = async function up(knex) { + return knex.schema.createTable('pagerduty_custom_fields', table => { + table.increments('id').primary(); + table.string('pagerdutyCustomFieldId').notNullable(); + table.string('pagerdutyCustomFieldDisplayName').notNullable(); + table.boolean('pagerdutyCustomFieldEnabled').notNullable().defaultTo(true); + table.string('backstageEntityMappingPath').notNullable(); + table.string('pagerdutySubdomain').notNullable(); + table.string('description'); + table.index(['pagerdutyCustomFieldId'], 'pagerduty_custom_field_id_idx'); + table.dateTime('updatedAt').defaultTo(knex.fn.now()); + table.dateTime('createdAt').defaultTo(knex.fn.now()); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('pagerduty_custom_fields', table => { + table.dropIndex([], 'pagerduty_custom_field_id_idx'); + }); + return knex.schema.dropTable('pagerduty_custom_fields'); +}; diff --git a/plugins/backstage-plugin-backend/migrations/20260313_add_custom_fields_unique_indexes.js b/plugins/backstage-plugin-backend/migrations/20260313_add_custom_fields_unique_indexes.js new file mode 100644 index 0000000..ef129d8 --- /dev/null +++ b/plugins/backstage-plugin-backend/migrations/20260313_add_custom_fields_unique_indexes.js @@ -0,0 +1,27 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('pagerduty_custom_fields', table => { + table.unique( + ['backstageEntityMappingPath', 'pagerdutySubdomain'], + { indexName: 'pagerduty_cf_entitypath_subdomain_unique' }, + ); + table.unique( + ['pagerdutyCustomFieldDisplayName', 'pagerdutySubdomain'], + { indexName: 'pagerduty_cf_displayname_subdomain_unique' }, + ); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('pagerduty_custom_fields', table => { + table.dropUnique([], 'pagerduty_cf_entitypath_subdomain_unique'); + table.dropUnique([], 'pagerduty_cf_displayname_subdomain_unique'); + }); +}; diff --git a/plugins/backstage-plugin-backend/migrations/20260515_add_custom_field_sync_logs_table.js b/plugins/backstage-plugin-backend/migrations/20260515_add_custom_field_sync_logs_table.js new file mode 100644 index 0000000..934dfe5 --- /dev/null +++ b/plugins/backstage-plugin-backend/migrations/20260515_add_custom_field_sync_logs_table.js @@ -0,0 +1,32 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function up(knex) { + return knex.schema.createTable('pagerduty_custom_field_sync_logs', table => { + table.increments('id').primary(); + table.dateTime('timestamp').notNullable().defaultTo(knex.fn.now()); + table.string('errorCode').notNullable(); + table.string('customFieldId').notNullable(); + table.string('customFieldName').notNullable(); + table.string('entityPath').notNullable(); + table.string('serviceId').notNullable(); + table.string('serviceName').notNullable(); + table.text('errorMessage').notNullable(); + table.string('subdomain').notNullable(); + table.index(['subdomain', 'timestamp'], 'sync_logs_subdomain_timestamp_idx'); + table.index(['serviceId'], 'sync_logs_service_id_idx'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('pagerduty_custom_field_sync_logs', table => { + table.dropIndex([], 'sync_logs_subdomain_timestamp_idx'); + table.dropIndex([], 'sync_logs_service_id_idx'); + }); + return knex.schema.dropTable('pagerduty_custom_field_sync_logs'); +}; diff --git a/plugins/backstage-plugin-backend/migrations/20260612_add_sync_logs_timestamp_index.js b/plugins/backstage-plugin-backend/migrations/20260612_add_sync_logs_timestamp_index.js new file mode 100644 index 0000000..53cee8f --- /dev/null +++ b/plugins/backstage-plugin-backend/migrations/20260612_add_sync_logs_timestamp_index.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function up(knex) { + return knex.schema.alterTable('pagerduty_custom_field_sync_logs', table => { + table.index(['timestamp'], 'sync_logs_timestamp_idx'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + return knex.schema.alterTable('pagerduty_custom_field_sync_logs', table => { + table.dropIndex([], 'sync_logs_timestamp_idx'); + }); +}; diff --git a/plugins/backstage-plugin-backend/src/apis/pagerduty.test.ts b/plugins/backstage-plugin-backend/src/apis/pagerduty.test.ts index 2e9fdb3..ff43071 100644 --- a/plugins/backstage-plugin-backend/src/apis/pagerduty.test.ts +++ b/plugins/backstage-plugin-backend/src/apis/pagerduty.test.ts @@ -17,8 +17,14 @@ import { getServiceByIntegrationKey, getServiceMetrics, getServiceStandards, + getServicesByIds, + getSerivcesByIdsAndAccount, insertAccountConfig, + isValidServiceId, + removeAccountConfig, setFallbackAccountConfig, + updateCustomField, + fetchWithRetries, } from './pagerduty'; import { mocked } from 'jest-mock'; @@ -1001,6 +1007,130 @@ describe('PagerDuty API', () => { ); }); + describe('isValidServiceId', () => { + it.each([ + ['PXXXXXX', true], + ['P1A2B3C', true], + ['ABCDEFG', true], + ['S3RV1CE1D', true], + [' PXXXXXX ', true], + ['', false], + [' ', false], + ['pxxxxxx', false], + ['https://acme.pagerduty.com/service-directory/PXXXXXX', false], + ['PXX', false], + ['PXX XXXX', false], + ])('returns expected validity for %p', (input, expected) => { + expect(isValidServiceId(input as string)).toBe(expected); + }); + + it('returns false for undefined and null', () => { + expect(isValidServiceId(undefined)).toBe(false); + expect(isValidServiceId(null)).toBe(false); + }); + }); + + describe('getServicesByIds (batch)', () => { + const buildService = (id: string): PagerDutyService => + ({ + id, + name: `Service ${id}`, + html_url: `https://testaccount.pagerduty.com/services/${id}`, + escalation_policy: { + id: 'P0L1CY1D', + name: 'Test Escalation Policy', + html_url: + 'https://testaccount.pagerduty.com/escalation_policies/P0L1CY1D', + type: 'escalation_policy_reference', + }, + status: 'active', + } as PagerDutyService); + + it('filters out malformed ids and still returns the valid services', async () => { + mocked(fetch).mockReturnValue( + mockedResponse(200, { + services: [buildService('PVALID1'), buildService('PVALID2')], + }), + ); + + const result = await getSerivcesByIdsAndAccount( + ['PVALID1', 'not a url/PBAD', ' PVALID2 '], + 'testaccount', + ); + + expect(result.map(s => s.id).sort()).toEqual(['PVALID1', 'PVALID2']); + expect(fetch).toHaveBeenCalledTimes(1); + + const calledUrl = String(mocked(fetch).mock.calls[0][0]); + expect(calledUrl).toContain('id%5B%5D=PVALID1'); + expect(calledUrl).toContain('id%5B%5D=PVALID2'); + expect(calledUrl).not.toContain('PBAD'); + expect(calledUrl).not.toContain('%20'); + }); + + it('makes no API call when every id is malformed', async () => { + const result = await getSerivcesByIdsAndAccount( + ['bad/url', ' ', 'pxx'], + 'testaccount', + ); + + expect(result).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('treats a 400 from the list endpoint as no matching services', async () => { + mocked(fetch).mockReturnValue(mockedResponse(400, {})); + + await expect( + getSerivcesByIdsAndAccount(['PXXXXXX'], 'testaccount'), + ).resolves.toEqual([]); + }); + + it('splits ids into batches of 50', async () => { + mocked(fetch).mockReturnValue(mockedResponse(200, { services: [] })); + + const ids = Array.from( + { length: 51 }, + (_, i) => `P${String(i).padStart(6, '0')}`, + ); + + await getSerivcesByIdsAndAccount(ids, 'testaccount'); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + describe('multi-account', () => { + beforeAll(() => { + insertAccountConfig({ + id: 'secondaccount', + apiBaseUrl: 'https://mock2.api.pagerduty.com', + eventsBaseUrl: 'https://mock2.events.pagerduty.com', + oauth: { + clientId: 'mock-client-id-2', + clientSecret: 'mock-client-secret-2', + subDomain: 'secondaccount', + }, + }); + }); + + afterAll(() => { + removeAccountConfig('secondaccount'); + }); + + it('accumulates services from all accounts', async () => { + (fetch as unknown as jest.Mock).mockImplementation((url: unknown) => + String(url).includes('mock2') + ? mockedResponse(200, { services: [buildService('PFROM2ND')] }) + : mockedResponse(200, { services: [buildService('PFROM1ST')] }), + ); + + const result = await getServicesByIds(['PXXXXXX']); + + expect(result.map(s => s.id).sort()).toEqual(['PFROM1ST', 'PFROM2ND']); + }); + }); + }); + describe('getChangeEvents', () => { it.each(testInputs)('should return change events list', async () => { const serviceId = 'SERV1C31D'; @@ -1567,6 +1697,52 @@ describe('PagerDuty API', () => { ); }, ); + }); + + describe('updateCustomField', () => { + it.each(testInputs)( + 'calls PUT /services/custom_fields/:id and returns the updated field', + async () => { + const mockField = { + id: 'PD123', + display_name: 'Runbook Link', + name: 'runbook_link', + data_type: 'string', + field_type: 'single_value', + enabled: true, + description: 'Updated description', + }; + + mocked(fetch).mockReturnValue( + mockedResponse(200, { field: mockField }), + ); + + const result = await updateCustomField({ + fieldId: 'PD123', + request: { + field: { + display_name: 'Runbook Link', + description: 'Updated description', + }, + }, + }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/custom_fields/PD123'), + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ + field: { + display_name: 'Runbook Link', + description: 'Updated description', + }, + }), + }), + ); + expect(result.field).toEqual(mockField); + }, + ); it.each(testInputs)( 'should include X-PagerDuty-Client header in oncall requests', @@ -1604,4 +1780,116 @@ describe('PagerDuty API', () => { }, ); }); + + describe('updateCustomField error handling', () => { + it.each(testInputs)( + 'throws HttpError with status 404 when field not found', + async () => { + mocked(fetch).mockReturnValue(mockedResponse(404, {})); + + await expect( + updateCustomField({ + fieldId: 'MISSING', + request: { field: { display_name: 'X' } }, + }), + ).rejects.toMatchObject({ status: 404 }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/custom_fields/MISSING'), + expect.objectContaining({ + method: 'PUT', + }), + ); + }, + ); + }); + + describe('fetchWithRetries SSRF guard', () => { + it('refuses to fetch a URL whose origin is not in the configured allow-list', async () => { + await expect( + fetchWithRetries('https://internal.example.com/data', {}), + ).rejects.toThrow( + 'Refusing to fetch URL with disallowed origin: https://internal.example.com', + ); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('refuses to fetch an invalid URL', async () => { + await expect(fetchWithRetries('not-a-url', {})).rejects.toThrow( + 'Refusing to fetch invalid URL.', + ); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('proceeds for a configured allowed origin', async () => { + mocked(fetch).mockReturnValue(mockedResponse(200, { ok: true })); + + const response = await fetchWithRetries( + 'https://mock.api.pagerduty.com/services/custom_fields', + {}, + ); + + expect(response.status).toEqual(200); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('proceeds for the public PagerDuty API origin', async () => { + mocked(fetch).mockReturnValue(mockedResponse(200, { ok: true })); + + const response = await fetchWithRetries( + 'https://api.pagerduty.com/abilities', + {}, + ); + + expect(response.status).toEqual(200); + expect(fetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('path segment encoding', () => { + it('percent-encodes a user-controlled service id in getServiceById', async () => { + mocked(fetch).mockReturnValue( + mockedResponse(200, { + service: { + id: 'S3RV1CE1D', + name: 'Test Service', + description: 'Test Service Description', + status: 'active', + html_url: 'https://testaccount.pagerduty.com/services/S3RV1CE1D', + escalation_policy: { + id: 'P0L1CY1D', + name: 'Test Escalation Policy', + type: 'escalation_policy_reference', + html_url: + 'https://testaccount.pagerduty.com/escalation_policies/P0L1CY1D', + }, + }, + }), + ); + + await getServiceById('../../abilities'); + + const calledUrl = mocked(fetch).mock.calls[0][0] as string; + expect(calledUrl).toContain('..%2F..%2Fabilities'); + expect(calledUrl).not.toContain('../../abilities'); + }); + + it('percent-encodes a user-controlled field id in updateCustomField', async () => { + mocked(fetch).mockReturnValue( + mockedResponse(200, { field: { id: 'PD123' } }), + ); + + await updateCustomField({ + fieldId: '../evil', + request: { field: { display_name: 'X' } }, + }); + + const calledUrl = mocked(fetch).mock.calls[0][0] as string; + expect(calledUrl).toContain('/custom_fields/..%2Fevil'); + expect(calledUrl).not.toContain('/custom_fields/../evil'); + }); + }); }); diff --git a/plugins/backstage-plugin-backend/src/apis/pagerduty.ts b/plugins/backstage-plugin-backend/src/apis/pagerduty.ts index f5b8493..6655124 100644 --- a/plugins/backstage-plugin-backend/src/apis/pagerduty.ts +++ b/plugins/backstage-plugin-backend/src/apis/pagerduty.ts @@ -26,10 +26,17 @@ import { PagerDutyServiceDependencyResponse, PagerDutyTeam, PagerDutyTeamsResponse, + PagerDutyCustomFieldCreateRequest, + PagerDutyCustomFieldResponse, + PagerDutyCustomFieldsResponse, + PagerDutyCustomFieldUpdateRequest, + PagerDutyServiceCustomFieldValuesRequest, + PagerDutyServiceCustomFieldValuesResponse, } from '@pagerduty/backstage-plugin-common'; import { DateTime } from 'luxon'; import { + CacheService, LoggerService, RootConfigService, } from '@backstage/backend-plugin-api'; @@ -68,6 +75,14 @@ export function insertAccountConfig(account: PagerDutyAccountConfig) { } } +// Remove a previously inserted account from the in-memory endpoint config. +// Primarily used by tests to tear down accounts they register so the shared +// EndpointConfig does not leak between cases. +export function removeAccountConfig(accountId: string) { + delete EndpointConfig[accountId]; + delete SubdomainConfig[accountId]; +} + export function loadPagerDutyEndpointsFromConfig( config: RootConfigService, logger: LoggerService, @@ -169,6 +184,54 @@ async function getDefaultHeaders(account?: string): Promise + `pagerduty:service:${account || 'default'}:${serviceId}`; + +async function readCache( + cache: CacheService | undefined, + key: string, +): Promise { + if (!cache) { + return undefined; + } + return (await cache.get(key)) as T | undefined; +} + +async function writeCache( + cache: CacheService | undefined, + key: string, + value: unknown, +): Promise { + if (!cache) { + return; + } + await cache.set( + key, + value as Parameters[1], + { ttl: SERVICES_CACHE_TTL_MS }, + ); +} + +async function deleteCache( + cache: CacheService | undefined, + key: string, +): Promise { + if (!cache) { + return; + } + await cache.delete(key); +} + // Supporting router export async function addServiceRelationsToService( serviceRelations: PagerDutyServiceDependency[], @@ -315,7 +378,7 @@ export async function getServiceRelationshipsById( }; const apiBaseUrl = getApiBaseUrl(account); - const baseUrl = `${apiBaseUrl}/service_dependencies/technical_services/${serviceId}`; + const baseUrl = `${apiBaseUrl}/service_dependencies/technical_services/${encodeURIComponent(serviceId)}`; try { response = await fetchWithRetries(baseUrl, options); @@ -734,7 +797,22 @@ export async function getOncallUsers( export async function getServiceById( serviceId: string, account?: string, + cache?: CacheService, + logger?: LoggerService, ): Promise { + // Serve from cache first if this specific service is already cached. + const cacheKey = serviceCacheKey(serviceId, account); + const cached = await readCache(cache, cacheKey); + if (cached) { + logger?.debug(`getServiceById: cache HIT for ${cacheKey}`); + return cached; + } + if (cache) { + logger?.debug( + `getServiceById: cache MISS for ${cacheKey}, fetching from PagerDuty API`, + ); + } + let response: Response; const params = `time_zone=UTC&include[]=integrations&include[]=escalation_policies`; @@ -748,7 +826,7 @@ export async function getServiceById( try { response = await fetchWithRetries( - `${baseUrl}/${serviceId}?${params}`, + `${baseUrl}/${encodeURIComponent(serviceId)}?${params}`, options, ); } catch (error) { @@ -791,13 +869,35 @@ export async function getServiceById( try { result = (await response.json()) as PagerDutyServiceResponse; + await writeCache(cache, serviceCacheKey(serviceId, account), result.service); + return result.service; } catch (error) { throw new HttpError(`Failed to parse service information: ${error}`, 500); } } -export async function getSerivcesByIdsAndAccount( +// PagerDuty's `id[]` filter accepts at most 100 ids per request, and packing +// too many into the query string produces a URL long enough for the API +// gateway to reject with an HTML error page. Keep batches well under both +// limits. +const SERVICE_IDS_BATCH_SIZE = 50; + +// PagerDuty resource ids are uppercase alphanumeric, conventionally 7 chars +// (e.g. "PXXXXXX"). The `id[]` list filter rejects the ENTIRE request with a +// 400 if any single value is malformed, so drop clearly-invalid ids before +// calling the API. Permissive on length (>= 6) to avoid rejecting valid ids, +// while still catching pasted URLs, whitespace, lowercase, and typos. +const SERVICE_ID_PATTERN = /^[A-Z0-9]{6,}$/; + +export function isValidServiceId(id: string | undefined | null): boolean { + if (typeof id !== 'string') { + return false; + } + return SERVICE_ID_PATTERN.test(id.trim()); +} + +async function getServicesByIdsBatch( serviceIds: string[], account?: string, ): Promise { @@ -816,11 +916,16 @@ export async function getSerivcesByIdsAndAccount( const apiBaseUrl = getApiBaseUrl(account); const baseUrl = `${apiBaseUrl}/services`; + const params = new URLSearchParams(); + serviceIds.forEach(id => params.append('id[]', id)); + // Without an explicit limit the `/services` list endpoint defaults to a page + // size of 25, which would silently drop ids 26+ of a full batch. Set the + // limit to the batch size (kept under PagerDuty's 100-id ceiling) so the + // whole batch always fits in this single, non-paginated request. + params.append('limit', String(SERVICE_IDS_BATCH_SIZE)); + try { - response = await fetchWithRetries( - `${baseUrl}?id[]=${serviceIds.join('&id[]=')}`, - options, - ); + response = await fetchWithRetries(`${baseUrl}?${params}`, options); } catch (error) { throw new Error(`Failed to retrieve service: ${error}`); } @@ -834,10 +939,12 @@ export async function getSerivcesByIdsAndAccount( switch (response.status) { case 400: - throw new HttpError( - 'Failed to get service. Caller provided invalid arguments.', - 400, - ); + // The `id[]` list filter returns 400 if any single id is malformed. We + // already sanitize ids before calling this, but treat a 400 from the + // *list* endpoint as "no matching services" rather than failing the whole + // page — a list query that matches nothing should degrade gracefully. + // (Single-service fetches such as getServiceById still throw on 400.) + return []; case 401: throw new HttpError( 'Failed to get service. Caller did not supply credentials or did not provide the correct credentials.', @@ -867,6 +974,39 @@ export async function getSerivcesByIdsAndAccount( } } +export async function getSerivcesByIdsAndAccount( + serviceIds: string[], + account?: string, +): Promise { + // Service ids originate from user-authored entity annotations and stored + // mappings, so trim and drop any malformed values. PagerDuty rejects the + // entire `id[]` request with a 400 if even one value is malformed, which + // would otherwise fail the whole page. Re-dedupe after trimming since that + // can newly collide e.g. "PXXXXXX " with "PXXXXXX". + const sanitizedServiceIds = Array.from( + new Set( + serviceIds + .map(id => (typeof id === 'string' ? id.trim() : '')) + .filter(id => isValidServiceId(id)), + ), + ); + + if (sanitizedServiceIds.length === 0) { + return []; + } + + const batches: string[][] = []; + for (let i = 0; i < sanitizedServiceIds.length; i += SERVICE_IDS_BATCH_SIZE) { + batches.push(sanitizedServiceIds.slice(i, i + SERVICE_IDS_BATCH_SIZE)); + } + + const results = await Promise.all( + batches.map(batch => getServicesByIdsBatch(batch, account)), + ); + + return results.flat(); +} + export async function getServiceByIntegrationKey( integrationKey: string, account?: string, @@ -940,16 +1080,29 @@ export async function getServiceByIntegrationKey( export async function getServicesByIds( ids: string[], ): Promise { - let services: PagerDutyService[] = []; - await Promise.all( - Object.entries(EndpointConfig).map(async ([account, _]) => { - services = await getSerivcesByIdsAndAccount(ids, account); - }), + const servicesPerAccount = await Promise.all( + Object.keys(EndpointConfig).map(account => + getSerivcesByIdsAndAccount(ids, account), + ), ); - return services; + return servicesPerAccount.flat(); } -export async function getAllServices(): Promise { +export async function getAllServices( + cache?: CacheService, +): Promise { + // Return the cached service list if we already have one. An empty array is a + // valid cached result (an account with zero services), so check for presence + // rather than length — otherwise `[]` would be treated as a miss and trigger + // a fresh full fetch on every call. + const cached = await readCache( + cache, + ALL_SERVICES_CACHE_KEY, + ); + if (cached !== undefined) { + return cached; + } + const allServices: PagerDutyService[] = []; await Promise.all( @@ -1018,6 +1171,15 @@ export async function getAllServices(): Promise { }), ); + // Cache the full list, and index each service by id so single-service + // lookups can be served from the cache too. + await writeCache(cache, ALL_SERVICES_CACHE_KEY, allServices); + await Promise.all( + allServices.map(service => + writeCache(cache, serviceCacheKey(service.id, service.account), service), + ), + ); + return allServices; } @@ -1206,7 +1368,7 @@ export async function getChangeEvents( try { response = await fetchWithRetries( - `${baseUrl}/${serviceId}/change_events?${params}`, + `${baseUrl}/${encodeURIComponent(serviceId)}/change_events?${params}`, options, ); } catch (error) { @@ -1263,7 +1425,7 @@ export async function getIncidents( account?: string, ): Promise { let response: Response; - const params = `time_zone=UTC&sort_by=created_at&statuses[]=triggered&statuses[]=acknowledged&service_ids[]=${serviceId}`; + const params = `time_zone=UTC&sort_by=created_at&statuses[]=triggered&statuses[]=acknowledged&service_ids[]=${encodeURIComponent(serviceId)}`; const options: RequestInit = { method: 'GET', @@ -1338,7 +1500,7 @@ export async function getServiceStandards( }; const apiBaseUrl = getApiBaseUrl(account); - const baseUrl = `${apiBaseUrl}/standards/scores/technical_services/${serviceId}`; + const baseUrl = `${apiBaseUrl}/standards/scores/technical_services/${encodeURIComponent(serviceId)}`; try { response = await fetchWithRetries(baseUrl, options); @@ -1455,12 +1617,14 @@ export type CreateServiceIntegrationProps = { serviceId: string; vendorId: string; account?: string; + cache?: CacheService; }; export async function createServiceIntegration({ serviceId, vendorId, account, + cache, }: CreateServiceIntegrationProps): Promise { let response: Response; @@ -1487,7 +1651,7 @@ export async function createServiceIntegration({ try { response = await fetchWithRetries( - `${baseUrl}/${serviceId}/integrations`, + `${baseUrl}/${encodeURIComponent(serviceId)}/integrations`, options, ); } catch (error) { @@ -1525,16 +1689,64 @@ export async function createServiceIntegration({ try { result = (await response.json()) as PagerDutyIntegrationResponse; + // We just added an integration to this service, so any cached copy now has + // a stale `integrations` array. Evict the per-id entry and the full list + // (which embeds the same stale service) so the next read re-fetches and + // sees the new integration — otherwise a subsequent confirm/bulk mapping + // would create a duplicate Backstage integration within the cache TTL. + await deleteCache(cache, serviceCacheKey(serviceId, account)); + await deleteCache(cache, ALL_SERVICES_CACHE_KEY); + return result.integration.integration_key ?? ''; } catch (error) { throw new Error(`Failed to parse service information: ${error}`); } } +function getAllowedApiOrigins(): Set { + const origins = new Set(); + + const addOrigin = (apiBaseUrl?: string) => { + if (!apiBaseUrl) { + return; + } + try { + origins.add(new URL(apiBaseUrl).origin); + } catch { + // ignore malformed configured URLs + } + }; + + Object.values(EndpointConfig).forEach(cfg => addOrigin(cfg.apiBaseUrl)); + addOrigin(fallbackEndpointConfig?.apiBaseUrl); + + // Always allow the public PagerDuty API host (default for every config path, + // and used directly by isEventNoiseReductionEnabled). + origins.add('https://api.pagerduty.com'); + + return origins; +} + export async function fetchWithRetries( url: string, options: RequestInit, ): Promise { + // Guard against SSRF: only allow requests to configured PagerDuty API origins. + // The host is server-controlled, but path segments may be user-provided, so we + // validate the resolved origin against an allow-list before fetching. + let requestOrigin: string; + try { + requestOrigin = new URL(url).origin; + } catch { + throw new Error('Refusing to fetch invalid URL.'); + } + + if (!getAllowedApiOrigins().has(requestOrigin)) { + throw new Error( + `Refusing to fetch URL with disallowed origin: ${requestOrigin}`, + ); + } + let response: Response; let error: Error = new Error(); @@ -1560,3 +1772,356 @@ export async function fetchWithRetries( `Failed to fetch data after ${maxRetries} retries. Last error: ${error}`, ); } + +export type CreateCustomFieldProps = { + request: PagerDutyCustomFieldCreateRequest; + account?: string; +}; + +export async function createCustomField({ + request, + account, +}: CreateCustomFieldProps): Promise { + let response: Response; + + const apiBaseUrl = getApiBaseUrl(account); + const baseUrl = `${apiBaseUrl}/services/custom_fields`; + const token = await getAuthToken(account); + + const options: RequestInit = { + method: 'POST', + body: JSON.stringify(request), + headers: { + Authorization: token, + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetchWithRetries(baseUrl, options); + } catch (error) { + throw new Error(`Failed to create custom field: ${error}`); + } + + if (response.status >= 500) { + throw new HttpError( + `Failed to create custom field. PagerDuty API returned a server error.`, + response.status, + ); + } + + switch (response.status) { + case 400: { + const errorData = await response.json().catch(() => ({})); + throw new HttpError( + `Failed to create custom field. Invalid arguments: ${JSON.stringify(errorData)}`, + 400, + ); + } + case 401: + throw new HttpError( + `Failed to create custom field. Invalid credentials provided.`, + 401, + ); + case 403: + throw new HttpError( + `Failed to create custom field. Not authorized to perform this action.`, + 403, + ); + case 409: { + const errorData = await response.json().catch(() => ({})); + throw new HttpError( + `Custom field with this name already exists: ${JSON.stringify(errorData)}`, + 409, + ); + } + case 429: + throw new HttpError(`Rate limit exceeded.`, 429); + default: // 201 + break; + } + + try { + const result = (await response.json()) as PagerDutyCustomFieldResponse; + return result; + } catch (error) { + throw new Error(`Failed to parse custom field response: ${error}`); + } +} + +export type UpdateCustomFieldProps = { + fieldId: string; + request: PagerDutyCustomFieldUpdateRequest; + account?: string; +}; + +export async function updateCustomField({ + fieldId, + request, + account, +}: UpdateCustomFieldProps): Promise { + const apiBaseUrl = getApiBaseUrl(account); + const baseUrl = `${apiBaseUrl}/services/custom_fields/${encodeURIComponent(fieldId)}`; + const token = await getAuthToken(account); + + const options: RequestInit = { + method: 'PUT', + body: JSON.stringify(request), + headers: { + Authorization: token, + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }, + }; + + let response: Response; + try { + response = await fetchWithRetries(baseUrl, options); + } catch (error) { + throw new Error(`Failed to update custom field: ${error}`); + } + + if (response.status >= 500) { + throw new HttpError( + `Failed to update custom field. PagerDuty API returned a server error.`, + response.status, + ); + } + + switch (response.status) { + case 400: { + const errorData = await response.json().catch(() => ({})); + throw new HttpError( + `Failed to update custom field. Invalid arguments: ${JSON.stringify(errorData)}`, + 400, + ); + } + case 401: + throw new HttpError( + `Failed to update custom field. Invalid credentials provided.`, + 401, + ); + case 403: + throw new HttpError( + `Failed to update custom field. Not authorized to perform this action.`, + 403, + ); + case 404: + throw new HttpError( + `Failed to update custom field. Custom field not found.`, + 404, + ); + case 409: { + const errorData = await response.json().catch(() => ({})); + throw new HttpError( + `Custom field with this name already exists: ${JSON.stringify(errorData)}`, + 409, + ); + } + case 429: + throw new HttpError(`Rate limit exceeded.`, 429); + default: // 200 + break; + } + + try { + const result = (await response.json()) as PagerDutyCustomFieldResponse; + return result; + } catch (error) { + throw new Error(`Failed to parse custom field response: ${error}`); + } +} + +export type GetCustomFieldsProps = { + account?: string; +}; + +export async function getCustomFields({ + account, +}: GetCustomFieldsProps = {}): Promise { + let response: Response; + + const apiBaseUrl = getApiBaseUrl(account); + const baseUrl = `${apiBaseUrl}/services/custom_fields`; + const token = await getAuthToken(account); + + const options: RequestInit = { + method: 'GET', + headers: { + Authorization: token, + Accept: 'application/vnd.pagerduty+json;version=2', + }, + }; + + try { + response = await fetchWithRetries(baseUrl, options); + } catch (error) { + throw new Error(`Failed to get custom fields: ${error}`); + } + + if (response.status >= 500) { + throw new HttpError( + `Failed to get custom fields. PagerDuty API returned a server error.`, + response.status, + ); + } + + switch (response.status) { + case 401: + throw new HttpError( + `Failed to get custom fields. Invalid credentials provided.`, + 401, + ); + case 403: + throw new HttpError( + `Failed to get custom fields. Not authorized to perform this action.`, + 403, + ); + case 429: + throw new HttpError(`Rate limit exceeded.`, 429); + default: // 200 + break; + } + + try { + const result = (await response.json()) as PagerDutyCustomFieldsResponse; + return result; + } catch (error) { + throw new Error(`Failed to parse custom fields response: ${error}`); + } +} + +export type DeleteCustomFieldProps = { + fieldId: string; + account?: string; +}; + +export async function deleteCustomField({ + fieldId, + account, +}: DeleteCustomFieldProps): Promise { + const apiBaseUrl = getApiBaseUrl(account); + const baseUrl = `${apiBaseUrl}/services/custom_fields/${encodeURIComponent(fieldId)}`; + const token = await getAuthToken(account); + + const options: RequestInit = { + method: 'DELETE', + headers: { + Authorization: token, + Accept: 'application/vnd.pagerduty+json;version=2', + }, + }; + + let response: Response; + try { + response = await fetchWithRetries(baseUrl, options); + } catch (error) { + throw new Error(`Failed to delete custom field: ${error}`); + } + + if (response.status >= 500) { + throw new HttpError( + `Failed to delete custom field. PagerDuty API returned a server error.`, + response.status, + ); + } + + switch (response.status) { + case 401: + throw new HttpError( + `Failed to delete custom field. Invalid credentials provided.`, + 401, + ); + case 403: + throw new HttpError( + `Failed to delete custom field. Caller is not authorized to delete this custom field.`, + 403, + ); + case 404: + throw new HttpError(`Custom field not found.`, 404); + case 429: + throw new HttpError(`Rate limit exceeded.`, 429); + default: // 204 + break; + } +} + +export type SetServiceCustomFieldValuesProps = { + serviceId: string; + request: PagerDutyServiceCustomFieldValuesRequest; + account?: string; +}; + +export async function setServiceCustomFieldValues({ + serviceId, + request, + account, +}: SetServiceCustomFieldValuesProps): Promise { + const apiBaseUrl = getApiBaseUrl(account); + const baseUrl = `${apiBaseUrl}/services/${encodeURIComponent(serviceId)}/custom_fields/values`; + const token = await getAuthToken(account); + + const options: RequestInit = { + method: 'PUT', + body: JSON.stringify(request), + headers: { + Authorization: token, + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }, + }; + + let response: Response; + try { + response = await fetchWithRetries(baseUrl, options); + } catch (error) { + throw new Error(`Failed to set service custom field values: ${error}`); + } + + if (response.status >= 500) { + throw new HttpError( + `Failed to set service custom field values. PagerDuty API returned a server error.`, + response.status, + ); + } + + switch (response.status) { + case 400: { + const errorData = await response.json().catch(() => ({})); + throw new HttpError( + `Failed to set service custom field values. Invalid arguments: ${JSON.stringify(errorData)}`, + 400, + ); + } + case 401: + throw new HttpError( + `Failed to set service custom field values. Invalid credentials provided.`, + 401, + ); + case 403: + throw new HttpError( + `Failed to set service custom field values. Not authorized to perform this action.`, + 403, + ); + case 404: + throw new HttpError( + `Failed to set service custom field values. Service or custom field not found.`, + 404, + ); + case 429: + throw new HttpError(`Rate limit exceeded.`, 429); + default: // 200 + break; + } + + try { + const result = + (await response.json()) as PagerDutyServiceCustomFieldValuesResponse; + return result; + } catch (error) { + throw new Error( + `Failed to parse set service custom field values response: ${error}`, + ); + } +} diff --git a/plugins/backstage-plugin-backend/src/controllers/mappings-controller.test.ts b/plugins/backstage-plugin-backend/src/controllers/mappings-controller.test.ts new file mode 100644 index 0000000..b6eff88 --- /dev/null +++ b/plugins/backstage-plugin-backend/src/controllers/mappings-controller.test.ts @@ -0,0 +1,174 @@ +import { Request, Response } from 'express'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { CatalogApi } from '@backstage/catalog-client'; +import { PagerDutyService } from '@pagerduty/backstage-plugin-common'; +import { PagerDutyBackendStore, RawDbEntityResultRow } from '../db/PagerDutyBackendDatabase'; +import { getMappingEntities } from './mappings-controller'; +import * as PagerdutyApi from '../apis/pagerduty'; + +jest.mock('../services/pagerduty', () => ({ + getServicesIdsByPartialName: jest.fn(), +})); + +jest.mock('../apis/pagerduty', () => ({ + ...jest.requireActual('../apis/pagerduty'), + getServicesByIds: jest.fn(), + getServiceByIntegrationKey: jest.fn(), +})); + +const MALFORMED_SERVICE_ID = + 'https://acme.pagerduty.com/service-directory/PBAD'; + +function buildService(id: string): PagerDutyService { + return { + id, + name: `Service ${id}`, + html_url: `https://acme.pagerduty.com/services/${id}`, + escalation_policy: { + id: 'P0L1CY1D', + name: 'Test Escalation Policy', + html_url: 'https://acme.pagerduty.com/escalation_policies/P0L1CY1D', + type: 'escalation_policy_reference', + }, + status: 'active', + } as PagerDutyService; +} + +function buildEntity(name: string, serviceId: string) { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name, + namespace: 'default', + uid: `uid-${name}`, + annotations: { + 'pagerduty.com/service-id': serviceId, + }, + }, + spec: { owner: 'team-a' }, + }; +} + +function buildResponse() { + const response = {} as Response; + response.status = jest.fn().mockReturnValue(response); + response.json = jest.fn().mockReturnValue(response); + return response; +} + +function buildRequest(body: unknown): Request { + return { body } as Request; +} + +describe('getMappingEntities', () => { + const getServicesByIds = PagerdutyApi.getServicesByIds as jest.Mock; + const logger = { warn: jest.fn() } as unknown as jest.Mocked; + + let store: PagerDutyBackendStore; + let catalogApi: CatalogApi; + + beforeEach(() => { + jest.clearAllMocks(); + + store = { + getAllEntityMappings: jest.fn().mockResolvedValue([] as RawDbEntityResultRow[]), + } as unknown as PagerDutyBackendStore; + + catalogApi = { + queryEntities: jest.fn(), + } as unknown as CatalogApi; + }); + + it('does not 400 when an entity carries a malformed service-id annotation and the status filter is set', async () => { + (catalogApi.queryEntities as jest.Mock).mockResolvedValue({ + items: [buildEntity('bad-component', MALFORMED_SERVICE_ID)], + totalItems: 1, + pageInfo: {}, + }); + getServicesByIds.mockResolvedValue([]); + + const handler = getMappingEntities(store, catalogApi, logger); + const response = buildResponse(); + + await handler( + buildRequest({ offset: 0, limit: 10, filters: { status: 'OutOfSync' } }), + response, + ); + + // The malformed id must never reach the PagerDuty batch call. + expect(getServicesByIds).toHaveBeenCalledTimes(1); + expect(getServicesByIds.mock.calls[0][0]).not.toContain(MALFORMED_SERVICE_ID); + + // And the request resolves successfully rather than returning a 400. + expect(response.status).not.toHaveBeenCalledWith(400); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ entities: expect.any(Array), totalCount: expect.any(Number) }), + ); + }); + + it('logs a warning naming the malformed service id', async () => { + (catalogApi.queryEntities as jest.Mock).mockResolvedValue({ + items: [buildEntity('bad-component', MALFORMED_SERVICE_ID)], + totalItems: 1, + pageInfo: {}, + }); + getServicesByIds.mockResolvedValue([]); + + const handler = getMappingEntities(store, catalogApi, logger); + + await handler( + buildRequest({ offset: 0, limit: 10, filters: { status: 'OutOfSync' } }), + buildResponse(), + ); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining(MALFORMED_SERVICE_ID), + ); + }); + + it('forwards a valid service-id annotation to getServicesByIds and populates service fields', async () => { + (catalogApi.queryEntities as jest.Mock).mockResolvedValue({ + items: [buildEntity('good-component', 'PVALID1')], + totalItems: 1, + pageInfo: {}, + }); + getServicesByIds.mockResolvedValue([buildService('PVALID1')]); + + const handler = getMappingEntities(store, catalogApi, logger); + const response = buildResponse(); + + await handler( + buildRequest({ offset: 0, limit: 10, filters: {} }), + response, + ); + + expect(getServicesByIds.mock.calls[0][0]).toContain('PVALID1'); + expect(logger.warn).not.toHaveBeenCalled(); + + const payload = (response.json as jest.Mock).mock.calls[0][0]; + expect(payload.entities[0].serviceName).toBe('Service PVALID1'); + expect(payload.entities[0].serviceUrl).toBe( + 'https://acme.pagerduty.com/services/PVALID1', + ); + }); + + it('returns normally with no status filter regardless of annotation validity', async () => { + (catalogApi.queryEntities as jest.Mock).mockResolvedValue({ + items: [buildEntity('bad-component', MALFORMED_SERVICE_ID)], + totalItems: 1, + pageInfo: {}, + }); + getServicesByIds.mockResolvedValue([]); + + const handler = getMappingEntities(store, catalogApi, logger); + const response = buildResponse(); + + await handler(buildRequest({ offset: 0, limit: 10, filters: {} }), response); + + expect(response.status).not.toHaveBeenCalledWith(400); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ entities: expect.any(Array) }), + ); + }); +}); diff --git a/plugins/backstage-plugin-backend/src/controllers/mappings-controller.ts b/plugins/backstage-plugin-backend/src/controllers/mappings-controller.ts index b75292e..99a2ff8 100644 --- a/plugins/backstage-plugin-backend/src/controllers/mappings-controller.ts +++ b/plugins/backstage-plugin-backend/src/controllers/mappings-controller.ts @@ -1,10 +1,11 @@ import { Request, Response } from 'express'; +import { LoggerService } from '@backstage/backend-plugin-api'; import * as Pagerduty from '../services/pagerduty'; import * as CatalogEntityUtils from '../utils/catalog-entity'; import { PagerDutyBackendStore } from '../db'; import { CatalogApi, GetEntitiesResponse, QueryEntitiesResponse } from '@backstage/catalog-client'; import { HttpError, PagerDutyEntityMappingsResponse, PagerDutyService, FormattedBackstageEntity } from '@pagerduty/backstage-plugin-common'; -import { getServiceByIntegrationKey, getServicesByIds } from '../apis/pagerduty'; +import { getServiceByIntegrationKey, getServicesByIds, isValidServiceId } from '../apis/pagerduty'; import { RawDbEntityResultRow } from '../db/PagerDutyBackendDatabase'; // Status order for sorting (from least to most complete) @@ -55,7 +56,11 @@ function compareEntities( return direction === 'ascending' ? comparison : -comparison; } -export function getMappingEntities(store: PagerDutyBackendStore, catalogApi: CatalogApi) { +export function getMappingEntities( + store: PagerDutyBackendStore, + catalogApi: CatalogApi, + logger?: LoggerService, +) { return async function getMappingEntitiesFunction(request: Request, response: Response) { try { const { offset = 0, limit = 10, filters = {}, sort, account } = request.body; @@ -169,7 +174,35 @@ export function getMappingEntities(store: PagerDutyBackendStore, catalogApi: Cat }); }); - const currentPagePagerDutyServiceIds = currentPageMappings.map(mapping => mapping.serviceId).filter(Boolean); + // Collect the PagerDuty service ids we need to resolve for this page from + // both the stored mappings and the service-id annotations on the + // current-page entities (an entity can carry an annotation without a DB + // mapping yet). + const rawPagerDutyServiceIds = [ + ...currentPageMappings.map(mapping => mapping.serviceId), + ...componentEntities.items.map(entity => + CatalogEntityUtils.getPagerDutyServiceId(entity), + ), + ].filter(Boolean) as string[]; + + // Service ids come from user-authored annotations / stored mappings, so a + // malformed value (pasted URL, whitespace, typo) can appear. getServicesByIds + // sanitizes again before calling PagerDuty, but surface a warning here so the + // operator can fix the offending entity annotation. De-duplicate first so each + // id is fetched and warned about at most once. + const uniqueTrimmedServiceIds = Array.from( + new Set(rawPagerDutyServiceIds.map(id => id.trim())), + ); + + const currentPagePagerDutyServiceIds = uniqueTrimmedServiceIds.filter(id => { + if (isValidServiceId(id)) { + return true; + } + logger?.warn( + `Ignoring malformed PagerDuty service id "${id}" while building entity mappings`, + ); + return false; + }); const currentPagePagerDutyServices = await getServicesByIds(currentPagePagerDutyServiceIds); @@ -207,9 +240,21 @@ export function getMappingEntities(store: PagerDutyBackendStore, catalogApi: Cat account: '', }; + const entityRef = CatalogEntityUtils.entityRef(entity).toLowerCase(); + + const entityMapping = maps.mappings.find( + mapping => + mapping.entityRef === entityRef || + (mapping.integrationKey && mapping.integrationKey === annotations['pagerduty.com/integration-key']) || + (mapping.serviceId && mapping.serviceId === annotations['pagerduty.com/service-id']), + ); + // Try to find a service by service ID or integration key let service = null; let isServiceError = null; + // Tracks whether the service was resolved from a stored DB mapping + // rather than from the entity's own annotations. + let resolvedFromMapping = false; if (annotations['pagerduty.com/service-id']) { const serviceId = annotations['pagerduty.com/service-id']; @@ -227,14 +272,24 @@ export function getMappingEntities(store: PagerDutyBackendStore, catalogApi: Cat } } - const entityRef = CatalogEntityUtils.entityRef(entity).toLowerCase(); - - const entityMapping = maps.mappings.find( - mapping => - mapping.entityRef === entityRef || - (mapping.integrationKey && mapping.integrationKey === annotations['pagerduty.com/integration-key']) || - (mapping.serviceId && mapping.serviceId === annotations['pagerduty.com/service-id']), - ); + // The entity's annotations are written back asynchronously by the + // entity processor after a mapping is created, so a freshly-created + // mapping has a DB row but no annotation yet. When we matched a stored + // mapping by entityRef but couldn't resolve the service from the + // (still-empty) annotations, fall back to the mapping's serviceId so + // the mapping shows up immediately rather than waiting for the next + // catalog processing cycle. + if ( + !service && + !isServiceError && + entityMapping?.entityRef === entityRef && + entityMapping?.serviceId + ) { + service = currentPagePagerDutyServices.find( + s => s.id === entityMapping.serviceId, + ); + resolvedFromMapping = !!service; + } if (service) { formattedEntity.serviceName = service.name; @@ -244,10 +299,17 @@ export function getMappingEntities(store: PagerDutyBackendStore, catalogApi: Cat formattedEntity.account = service.account || ''; if (entityMapping) { - const expectedEntityRef = componentEntitiesDict[service.id]?.ref; + // When resolved from the stored mapping, the mapping's entityRef + // already equals this entity's ref (that's how it was matched), so + // trust the mapping's status directly. Otherwise validate against + // the annotation-derived ref to guard against a stale mapping that + // points the service at a different entity. + const expectedEntityRef = resolvedFromMapping + ? entityMapping.entityRef + : componentEntitiesDict[service.id]?.ref; if (expectedEntityRef && expectedEntityRef === entityMapping.entityRef) { - formattedEntity.status = + formattedEntity.status = (entityMapping.status || 'NotMapped') as NonNullable; } else { formattedEntity.status = 'NotMapped' as NonNullable; diff --git a/plugins/backstage-plugin-backend/src/db/PagerDutyBackendDatabase.ts b/plugins/backstage-plugin-backend/src/db/PagerDutyBackendDatabase.ts index eaa5cfe..ff30f9f 100644 --- a/plugins/backstage-plugin-backend/src/db/PagerDutyBackendDatabase.ts +++ b/plugins/backstage-plugin-backend/src/db/PagerDutyBackendDatabase.ts @@ -1,6 +1,9 @@ import { PagerDutyEntityMapping, PagerDutySetting, + BackstageCustomField, + CustomFieldSyncLog, + CustomFieldSyncLogFilters, } from '@pagerduty/backstage-plugin-common'; import { resolvePackagePath } from '@backstage/backend-plugin-api'; import { Knex } from 'knex'; @@ -15,6 +18,31 @@ export type RawDbEntityResultRow = { processedDate?: Date; }; +export type RawDbCustomFieldRow = { + id: number; + pagerdutyCustomFieldId: string; + pagerdutyCustomFieldDisplayName: string; + pagerdutyCustomFieldEnabled: boolean; + backstageEntityMappingPath: string; + pagerdutySubdomain: string; + description?: string; + createdAt: Date; + updatedAt: Date; +}; + +export type RawDbSyncLogRow = { + id: number; + timestamp: Date; + errorCode: string; + customFieldId: string; + customFieldName: string; + entityPath: string; + serviceId: string; + serviceName: string; + errorMessage: string; + subdomain: string; +}; + /** @public */ export interface PagerDutyBackendStore { insertEntityMapping(entity: PagerDutyEntityMapping): Promise; @@ -31,8 +59,65 @@ export interface PagerDutyBackendStore { updateSetting(setting: PagerDutySetting): Promise; findSetting(settingId: string): Promise; getAllSettings(): Promise; + insertCustomField(customField: Omit): Promise; + getAllCustomFields( + subdomain: string, + options?: { enabled?: boolean }, + ): Promise; + findCustomFieldById(id: number): Promise; + updateCustomField( + id: number, + updates: { + pagerdutyCustomFieldDisplayName: string; + backstageEntityMappingPath: string; + description?: string; + }, + ): Promise; + setCustomFieldEnabled(id: number, enabled: boolean): Promise; + deleteCustomField(id: number): Promise; + updateCustomFieldPagerDutyId(id: number, pagerdutyCustomFieldId: string): Promise; + insertSyncLog( + log: Omit, + ): Promise; + getSyncLogs( + subdomain: string, + options?: SyncLogQueryOptions, + ): Promise<{ + logs: CustomFieldSyncLog[]; + total: number; + customFieldNames: string[]; + entityPaths: string[]; + serviceNames: string[]; + }>; + cleanupSyncLogs( + options: SyncLogCleanupOptions, + ): Promise; } +/** @public */ +export type SyncLogCleanupOptions = { + olderThanDays: number; + maxRows: number; + batchSize: number; + maxBatchesPerRun: number; +}; + +/** @public */ +export type SyncLogCleanupResult = { + deletedByAge: number; + deletedByCap: number; + batchesUsed: number; +}; + +/** @public */ +export type SyncLogQueryOptions = CustomFieldSyncLogFilters & { + limit?: number; + offset?: number; +}; + +const ERROR_SEVERITY_CODES = ['PD_API_ERROR'] as const; +const INFO_SEVERITY_CODES = ['SYNC_SUCCESS'] as const; + type Options = { skipMigrations?: boolean; }; @@ -96,13 +181,26 @@ export class PagerDutyBackendDatabase implements PagerDutyBackendStore { account: entity.account, })); - const results = await this.db( - 'pagerduty_entity_mapping', - ) - .insert(rows) - .returning('id'); + // Insert in chunks inside a single transaction. A single multi-row insert + // is compiled by the SQLite driver into a `SELECT ... UNION ALL ...` with + // one term per row, which overflows SQLITE_MAX_COMPOUND_SELECT (default + // 500) for large batches and fails the whole insert with "too many terms + // in compound SELECT". Chunking keeps each statement under that limit while + // the transaction preserves the all-or-nothing behavior callers expect. + // The ids are generated here, so we return them directly (preserving input + // order) rather than relying on driver-specific RETURNING semantics. + const CHUNK_SIZE = 200; - return results.map(r => r.id); + await this.db.transaction(async trx => { + for (let i = 0; i < rows.length; i += CHUNK_SIZE) { + const chunk = rows.slice(i, i + CHUNK_SIZE); + await trx('pagerduty_entity_mapping').insert( + chunk, + ); + } + }); + + return rows.map(r => r.id); } async getAllEntityMappings(): Promise { @@ -171,4 +269,334 @@ export class PagerDutyBackendDatabase implements PagerDutyBackendStore { return rawEntities; } + + private toBackstageCustomField(row: RawDbCustomFieldRow): BackstageCustomField { + return { + ...row, + pagerdutyCustomFieldEnabled: Boolean(row.pagerdutyCustomFieldEnabled), + }; + } + + async insertCustomField( + customField: Omit, + ): Promise { + await this.db('pagerduty_custom_fields').insert({ + pagerdutyCustomFieldId: customField.pagerdutyCustomFieldId, + pagerdutyCustomFieldDisplayName: customField.pagerdutyCustomFieldDisplayName, + pagerdutyCustomFieldEnabled: customField.pagerdutyCustomFieldEnabled, + backstageEntityMappingPath: customField.backstageEntityMappingPath, + pagerdutySubdomain: customField.pagerdutySubdomain, + description: customField.description, + }); + + const result = await this.db( + 'pagerduty_custom_fields', + ) + .where({ + pagerdutyCustomFieldId: customField.pagerdutyCustomFieldId, + pagerdutySubdomain: customField.pagerdutySubdomain, + }) + .orderBy('createdAt', 'desc') + .first(); + + if (!result) { + throw new Error( + 'Failed to retrieve custom field after insert into pagerduty_custom_fields', + ); + } + + return this.toBackstageCustomField(result); + } + + async findCustomFieldById(id: number): Promise { + const result = await this.db('pagerduty_custom_fields') + .where('id', id) + .first(); + + if (!result) return undefined; + + return this.toBackstageCustomField(result); + } + + async updateCustomField( + id: number, + updates: { + pagerdutyCustomFieldDisplayName: string; + backstageEntityMappingPath: string; + description?: string; + }, + ): Promise { + const rowsAffected = await this.db('pagerduty_custom_fields') + .where('id', id) + .update({ + pagerdutyCustomFieldDisplayName: updates.pagerdutyCustomFieldDisplayName, + backstageEntityMappingPath: updates.backstageEntityMappingPath, + description: updates.description, + updatedAt: new Date(), + }); + + if (rowsAffected === 0) { + throw new Error(`Custom field with id ${id} does not exist`); + } + + const result = await this.db('pagerduty_custom_fields') + .where('id', id) + .first(); + + if (!result) { + throw new Error(`Failed to retrieve custom field after update for id: ${id}`); + } + + return this.toBackstageCustomField(result); + } + + async setCustomFieldEnabled( + id: number, + enabled: boolean, + ): Promise { + const rowsAffected = await this.db('pagerduty_custom_fields') + .where('id', id) + .update({ + pagerdutyCustomFieldEnabled: enabled, + updatedAt: new Date(), + }); + + if (rowsAffected === 0) { + throw new Error(`Custom field with id ${id} does not exist`); + } + + const result = await this.db('pagerduty_custom_fields') + .where('id', id) + .first(); + + if (!result) { + throw new Error(`Failed to retrieve custom field after update for id: ${id}`); + } + + return this.toBackstageCustomField(result); + } + + async getAllCustomFields( + subdomain: string, + options: { enabled?: boolean } = {}, + ): Promise { + const query = this.db('pagerduty_custom_fields').where( + 'pagerdutySubdomain', + subdomain, + ); + if (options.enabled !== undefined) { + query.where('pagerdutyCustomFieldEnabled', options.enabled); + } + const rawFields = await query; + + if (!rawFields) { + return []; + } + + return rawFields.map(field => this.toBackstageCustomField(field)); + } + + async deleteCustomField(id: number): Promise { + await this.db('pagerduty_custom_fields') + .where('id', id) + .delete(); + } + + async updateCustomFieldPagerDutyId( + id: number, + pagerdutyCustomFieldId: string, + ): Promise { + await this.db('pagerduty_custom_fields') + .where('id', id) + .update({ + pagerdutyCustomFieldId, + updatedAt: new Date(), + }); + } + + async insertSyncLog( + log: Omit, + ): Promise { + await this.db('pagerduty_custom_field_sync_logs').insert({ + errorCode: log.errorCode, + customFieldId: log.customFieldId, + customFieldName: log.customFieldName, + entityPath: log.entityPath, + serviceId: log.serviceId, + serviceName: log.serviceName, + errorMessage: log.errorMessage, + subdomain: log.subdomain, + }); + } + + async getSyncLogs( + subdomain: string, + options?: SyncLogQueryOptions, + ): Promise<{ + logs: CustomFieldSyncLog[]; + total: number; + customFieldNames: string[]; + entityPaths: string[]; + serviceNames: string[]; + }> { + const limit = options?.limit ?? 100; + const offset = options?.offset ?? 0; + + const baseQuery = () => { + let q = this.db('pagerduty_custom_field_sync_logs') + .where('subdomain', subdomain); + + if (options?.severity === 'error') { + q = q.where(builder => + builder + .whereIn('errorCode', [...ERROR_SEVERITY_CODES]) + .orWhereRaw('LOWER(??) LIKE ?', ['errorCode', '%error%']), + ); + } else if (options?.severity === 'info') { + q = q.whereIn('errorCode', [...INFO_SEVERITY_CODES]); + } else if (options?.severity === 'warning') { + // Warning is the complement of both error and info: anything that is + // neither a known error/info code nor an error-by-name. + q = q + .whereNotIn('errorCode', [...ERROR_SEVERITY_CODES]) + .whereNotIn('errorCode', [...INFO_SEVERITY_CODES]) + .whereRaw('LOWER(??) NOT LIKE ?', ['errorCode', '%error%']); + } + + if (options?.customFieldName) { + q = q.where('customFieldName', options.customFieldName); + } + if (options?.entityPath) { + q = q.where('entityPath', options.entityPath); + } + if (options?.serviceName) { + q = q.where('serviceName', options.serviceName); + } + if (options?.search) { + const pattern = `%${options.search.toLowerCase()}%`; + q = q.where(builder => + builder + .whereRaw('LOWER(??) LIKE ?', ['errorMessage', pattern]) + .orWhereRaw('LOWER(??) LIKE ?', ['customFieldName', pattern]) + .orWhereRaw('LOWER(??) LIKE ?', ['entityPath', pattern]) + .orWhereRaw('LOWER(??) LIKE ?', ['serviceName', pattern]) + .orWhereRaw('LOWER(??) LIKE ?', ['errorCode', pattern]), + ); + } + + return q; + }; + + const [logs, countResult, customFields, entityPaths, services] = + await Promise.all([ + baseQuery().orderBy('timestamp', 'desc').limit(limit).offset(offset), + baseQuery().count<{ count: string | number }>('* as count').first(), + this.db('pagerduty_custom_field_sync_logs') + .where('subdomain', subdomain) + .whereNotNull('customFieldName') + .distinct('customFieldName') + .orderBy('customFieldName', 'asc'), + this.db('pagerduty_custom_field_sync_logs') + .where('subdomain', subdomain) + .whereNotNull('entityPath') + .distinct('entityPath') + .orderBy('entityPath', 'asc'), + this.db('pagerduty_custom_field_sync_logs') + .where('subdomain', subdomain) + .whereNotNull('serviceName') + .distinct('serviceName') + .orderBy('serviceName', 'asc'), + ]); + + const total = countResult ? Number(countResult.count) : 0; + + return { + logs: logs || [], + total, + customFieldNames: customFields.map(r => r.customFieldName).filter(Boolean), + entityPaths: entityPaths.map(r => r.entityPath).filter(Boolean), + serviceNames: services.map(r => r.serviceName).filter(Boolean), + }; + } + + async cleanupSyncLogs( + options: SyncLogCleanupOptions, + ): Promise { + const { olderThanDays, maxRows, batchSize, maxBatchesPerRun } = options; + const table = 'pagerduty_custom_field_sync_logs'; + + const result: SyncLogCleanupResult = { + deletedByAge: 0, + deletedByCap: 0, + batchesUsed: 0, + }; + + const newest = (await this.db(table) + .max('id as id') + .first()) as unknown as { id: number | null } | undefined; + if (!newest?.id) { + return result; + } + + const BATCH_SLEEP_MS = 50; + const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + + // Rows are stamped by the DB clock (CURRENT_TIMESTAMP, UTC on SQLite), so + // the cutoff is bound as a 'YYYY-MM-DD HH:MM:SS' UTC string: better-sqlite3 + // stores timestamps as strings of that format and would never match a + // numeric Date binding, while Postgres casts the string to a timestamp. + // Clock skew between app and DB is irrelevant at multi-day granularity. + const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000) + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, ''); + + // Delete in id order via an IN subquery: Postgres has no DELETE ... LIMIT, + // and each batch runs in its own short implicit transaction so concurrent + // inserts (which append the highest ids) are never blocked for long. + const deleteBatch = async (filter: (q: Knex.QueryBuilder) => void) => { + const subquery = this.db(table) + .select('id') + .orderBy('id', 'asc') + .limit(batchSize); + filter(subquery); + return this.db(table).whereIn('id', subquery).delete(); + }; + + while (result.batchesUsed < maxBatchesPerRun) { + const deleted = await deleteBatch(q => q.where('timestamp', '<', cutoff)); + result.deletedByAge += deleted; + result.batchesUsed += 1; + if (deleted < batchSize) { + break; + } + await sleep(BATCH_SLEEP_MS); + } + + // Hard cap: keep only the newest maxRows rows. The cap is global across + // subdomains — a flooding subdomain may evict another's logs, which is + // acceptable for diagnostic data that the TTL bounds anyway. The threshold + // id is found with an index-only probe on the primary key (no count(*)). + const threshold = await this.db(table) + .select('id') + .orderBy('id', 'desc') + .offset(maxRows) + .limit(1) + .first(); + if (threshold) { + while (result.batchesUsed < maxBatchesPerRun) { + const deleted = await deleteBatch(q => + q.where('id', '<=', threshold.id), + ); + result.deletedByCap += deleted; + result.batchesUsed += 1; + if (deleted < batchSize) { + break; + } + await sleep(BATCH_SLEEP_MS); + } + } + + return result; + } } diff --git a/plugins/backstage-plugin-backend/src/db/index.ts b/plugins/backstage-plugin-backend/src/db/index.ts index 5eacf0d..9881273 100644 --- a/plugins/backstage-plugin-backend/src/db/index.ts +++ b/plugins/backstage-plugin-backend/src/db/index.ts @@ -1,2 +1,6 @@ export { PagerDutyBackendDatabase } from './PagerDutyBackendDatabase'; -export type { PagerDutyBackendStore } from './PagerDutyBackendDatabase'; +export type { + PagerDutyBackendStore, + SyncLogCleanupOptions, + SyncLogCleanupResult, +} from './PagerDutyBackendDatabase'; diff --git a/plugins/backstage-plugin-backend/src/plugin.test.ts b/plugins/backstage-plugin-backend/src/plugin.test.ts new file mode 100644 index 0000000..086e91d --- /dev/null +++ b/plugins/backstage-plugin-backend/src/plugin.test.ts @@ -0,0 +1,41 @@ +import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import { pagerDutyPlugin } from './plugin'; +import { SYNC_LOGS_CLEANUP_TASK_ID } from './services/syncLogsCleanup'; + +describe('pagerDutyPlugin', () => { + it('schedules the sync log cleanup task with global scope', async () => { + const scheduler = mockServices.scheduler.mock(); + + await startTestBackend({ + features: [pagerDutyPlugin, scheduler.factory], + }); + + expect(scheduler.scheduleTask).toHaveBeenCalledWith( + expect.objectContaining({ + id: SYNC_LOGS_CLEANUP_TASK_ID, + frequency: { minutes: 15 }, + scope: 'global', + }), + ); + }); + + it('does not schedule the cleanup task when disabled in config', async () => { + const scheduler = mockServices.scheduler.mock(); + + await startTestBackend({ + features: [ + pagerDutyPlugin, + scheduler.factory, + mockServices.rootConfig.factory({ + data: { + pagerDuty: { + customFieldsSyncLogs: { cleanup: { enabled: false } }, + }, + }, + }), + ], + }); + + expect(scheduler.scheduleTask).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/backstage-plugin-backend/src/plugin.ts b/plugins/backstage-plugin-backend/src/plugin.ts index 6d8520a..2f6442e 100644 --- a/plugins/backstage-plugin-backend/src/plugin.ts +++ b/plugins/backstage-plugin-backend/src/plugin.ts @@ -6,6 +6,13 @@ import { } from '@backstage/backend-plugin-api'; import { createRouter } from './service/router'; import { PagerDutyBackendDatabase, PagerDutyBackendStore } from './db'; +import { + readSyncLogsCleanupConfig, + runSyncLogsCleanup, + SYNC_LOGS_CLEANUP_INITIAL_DELAY_MINUTES, + SYNC_LOGS_CLEANUP_TASK_ID, + SYNC_LOGS_CLEANUP_TIMEOUT_MINUTES, +} from './services/syncLogsCleanup'; import { CatalogClient } from '@backstage/catalog-client'; class CatalogFetchApi { @@ -43,6 +50,7 @@ export const pagerDutyPlugin = createBackendPlugin({ discovery: coreServices.discovery, auth: coreServices.auth, cache: coreServices.cache, + scheduler: coreServices.scheduler, }, async init({ config, @@ -52,12 +60,30 @@ export const pagerDutyPlugin = createBackendPlugin({ discovery, auth, cache, + scheduler, }) { const pagerDutyBackendStore: PagerDutyBackendStore = await PagerDutyBackendDatabase.create(await database.getClient(), { skipMigrations: false, }); + const cleanupConfig = readSyncLogsCleanupConfig(config); + if (cleanupConfig.enabled) { + await scheduler.scheduleTask({ + id: SYNC_LOGS_CLEANUP_TASK_ID, + frequency: { minutes: cleanupConfig.frequencyMinutes }, + timeout: { minutes: SYNC_LOGS_CLEANUP_TIMEOUT_MINUTES }, + initialDelay: { minutes: SYNC_LOGS_CLEANUP_INITIAL_DELAY_MINUTES }, + scope: 'global', + fn: () => + runSyncLogsCleanup({ + store: pagerDutyBackendStore, + logger, + cleanupConfig, + }), + }); + } + httpRouter.use( await createRouter({ config, diff --git a/plugins/backstage-plugin-backend/src/service/customFieldsController.ts b/plugins/backstage-plugin-backend/src/service/customFieldsController.ts new file mode 100644 index 0000000..f2c1dac --- /dev/null +++ b/plugins/backstage-plugin-backend/src/service/customFieldsController.ts @@ -0,0 +1,627 @@ +import { LoggerService } from '@backstage/backend-plugin-api'; +import { Request, Response } from 'express'; +import { PagerDutyBackendStore } from '../db/PagerDutyBackendDatabase'; +import { + createCustomField, + deleteCustomField, + setServiceCustomFieldValues, + updateCustomField, +} from '../apis/pagerduty'; +import { + BackstageCustomFieldCreateRequest, + BackstageCustomFieldToggleEnabledRequest, + BackstageCustomFieldUpdateRequest, + BackstageCustomFieldsResponse, + CustomFieldSyncLogCreateRequest, + HttpError, + PagerDutyCustomFieldCreateRequest, + PagerDutyCustomFieldUpdateRequest, + PagerDutyServiceCustomFieldValue, +} from '@pagerduty/backstage-plugin-common'; + +export interface CustomFieldsControllerOptions { + logger: LoggerService; + store: PagerDutyBackendStore; +} + +export class CustomFieldsController { + private readonly logger: LoggerService; + private readonly store: PagerDutyBackendStore; + + constructor(options: CustomFieldsControllerOptions) { + this.logger = options.logger; + this.store = options.store; + } + + async createCustomField(request: Request, response: Response): Promise { + let backstageRecordId: number | undefined; + + try { + const { name, entityPath, description } = + request.body as BackstageCustomFieldCreateRequest; + + const sanitizedName = this.validateFieldInput(name, entityPath); + const subdomain = this.getSubdomainFromRequest(request); + const normalizedDescription = this.normalizeDescription(description, entityPath); + + // Step 1: Create in Backstage DB first with temporary PagerDuty ID + const tempPagerDutyId = `PENDING_${Date.now()}`; + try { + const backstageRecord = await this.store.insertCustomField({ + pagerdutyCustomFieldId: tempPagerDutyId, + pagerdutyCustomFieldDisplayName: name, + pagerdutyCustomFieldEnabled: true, + backstageEntityMappingPath: entityPath, + pagerdutySubdomain: subdomain, + description: normalizedDescription, + }); + backstageRecordId = backstageRecord.id; + + this.logger.info( + `Created temporary Backstage record (id=${backstageRecordId}) for custom field: ${name}`, + ); + } catch (error) { + this.handleDbError(error); + } + + // Step 2: Create in PagerDuty + const pagerDutyRequest: PagerDutyCustomFieldCreateRequest = { + field: { + data_type: 'string', + description: normalizedDescription, + display_name: name, + enabled: true, + field_type: 'single_value', + name: sanitizedName, + }, + }; + + let pagerDutyField; + try { + const pagerDutyResponse = await createCustomField({ + request: pagerDutyRequest, + account: subdomain, + }); + pagerDutyField = pagerDutyResponse.field; + + this.logger.info( + `Created PagerDuty custom field: ${name} (${pagerDutyField.id})`, + ); + } catch (error) { + // Rollback: Delete the Backstage record since PagerDuty creation failed + if (backstageRecordId) { + await this.store.deleteCustomField(backstageRecordId); + this.logger.info( + `Rolled back Backstage record (id=${backstageRecordId}) due to PagerDuty creation failure`, + ); + } + if (error instanceof HttpError) this.handlePagerDutyError(error); + throw error; + } + + // Step 3: Update Backstage record with real PagerDuty ID + try { + await this.store.updateCustomFieldPagerDutyId(backstageRecordId!, pagerDutyField.id); + + const customField = await this.store.findCustomFieldById(backstageRecordId!); + + this.logger.info( + `Successfully created custom field: ${name} (${pagerDutyField.id}) mapped to ${entityPath}`, + ); + response.status(201).json({ customField }); + } catch (error) { + this.logger.error( + `Failed to update Backstage record with PagerDuty ID. Manual cleanup may be required for PagerDuty field ${pagerDutyField.id}`, + ); + throw error; + } + } catch (error) { + this.handleUnexpectedError(error, 'creating the custom field', response); + } + } + + async getCustomFields(request: Request, response: Response): Promise { + try { + const subdomain = this.getSubdomainFromRequest(request); + const enabled = parseEnabledQuery(request.query.enabled); + const customFields = await this.store.getAllCustomFields(subdomain, { + enabled, + }); + const responseData: BackstageCustomFieldsResponse = { customFields }; + response.status(200).json(responseData); + } catch (error) { + this.handleUnexpectedError(error, 'fetching custom fields', response); + } + } + + async syncCustomFieldValues( + request: Request, + response: Response, + ): Promise { + try { + const { serviceId, values } = request.body as { + serviceId?: string; + values?: PagerDutyServiceCustomFieldValue[]; + }; + const subdomain = this.getSubdomainFromRequest(request); + + if (!serviceId) { + throw new HttpError('Missing required field: serviceId', 400); + } + if (!Array.isArray(values) || values.length === 0) { + response.status(204).end(); + return; + } + + try { + const result = await setServiceCustomFieldValues({ + serviceId, + request: { custom_fields: values }, + account: subdomain, + }); + this.logger.info( + `Synced ${values.length} custom field value(s) to PagerDuty service ${serviceId}`, + ); + response.status(200).json(result); + } catch (error) { + if (error instanceof HttpError) this.handlePagerDutyError(error); + throw error; + } + } catch (error) { + this.handleUnexpectedError( + error, + 'syncing custom field values', + response, + ); + } + } + + async updateCustomField(request: Request, response: Response): Promise { + try { + const id = parseInt(request.params.id, 10); + if (isNaN(id)) throw new HttpError('Invalid id parameter', 400); + + const existing = await this.store.findCustomFieldById(id); + if (!existing) throw new HttpError('Custom field not found', 404); + + const { name, entityPath, description } = + request.body as BackstageCustomFieldUpdateRequest; + + this.validateFieldInput(name, entityPath); + + // Validate uniqueness constraints BEFORE making any changes + await this.validateUniqueConstraints( + id, + name, + entityPath, + existing.pagerdutySubdomain, + ); + + const normalizedDescription = this.normalizeDescription(description, entityPath); + + // Snapshot current values for rollback + const snapshot = { + pagerdutyCustomFieldDisplayName: existing.pagerdutyCustomFieldDisplayName, + backstageEntityMappingPath: existing.backstageEntityMappingPath, + description: existing.description, + }; + + // Step 1: Update Backstage DB first + try { + await this.store.updateCustomField(id, { + pagerdutyCustomFieldDisplayName: name, + backstageEntityMappingPath: entityPath, + description: normalizedDescription, + }); + + this.logger.info(`Updated Backstage record for custom field id=${id}`); + } catch (error) { + this.handleDbError(error); + } + + // Step 2: Update PagerDuty + const pagerDutyRequest: PagerDutyCustomFieldUpdateRequest = { + field: { display_name: name, description: normalizedDescription }, + }; + + try { + await updateCustomField({ + fieldId: existing.pagerdutyCustomFieldId, + request: pagerDutyRequest, + account: existing.pagerdutySubdomain, + }); + + this.logger.info(`Updated PagerDuty custom field: ${name} (${existing.pagerdutyCustomFieldId})`); + } catch (error) { + // Rollback: Revert Backstage DB to snapshot + try { + await this.store.updateCustomField(id, snapshot); + this.logger.info( + `Rolled back Backstage record (id=${id}) to previous values due to PagerDuty update failure`, + ); + } catch (rollbackError) { + this.logger.error( + `CRITICAL: Failed to rollback Backstage record (id=${id}) after PagerDuty failure. Manual intervention required.`, + rollbackError as Error, + ); + } + + if (error instanceof HttpError) this.handlePagerDutyError(error); + throw error; + } + + // Step 3: Return updated record + const customField = await this.store.findCustomFieldById(id); + this.logger.info(`Successfully updated custom field id=${id} (${name})`); + response.status(200).json({ customField }); + } catch (error) { + this.handleUnexpectedError(error, 'updating the custom field', response); + } + } + + async toggleCustomFieldEnabled( + request: Request, + response: Response, + ): Promise { + try { + const id = parseInt(request.params.id, 10); + if (isNaN(id)) throw new HttpError('Invalid id parameter', 400); + + const { enabled } = request.body as BackstageCustomFieldToggleEnabledRequest; + if (typeof enabled !== 'boolean') { + throw new HttpError('Invalid enabled value', 400); + } + + const existing = await this.store.findCustomFieldById(id); + if (!existing) throw new HttpError('Custom field not found', 404); + + // No-op if already in the requested state + if (existing.pagerdutyCustomFieldEnabled === enabled) { + response.status(200).json({ customField: existing }); + return; + } + + const previousEnabled = existing.pagerdutyCustomFieldEnabled; + + // Step 1: Update Backstage DB first + try { + await this.store.setCustomFieldEnabled(id, enabled); + this.logger.info( + `Updated Backstage record for custom field id=${id} (enabled=${enabled})`, + ); + } catch (error) { + this.handleDbError(error); + } + + // Step 2: Update PagerDuty. display_name is required by the update request, + // so we send the existing name unchanged alongside the enabled flag. + const pagerDutyRequest: PagerDutyCustomFieldUpdateRequest = { + field: { + display_name: existing.pagerdutyCustomFieldDisplayName, + enabled, + }, + }; + + try { + await updateCustomField({ + fieldId: existing.pagerdutyCustomFieldId, + request: pagerDutyRequest, + account: existing.pagerdutySubdomain, + }); + this.logger.info( + `Updated PagerDuty custom field enabled=${enabled}: ${existing.pagerdutyCustomFieldDisplayName} (${existing.pagerdutyCustomFieldId})`, + ); + } catch (error) { + // Rollback: revert Backstage DB to its previous enabled state + try { + await this.store.setCustomFieldEnabled(id, previousEnabled); + this.logger.info( + `Rolled back Backstage record (id=${id}) to enabled=${previousEnabled} due to PagerDuty update failure`, + ); + } catch (rollbackError) { + this.logger.error( + `CRITICAL: Failed to rollback Backstage record (id=${id}) after PagerDuty failure. Manual intervention required.`, + rollbackError as Error, + ); + } + + if (error instanceof HttpError) this.handlePagerDutyError(error); + throw error; + } + + const customField = await this.store.findCustomFieldById(id); + this.logger.info( + `Successfully toggled custom field id=${id} to enabled=${enabled}`, + ); + response.status(200).json({ customField }); + } catch (error) { + this.handleUnexpectedError( + error, + 'toggling the custom field enabled state', + response, + ); + } + } + + async deleteCustomField(request: Request, response: Response): Promise { + try { + const id = parseInt(request.params.id, 10); + if (isNaN(id)) throw new HttpError('Invalid id parameter', 400); + + const existing = await this.store.findCustomFieldById(id); + if (!existing) throw new HttpError('Custom field not found', 404); + + // Step 1: Delete from PagerDuty first + try { + await deleteCustomField({ + fieldId: existing.pagerdutyCustomFieldId, + account: existing.pagerdutySubdomain, + }); + this.logger.info( + `Deleted PagerDuty custom field: ${existing.pagerdutyCustomFieldDisplayName} (${existing.pagerdutyCustomFieldId})`, + ); + } catch (error) { + // A 404 means the field no longer exists in PagerDuty. Treat the + // delete as already done and fall through to remove the Backstage + // record so the two sides don't drift out of sync. + if (error instanceof HttpError && error.status === 404) { + this.logger.info( + `PagerDuty custom field ${existing.pagerdutyCustomFieldId} already absent (404); proceeding to delete Backstage record id=${id}`, + ); + } else { + if (error instanceof HttpError) this.handlePagerDutyError(error); + throw error; + } + } + + // Step 2: Delete from Backstage DB + await this.store.deleteCustomField(id); + this.logger.info( + `Deleted Backstage custom field record id=${id} (${existing.pagerdutyCustomFieldDisplayName})`, + ); + + response.status(204).end(); + } catch (error) { + this.handleUnexpectedError(error, 'deleting the custom field', response); + } + } + + private async validateUniqueConstraints( + currentId: number, + name: string, + entityPath: string, + subdomain: string, + ): Promise { + const allFields = await this.store.getAllCustomFields(subdomain); + + // Check if another field (not the current one) has the same display name + const duplicateName = allFields.find( + field => + field.id !== currentId && + field.pagerdutyCustomFieldDisplayName === name, + ); + if (duplicateName) { + throw new HttpError( + 'A custom field with this display name already exists', + 409, + ); + } + + // Check if another field (not the current one) has the same entity path + const duplicateEntityPath = allFields.find( + field => + field.id !== currentId && + field.backstageEntityMappingPath === entityPath, + ); + if (duplicateEntityPath) { + throw new HttpError( + 'A custom field with this entity path already exists', + 409, + ); + } + } + + private validateFieldInput( + name: string | undefined, + entityPath: string | undefined, + ): string { + if (!name || !entityPath) { + throw new HttpError( + 'Missing required fields: name and entityPath are required', + 400, + ); + } + const sanitizedName = this.sanitizeFieldName(name); + if (!sanitizedName) { + throw new HttpError( + 'Field name must contain at least one alphanumeric character', + 400, + ); + } + return sanitizedName; + } + + private sanitizeFieldName(name: string): string { + return name + .toLowerCase() + .replace(/\s+/g, '_') + .replace(/[^a-z0-9_]/g, ''); + } + + private normalizeDescription( + description: string | undefined, + entityPath: string, + ): string { + return (description ?? '').trim() || `Backstage custom field: ${entityPath}`; + } + + private getSubdomainFromRequest(request: Request): string { + return (request.query.account as string) || 'default'; + } + + private handleDbError(error: unknown): never { + const msg = error instanceof Error ? error.message : String(error); + const code = (error as { code?: string })?.code; + + const isUniqueViolation = + code === '23505' || // PostgreSQL + msg.toLowerCase().includes('unique constraint') || // SQLite / generic + msg.toLowerCase().includes('unique violation'); + + if (isUniqueViolation) { + if ( + msg.includes('pagerduty_cf_entitypath_subdomain_unique') || + msg.includes('backstageEntityMappingPath') + ) { + throw new HttpError( + 'A custom field with this entity path already exists', + 409, + ); + } + if ( + msg.includes('pagerduty_cf_displayname_subdomain_unique') || + msg.includes('pagerdutyCustomFieldDisplayName') + ) { + throw new HttpError( + 'A custom field with this display name already exists', + 409, + ); + } + throw new HttpError('A custom field with these values already exists', 409); + } + + throw error instanceof Error ? error : new Error(String(error)); + } + + private handlePagerDutyError(error: HttpError): never { + if (error.status === 400) { + const message = error.message.toLowerCase().includes('product limit reached') + ? 'PagerDuty custom field limit reached. Maximum number of custom fields (15 or 30) has been exceeded.' + : error.message; + throw new HttpError(message, 400); + } + if (error.status === 401) { + throw new HttpError( + 'Authentication failed. Please check your PagerDuty API credentials.', + 401, + ); + } + if (error.status === 403) { + throw new HttpError( + 'Authorization failed. You do not have permission to manage custom fields in PagerDuty.', + 403, + ); + } + if (error.status === 404) { + throw new HttpError('Custom field or service not found in PagerDuty', 404); + } + if (error.status === 409) { + throw new HttpError( + 'A custom field with this name already exists in PagerDuty', + 409, + ); + } + if (error.status === 429) { + throw new HttpError( + 'PagerDuty API rate limit exceeded. Please try again in a few moments.', + 429, + ); + } + throw error; + } + + private handleUnexpectedError( + error: unknown, + context: string, + response: Response, + ): void { + this.logger.error(`Failed ${context}: ${error}`); + if (error instanceof HttpError) { + response.status(error.status).json({ errors: [error.message] }); + } else { + response.status(500).json({ + errors: [`An unexpected error occurred while ${context}`], + }); + } + } + + async createSyncLog(request: Request, response: Response): Promise { + try { + const log = request.body as CustomFieldSyncLogCreateRequest; + const subdomain = this.getSubdomainFromRequest(request); + + if ( + !log.errorCode || + !log.customFieldId || + !log.customFieldName || + !log.entityPath || + !log.serviceId || + !log.serviceName || + !log.errorMessage + ) { + throw new HttpError('Missing required fields in sync log', 400); + } + + await this.store.insertSyncLog({ + errorCode: log.errorCode, + customFieldId: log.customFieldId, + customFieldName: log.customFieldName, + entityPath: log.entityPath, + serviceId: log.serviceId, + serviceName: log.serviceName, + errorMessage: log.errorMessage, + subdomain, + }); + + response.status(201).end(); + } catch (error) { + this.handleUnexpectedError(error, 'creating sync log', response); + } + } + + async getSyncLogs( + request: Request, + response: Response, + ): Promise { + try { + const subdomain = this.getSubdomainFromRequest(request); + const limit = request.query.limit + ? parseInt(request.query.limit as string, 10) + : undefined; + const offset = request.query.offset + ? parseInt(request.query.offset as string, 10) + : undefined; + const severityParam = (request.query.severity as string) || undefined; + const severity = + severityParam === 'error' || + severityParam === 'warning' || + severityParam === 'info' + ? severityParam + : undefined; + const search = (request.query.search as string) || undefined; + const customFieldName = + (request.query.customFieldName as string) || undefined; + const entityPath = (request.query.entityPath as string) || undefined; + const serviceName = (request.query.serviceName as string) || undefined; + + const result = await this.store.getSyncLogs(subdomain, { + limit, + offset, + severity, + search, + customFieldName, + entityPath, + serviceName, + }); + response.status(200).json(result); + } catch (error) { + this.handleUnexpectedError(error, 'fetching sync logs', response); + } + } +} + +function parseEnabledQuery(value: unknown): boolean | undefined { + if (value === 'true') return true; + if (value === 'false') return false; + return undefined; +} diff --git a/plugins/backstage-plugin-backend/src/service/router.test.ts b/plugins/backstage-plugin-backend/src/service/router.test.ts index a18bac2..4b3231c 100644 --- a/plugins/backstage-plugin-backend/src/service/router.test.ts +++ b/plugins/backstage-plugin-backend/src/service/router.test.ts @@ -228,6 +228,10 @@ describe('createRouter', () => { beforeEach(() => { jest.resetAllMocks(); + // The cache is shared across the suite (created in beforeAll), so a service + // cached by one test would otherwise leak into the next and mask the + // mocked fetch responses (e.g. returning a cached 200 instead of 401/404). + cacheStore.clear(); }); describe('GET /health', () => { @@ -2314,7 +2318,7 @@ describe('createRouter', () => { const response = await request(app) .post('/mapping/entities') - .send({ offset: 20, limit: 5 }); + .send({ offset: 0, limit: 5 }); expect(response.body.entities[0]).toEqual({ account: '', @@ -3480,6 +3484,770 @@ describe('createRouter', () => { expect(response.body).toHaveProperty('errorCount'); }); }); + + describe('POST /custom-fields', () => { + it('returns 200 when custom field is created successfully', async () => { + const customFieldData = { + name: 'Test Field', + entityPath: 'spec.create_success', + description: 'A test custom field', + }; + + const mockPagerDutyResponse = { + field: { + id: 'PTEST123', + display_name: 'Test Field', + name: 'test_field', + data_type: 'string', + field_type: 'single_value', + description: 'A test custom field', + enabled: true, + }, + }; + mocked(fetch).mockReturnValue(mockedResponse(201, mockPagerDutyResponse)); + + const response = await request(app) + .post('/custom-fields') + .send(customFieldData); + + expect(response.status).toEqual(201); + }); + + it('returns 400 when required fields are missing', async () => { + const customFieldData = { + description: 'A test custom field', + }; + + const response = await request(app) + .post('/custom-fields') + .send(customFieldData); + + expect(response.status).toEqual(400); + }); + + it('returns 409 when PagerDuty reports a name conflict', async () => { + const customFieldData = { + name: 'Existing Field', + entityPath: 'spec.name_conflict', + description: 'A custom field that already exists', + }; + + const mockErrorResponse = { + error: { + message: 'Custom field with this name already exists', + code: 2009, + }, + }; + mocked(fetch).mockReturnValue(mockedResponse(409, mockErrorResponse)); + + const response = await request(app) + .post('/custom-fields') + .send(customFieldData); + + expect(response.status).toEqual(409); + }); + + it('returns 409 when entity path is already in use', async () => { + const first = { + name: 'First Field', + entityPath: 'spec.duplicate_path', + description: 'First', + }; + const mockPagerDutyResponse = { + field: { + id: 'PDUP123', + display_name: 'First Field', + name: 'first_field', + data_type: 'string', + field_type: 'single_value', + description: 'First', + enabled: true, + }, + }; + mocked(fetch).mockReturnValue(mockedResponse(201, mockPagerDutyResponse)); + + await request(app).post('/custom-fields').send(first); + + const response = await request(app) + .post('/custom-fields') + .send({ name: 'Second Field', entityPath: 'spec.duplicate_path' }); + + expect(response.status).toEqual(409); + }); + + it('rolls back Backstage DB when PagerDuty creation fails', async () => { + // This test validates that when PagerDuty creation fails, the temporary + // Backstage DB record is deleted to prevent orphaned records. + + const testId = `createrollback${Date.now()}`; + const newField = { + name: `Create Rollback Test ${testId}`, + entityPath: `spec.create_rollback_${testId}`, + description: 'This field creation should be rolled back', + }; + + // Mock PagerDuty to fail with 500 error + mocked(fetch).mockReturnValueOnce( + mockedResponse(500, { error: { message: 'PagerDuty service unavailable' } }), + ); + + // Get count of fields before the failed attempt + const beforeAttempt = await request(app).get('/custom-fields'); + const countBefore = beforeAttempt.body.customFields.length; + + // Attempt to create field (should fail) + const createAttempt = await request(app) + .post('/custom-fields') + .send(newField); + + // Verify creation failed + expect(createAttempt.status).toEqual(500); + + // CRITICAL: Verify no orphaned record exists in Backstage DB + const afterAttempt = await request(app).get('/custom-fields'); + const countAfter = afterAttempt.body.customFields.length; + + // Count should be the same (no new record) + expect(countAfter).toEqual(countBefore); + + // Verify no temporary PENDING_ ID exists in the database + const allFields = afterAttempt.body.customFields; + const hasPendingRecord = allFields.some((f: { pagerdutyCustomFieldId: string }) => + f.pagerdutyCustomFieldId.startsWith('PENDING_'), + ); + expect(hasPendingRecord).toBe(false); + + // Verify the specific field we tried to create doesn't exist + const orphanedField = allFields.find( + (f: { backstageEntityMappingPath: string }) => + f.backstageEntityMappingPath === newField.entityPath, + ); + expect(orphanedField).toBeUndefined(); + }); + + }); + + describe('PUT /custom-fields/:id', () => { + it('returns 200 with updated custom field', async () => { + const createData = { + name: 'Original Field', + entityPath: 'spec.put_success_create', + description: 'Original description', + }; + const mockPagerDutyCreateResponse = { + field: { + id: 'PTEST456', + display_name: 'Original Field', + name: 'original_field', + data_type: 'string', + field_type: 'single_value', + description: 'Original description', + enabled: true, + }, + }; + mocked(fetch) + .mockReturnValueOnce(mockedResponse(201, mockPagerDutyCreateResponse)) + .mockReturnValue(mockedResponse(200, {})); + + const createResponse = await request(app) + .post('/custom-fields') + .send(createData); + expect(createResponse.status).toEqual(201); + const createdId = createResponse.body.customField.id; + + const updateData = { + name: 'New Name', + entityPath: 'metadata.put_success_updated', + description: 'Updated desc', + }; + const response = await request(app) + .put(`/custom-fields/${createdId}`) + .send(updateData); + + expect(response.status).toEqual(200); + expect(response.body.customField.pagerdutyCustomFieldDisplayName).toEqual( + 'New Name', + ); + }); + + it('returns 400 when name is missing', async () => { + const response = await request(app) + .put('/custom-fields/1') + .send({ entityPath: 'metadata.missing_name' }); + + expect(response.status).toEqual(400); + }); + + it('returns 400 when entityPath is missing', async () => { + const response = await request(app) + .put('/custom-fields/1') + .send({ name: 'New Name' }); + + expect(response.status).toEqual(400); + }); + + it('returns 404 when custom field is not found in DB', async () => { + const response = await request(app) + .put('/custom-fields/999') + .send({ + name: 'New Name', + entityPath: 'metadata.not_found_path', + description: 'Updated desc', + }); + + expect(response.status).toEqual(404); + }); + + it('returns 409 when entity path is already used by another field', async () => { + const fieldA = { + name: 'Field A', + entityPath: 'spec.put_path_conflict_a', + description: 'Field A', + }; + const fieldB = { + name: 'Field B', + entityPath: 'spec.put_path_conflict_b', + description: 'Field B', + }; + const mockResponse = (id: string, name: string) => ({ + field: { + id, + display_name: name, + name: name.toLowerCase().replace(/ /g, '_'), + data_type: 'string', + field_type: 'single_value', + description: name, + enabled: true, + }, + }); + + mocked(fetch) + .mockReturnValueOnce(mockedResponse(201, mockResponse('PA1', 'Field A'))) + .mockReturnValueOnce(mockedResponse(201, mockResponse('PB1', 'Field B'))); + + const createA = await request(app).post('/custom-fields').send(fieldA); + expect(createA.status).toEqual(201); + + const createB = await request(app).post('/custom-fields').send(fieldB); + expect(createB.status).toEqual(201); + const idB = createB.body.customField.id; + + // Try updating B to use A's entity path + const response = await request(app) + .put(`/custom-fields/${idB}`) + .send({ + name: 'Field B Renamed', + entityPath: 'spec.put_path_conflict_a', + description: 'Updated', + }); + + expect(response.status).toEqual(409); + }); + + it('validates uniqueness constraints before updating PagerDuty to prevent sync issues', async () => { + // This test validates the fix for the synchronization bug where PagerDuty + // could be updated successfully but the DB update could fail due to constraint + // violations, leaving the two systems out of sync. + + // Create unique identifiers to avoid conflicts with other tests + const testId = `sync${Date.now()}`; + const fieldA = { + name: `Sync Test Field A ${testId}`, + entityPath: `spec.sync_test_field_a_${testId}`, + description: 'Field A for sync bug test', + }; + const fieldB = { + name: `Sync Test Field B ${testId}`, + entityPath: `spec.sync_test_field_b_${testId}`, + description: 'Field B for sync bug test', + }; + + const mockResponse = (name: string) => ({ + field: { + id: `P${testId}_${name.replace(/ /g, '')}`, + display_name: name, + name: name.toLowerCase().replace(/ /g, '_'), + data_type: 'string', + field_type: 'single_value', + description: `Description for ${name}`, + enabled: true, + }, + }); + + // Mock successful creates for both fields + mocked(fetch) + .mockReturnValueOnce(mockedResponse(201, mockResponse(fieldA.name))) + .mockReturnValueOnce(mockedResponse(201, mockResponse(fieldB.name))); + + const createA = await request(app).post('/custom-fields').send(fieldA); + expect(createA.status).toEqual(201); + const originalFieldA = createA.body.customField; + + const createB = await request(app).post('/custom-fields').send(fieldB); + expect(createB.status).toEqual(201); + const idB = createB.body.customField.id; + const originalFieldB = createB.body.customField; + + // Reset mock to track if PagerDuty is called during the update attempt + mocked(fetch).mockClear(); + + // Attempt to update Field B with Field A's entity path (duplicate) + // This should fail validation BEFORE calling PagerDuty + const updateAttempt = await request(app) + .put(`/custom-fields/${idB}`) + .send({ + name: `Sync Test Field B Updated ${testId}`, + entityPath: fieldA.entityPath, // Duplicate of Field A's entity path + description: 'This update should be prevented', + }); + + // Verify the request was rejected with 409 Conflict + expect(updateAttempt.status).toEqual(409); + expect(updateAttempt.body.errors[0]).toContain('entity path already exists'); + + // CRITICAL: Verify that PagerDuty was NEVER called + // This proves the validation happened before any external API calls + expect(fetch).not.toHaveBeenCalled(); + + // Verify Field A remains unchanged in the database + const fieldsAfterAttempt = await request(app).get('/custom-fields'); + const fieldAAfter = fieldsAfterAttempt.body.customFields.find( + (f: { id: number }) => f.id === originalFieldA.id, + ); + const fieldBAfter = fieldsAfterAttempt.body.customFields.find( + (f: { id: number }) => f.id === originalFieldB.id, + ); + + // Field A should be completely unchanged + expect(fieldAAfter).toEqual(originalFieldA); + + // Field B should also be completely unchanged (no partial updates) + expect(fieldBAfter).toEqual(originalFieldB); + + // Verify no data corruption or sync issues occurred + expect(fieldAAfter.backstageEntityMappingPath).toEqual(fieldA.entityPath); + expect(fieldBAfter.backstageEntityMappingPath).toEqual(fieldB.entityPath); + expect(fieldBAfter.pagerdutyCustomFieldDisplayName).toEqual(fieldB.name); + expect(fieldBAfter.description).toEqual(fieldB.description); + }); + + it('validates display name uniqueness before updating PagerDuty to prevent sync issues', async () => { + // Similar test for display name uniqueness constraint + + // Create unique identifiers to avoid conflicts with other tests + const testId = `syncname${Date.now()}`; + const fieldA = { + name: `Sync Name Test A ${testId}`, + entityPath: `spec.sync_name_test_a_${testId}`, + description: 'Field A for name validation', + }; + const fieldB = { + name: `Sync Name Test B ${testId}`, + entityPath: `spec.sync_name_test_b_${testId}`, + description: 'Field B for name validation', + }; + + const mockResponse = (name: string) => ({ + field: { + id: `P${testId}_${name.replace(/ /g, '')}`, + display_name: name, + name: name.toLowerCase().replace(/ /g, '_'), + data_type: 'string', + field_type: 'single_value', + description: `Description for ${name}`, + enabled: true, + }, + }); + + mocked(fetch) + .mockReturnValueOnce(mockedResponse(201, mockResponse(fieldA.name))) + .mockReturnValueOnce(mockedResponse(201, mockResponse(fieldB.name))); + + const createA = await request(app).post('/custom-fields').send(fieldA); + expect(createA.status).toEqual(201); + + const createB = await request(app).post('/custom-fields').send(fieldB); + expect(createB.status).toEqual(201); + const idB = createB.body.customField.id; + + mocked(fetch).mockClear(); + + // Attempt to update Field B with Field A's display name (duplicate) + const updateAttempt = await request(app) + .put(`/custom-fields/${idB}`) + .send({ + name: fieldA.name, // Duplicate of Field A's display name + entityPath: `spec.sync_name_test_b_updated_${testId}`, + description: 'This update should be prevented', + }); + + // Verify rejection with 409 Conflict + expect(updateAttempt.status).toEqual(409); + expect(updateAttempt.body.errors[0]).toContain('display name already exists'); + + // Verify PagerDuty was never called (validation happened first) + expect(fetch).not.toHaveBeenCalled(); + }); + + it('returns 409 when PagerDuty reports a name conflict', async () => { + const createData = { + name: 'Conflict Field', + entityPath: 'spec.put_pd_conflict_create', + description: 'A field', + }; + const mockPagerDutyCreateResponse = { + field: { + id: 'PCONFLICT123', + display_name: 'Conflict Field', + name: 'conflict_field', + data_type: 'string', + field_type: 'single_value', + description: 'A field', + enabled: true, + }, + }; + mocked(fetch) + .mockReturnValueOnce(mockedResponse(201, mockPagerDutyCreateResponse)) + .mockReturnValue(mockedResponse(409, { error: { message: 'Conflict' } })); + + const createResponse = await request(app) + .post('/custom-fields') + .send(createData); + expect(createResponse.status).toEqual(201); + const createdId = createResponse.body.customField.id; + + const response = await request(app) + .put(`/custom-fields/${createdId}`) + .send({ + name: 'Duplicate Name', + entityPath: 'metadata.put_pd_conflict_updated', + description: 'Updated desc', + }); + + expect(response.status).toEqual(409); + }); + + it('rolls back Backstage DB when PagerDuty update fails', async () => { + // This test validates that when PagerDuty update fails, the Backstage DB + // changes are rolled back to maintain consistency between the two systems. + + const testId = `rollback${Date.now()}`; + const originalField = { + name: `Rollback Test Original ${testId}`, + entityPath: `spec.rollback_test_${testId}`, + description: 'Original description', + }; + + // Create field successfully + const mockCreateResponse = { + field: { + id: `PROLLBACK_${testId}`, + display_name: originalField.name, + name: originalField.name.toLowerCase().replace(/ /g, '_'), + data_type: 'string', + field_type: 'single_value', + description: originalField.description, + enabled: true, + }, + }; + + mocked(fetch).mockReturnValueOnce(mockedResponse(201, mockCreateResponse)); + + const createResponse = await request(app) + .post('/custom-fields') + .send(originalField); + expect(createResponse.status).toEqual(201); + const fieldId = createResponse.body.customField.id; + const createdField = createResponse.body.customField; + + // Attempt to update but PagerDuty will fail + mocked(fetch).mockReturnValueOnce( + mockedResponse(500, { error: { message: 'PagerDuty internal error' } }), + ); + + const updateAttempt = await request(app) + .put(`/custom-fields/${fieldId}`) + .send({ + name: `Rollback Test Updated ${testId}`, + entityPath: `spec.rollback_test_updated_${testId}`, + description: 'Updated description that should be rolled back', + }); + + // Verify the update failed + expect(updateAttempt.status).toEqual(500); + + // CRITICAL: Verify Backstage DB was rolled back to original values + const fieldsAfterRollback = await request(app).get('/custom-fields'); + const fieldAfterRollback = fieldsAfterRollback.body.customFields.find( + (f: { id: number }) => f.id === fieldId, + ); + + // Field should still have original values (not updated values) + expect(fieldAfterRollback).toBeDefined(); + expect(fieldAfterRollback.pagerdutyCustomFieldDisplayName).toEqual( + originalField.name, + ); + expect(fieldAfterRollback.backstageEntityMappingPath).toEqual( + originalField.entityPath, + ); + expect(fieldAfterRollback.description).toEqual(originalField.description); + + // Verify no partial updates occurred + expect(fieldAfterRollback.pagerdutyCustomFieldId).toEqual( + createdField.pagerdutyCustomFieldId, + ); + expect(fieldAfterRollback.pagerdutySubdomain).toEqual( + createdField.pagerdutySubdomain, + ); + }); + }); + + describe('PATCH /custom-fields/:id/enabled', () => { + const createField = async (testId: string) => { + const createData = { + name: `Toggle Field ${testId}`, + entityPath: `spec.toggle_${testId}`, + description: 'Toggle test field', + }; + mocked(fetch).mockReturnValueOnce( + mockedResponse(201, { + field: { + id: `PTOGGLE_${testId}`, + display_name: createData.name, + name: createData.name.toLowerCase().replace(/ /g, '_'), + data_type: 'string', + field_type: 'single_value', + description: createData.description, + enabled: true, + }, + }), + ); + const createResponse = await request(app) + .post('/custom-fields') + .send(createData); + expect(createResponse.status).toEqual(201); + return createResponse.body.customField.id as number; + }; + + it('disables a custom field and persists enabled=false', async () => { + const testId = `disable${Date.now()}`; + const fieldId = await createField(testId); + + mocked(fetch).mockReturnValueOnce(mockedResponse(200, {})); + const response = await request(app) + .patch(`/custom-fields/${fieldId}/enabled`) + .send({ enabled: false }); + + expect(response.status).toEqual(200); + expect(response.body.customField.pagerdutyCustomFieldEnabled).toBe(false); + + // Disabled fields should be filtered out of the enabled view + const enabledOnly = await request(app).get('/custom-fields?enabled=true'); + expect( + enabledOnly.body.customFields.find( + (f: { id: number }) => f.id === fieldId, + ), + ).toBeUndefined(); + }); + + it('returns 400 when enabled is not a boolean', async () => { + const response = await request(app) + .patch('/custom-fields/1/enabled') + .send({ enabled: 'yes' }); + expect(response.status).toEqual(400); + }); + + it('returns 404 when the custom field does not exist', async () => { + const response = await request(app) + .patch('/custom-fields/999999/enabled') + .send({ enabled: false }); + expect(response.status).toEqual(404); + }); + + it('rolls back enabled state when PagerDuty rejects re-enable over the limit', async () => { + const testId = `overlimit${Date.now()}`; + const fieldId = await createField(testId); + + // First disable it successfully + mocked(fetch).mockReturnValueOnce(mockedResponse(200, {})); + const disable = await request(app) + .patch(`/custom-fields/${fieldId}/enabled`) + .send({ enabled: false }); + expect(disable.status).toEqual(200); + + // Re-enable fails because the PagerDuty hard limit is reached + mocked(fetch).mockReturnValueOnce( + mockedResponse(400, { error: { message: 'Product limit reached' } }), + ); + const reEnable = await request(app) + .patch(`/custom-fields/${fieldId}/enabled`) + .send({ enabled: true }); + expect(reEnable.status).toEqual(400); + + // DB should have been rolled back to disabled + const all = await request(app).get('/custom-fields'); + const field = all.body.customFields.find( + (f: { id: number }) => f.id === fieldId, + ); + expect(field.pagerdutyCustomFieldEnabled).toBe(false); + }); + }); + + describe('DELETE /custom-fields/:id', () => { + const createField = async (testId: string) => { + const createData = { + name: `Delete Field ${testId}`, + entityPath: `spec.delete_${testId}`, + description: 'Delete test field', + }; + mocked(fetch).mockReturnValueOnce( + mockedResponse(201, { + field: { + id: `PDELETE_${testId}`, + display_name: createData.name, + name: createData.name.toLowerCase().replace(/ /g, '_'), + data_type: 'string', + field_type: 'single_value', + description: createData.description, + enabled: true, + }, + }), + ); + const createResponse = await request(app) + .post('/custom-fields') + .send(createData); + expect(createResponse.status).toEqual(201); + return createResponse.body.customField.id as number; + }; + + const fieldExists = async (fieldId: number) => { + const all = await request(app).get('/custom-fields'); + return all.body.customFields.some( + (f: { id: number }) => f.id === fieldId, + ); + }; + + it('deletes from PagerDuty and removes the Backstage record', async () => { + const testId = `ok${Date.now()}`; + const fieldId = await createField(testId); + + mocked(fetch).mockReturnValueOnce(mockedResponse(204, {})); + const response = await request(app).delete(`/custom-fields/${fieldId}`); + + expect(response.status).toEqual(204); + expect(await fieldExists(fieldId)).toBe(false); + }); + + it('returns 404 when the custom field does not exist in Backstage', async () => { + const response = await request(app).delete('/custom-fields/999999'); + expect(response.status).toEqual(404); + }); + + it('removes the Backstage record when PagerDuty returns 404', async () => { + // The field is already gone from PagerDuty, so the delete should still + // succeed and drop the Backstage record rather than leaving it orphaned. + const testId = `pd404${Date.now()}`; + const fieldId = await createField(testId); + + mocked(fetch).mockReturnValueOnce( + mockedResponse(404, { error: { message: 'Not Found' } }), + ); + const response = await request(app).delete(`/custom-fields/${fieldId}`); + + expect(response.status).toEqual(204); + expect(await fieldExists(fieldId)).toBe(false); + }); + + it('keeps the Backstage record when PagerDuty fails with a non-404 error', async () => { + const testId = `pd500${Date.now()}`; + const fieldId = await createField(testId); + + mocked(fetch).mockReturnValueOnce( + mockedResponse(500, { error: { message: 'PagerDuty internal error' } }), + ); + const response = await request(app).delete(`/custom-fields/${fieldId}`); + + expect(response.status).toEqual(500); + expect(await fieldExists(fieldId)).toBe(true); + }); + }); + + describe('GET /custom-fields', () => { + it.each(testInputs)( + 'returns 200 with customFields array', + async () => { + const response = await request(app).get('/custom-fields'); + + expect(response.status).toEqual(200); + expect(response.body).toHaveProperty('customFields'); + expect(Array.isArray(response.body.customFields)).toBe(true); + }, + ); + + it('filters out disabled fields when enabled=true', async () => { + const filtered = await request(app).get('/custom-fields?enabled=true'); + expect(filtered.status).toEqual(200); + expect(filtered.body.customFields.length).toBeGreaterThan(0); + filtered.body.customFields.forEach((f: { pagerdutyCustomFieldEnabled: boolean }) => { + expect(f.pagerdutyCustomFieldEnabled).toBe(true); + }); + }); + }); + + describe('POST /custom-fields/sync', () => { + const syncResponseBody = { custom_fields: [{ id: 'PD123', value: 'team-a' }] }; + + beforeEach(() => { + mocked(fetch).mockReturnValue(mockedResponse(200, syncResponseBody)); + }); + + it('returns 204 with no values', async () => { + const response = await request(app) + .post('/custom-fields/sync') + .send({ serviceId: 'PSERV1', values: [] }); + + expect(response.status).toEqual(204); + // PagerDuty should NOT be called when there are no values + expect(fetch).not.toHaveBeenCalled(); + }); + + it('returns 400 when serviceId is missing', async () => { + const response = await request(app) + .post('/custom-fields/sync') + .send({ values: [{ id: 'PD123', value: 'x' }] }); + + expect(response.status).toEqual(400); + }); + + it('forwards values to PagerDuty and returns 200 with body', async () => { + const response = await request(app) + .post('/custom-fields/sync') + .send({ + serviceId: 'PSERV1', + values: [{ id: 'PD123', value: 'team-a' }], + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(syncResponseBody); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/services/PSERV1/custom_fields/values'), + expect.objectContaining({ method: 'PUT' }), + ); + }); + + it('returns 404 when PagerDuty reports the service is missing', async () => { + mocked(fetch).mockReturnValue(mockedResponse(404, {})); + + const response = await request(app) + .post('/custom-fields/sync') + .send({ + serviceId: 'MISSING', + values: [{ id: 'PD123', value: 'x' }], + }); + + expect(response.status).toEqual(404); + }); + }); }); describe('async auto-match job endpoints', () => { @@ -3548,4 +4316,55 @@ describe('createRouter', () => { expect(final.body.completedAt).toBeDefined(); }); }); + + describe('settings: data sync toggle', () => { + const DATA_SYNC_SETTING_ID = 'settings::data-sync'; + + it('POST /settings persists an enabled data-sync setting and GET reads it back', async () => { + const postResponse = await request(app) + .post('/settings') + .send([{ id: DATA_SYNC_SETTING_ID, value: 'enabled' }]); + expect(postResponse.status).toEqual(200); + + const getResponse = await request(app).get( + `/settings/${DATA_SYNC_SETTING_ID}`, + ); + expect(getResponse.status).toEqual(200); + expect(getResponse.body).toMatchObject({ + id: DATA_SYNC_SETTING_ID, + value: 'enabled', + }); + }); + + it('POST /settings allows disabling the data-sync setting', async () => { + const response = await request(app) + .post('/settings') + .send([{ id: DATA_SYNC_SETTING_ID, value: 'disabled' }]); + expect(response.status).toEqual(200); + }); + + it('POST /settings rejects an invalid value for the data-sync setting', async () => { + const response = await request(app) + .post('/settings') + .send([{ id: DATA_SYNC_SETTING_ID, value: 'both' }]); + expect(response.status).toEqual(400); + }); + + it('POST /settings rejects "enabled" for the dependency-strategy setting', async () => { + const response = await request(app) + .post('/settings') + .send([ + { + id: 'settings::service-dependency-sync-strategy', + value: 'enabled', + }, + ]); + expect(response.status).toEqual(400); + }); + + it('GET /settings/:settingId returns 404 when the setting is unset', async () => { + const response = await request(app).get('/settings/settings::not-a-real-setting'); + expect(response.status).toEqual(404); + }); + }); }); diff --git a/plugins/backstage-plugin-backend/src/service/router.ts b/plugins/backstage-plugin-backend/src/service/router.ts index 9738cb0..1ed30c4 100644 --- a/plugins/backstage-plugin-backend/src/service/router.ts +++ b/plugins/backstage-plugin-backend/src/service/router.ts @@ -45,6 +45,7 @@ import { PagerDutyBackendStore, RawDbEntityResultRow, } from '../db/PagerDutyBackendDatabase'; +import { CustomFieldsController } from './customFieldsController'; import * as express from 'express'; import Router from 'express-promise-router'; import type { CatalogApi, GetEntitiesResponse } from '@backstage/catalog-client'; @@ -53,6 +54,12 @@ import * as MappingsController from '../controllers/mappings-controller'; import * as CatalogEntityUtils from '../utils/catalog-entity'; import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter'; +/** + * Setting key for the org-wide custom field data sync toggle. + * Stored in the `pagerduty_settings` table with value 'enabled' or 'disabled'. + */ +const DATA_SYNC_SETTING_ID = 'settings::data-sync'; + export interface RouterOptions { logger: LoggerService; config: RootConfigService; @@ -262,9 +269,15 @@ export async function createRouter( const router = Router(); router.use(express.json()); - const runAutoMatch = createAutoMatchRunner(catalogApi); + const runAutoMatch = createAutoMatchRunner(catalogApi, cache); const autoMatchJobs = new AutoMatchJobRegistry(cache, runAutoMatch); + // Initialize controllers + const customFieldsController = new CustomFieldsController({ + logger, + store, + }); + // DELETE /dependencies/service/:serviceId router.delete( '/dependencies/service/:serviceId', @@ -465,12 +478,10 @@ export async function createRouter( return; } - if (!isValidSetting(setting.value)) { + if (!isValidSettingValue(setting.id, setting.value)) { response .status(400) - .json( - "Bad Request: 'value' is invalid. Valid options are 'backstage', 'pagerduty', 'both' or 'disabled'", - ); + .json(`Bad Request: '${setting.value}' is not a valid value for setting '${setting.id}'`); return; } @@ -515,19 +526,59 @@ export async function createRouter( } }); - function isValidSetting(value: string): boolean { - if ( + function isValidSettingValue(id: string, value: string): boolean { + if (id === DATA_SYNC_SETTING_ID) { + return value === 'enabled' || value === 'disabled'; + } + + return ( value === 'backstage' || value === 'pagerduty' || value === 'both' || value === 'disabled' - ) { - return true; - } - - return false; + ); } + // POST /custom-fields + router.post('/custom-fields', async (request, response) => { + await customFieldsController.createCustomField(request, response); + }); + + // GET /custom-fields + router.get('/custom-fields', async (request, response) => { + await customFieldsController.getCustomFields(request, response); + }); + + // PUT /custom-fields/:id + router.put('/custom-fields/:id', async (request, response) => { + await customFieldsController.updateCustomField(request, response); + }); + + // PATCH /custom-fields/:id/enabled + router.patch('/custom-fields/:id/enabled', async (request, response) => { + await customFieldsController.toggleCustomFieldEnabled(request, response); + }); + + // DELETE /custom-fields/:id + router.delete('/custom-fields/:id', async (request, response) => { + await customFieldsController.deleteCustomField(request, response); + }); + + // POST /custom-fields/sync + router.post('/custom-fields/sync', async (request, response) => { + await customFieldsController.syncCustomFieldValues(request, response); + }); + + // POST /custom-fields/sync-logs + router.post('/custom-fields/sync-logs', async (request, response) => { + await customFieldsController.createSyncLog(request, response); + }); + + // GET /custom-fields/sync-logs + router.get('/custom-fields/sync-logs', async (request, response) => { + await customFieldsController.getSyncLogs(request, response); + }); + // POST /mapping/entity router.post('/mapping/entity', async (request, response) => { try { @@ -557,7 +608,11 @@ export async function createRouter( ) { const backstageVendorId = 'PRO19CT'; // check for existing integration key on service - const service = await getServiceById(entity.serviceId, entity.account); + const service = await getServiceById( + entity.serviceId, + entity.account, + cache, + ); const backstageIntegration = service.integrations?.find( integration => integration.vendor?.id === backstageVendorId, ); @@ -569,6 +624,7 @@ export async function createRouter( serviceId: entity.serviceId, vendorId: backstageVendorId, account: entity.account, + cache, }); entity.integrationKey = integrationKey; @@ -661,6 +717,7 @@ export async function createRouter( const service = await getServiceById( entity.serviceId, entity.account, + cache, ); const backstageIntegration = service.integrations?.find( integration => integration.vendor?.id === backstageVendorId, @@ -671,6 +728,7 @@ export async function createRouter( serviceId: entity.serviceId, vendorId: backstageVendorId, account: entity.account, + cache, }); entity.integrationKey = integrationKey; @@ -773,7 +831,7 @@ export async function createRouter( > = await CatalogEntityUtils.createComponentEntitiesReferenceDict(componentEntities); // Get all services from PagerDuty - const pagerDutyServices = await getAllServices(); + const pagerDutyServices = await getAllServices(cache); // Build the response object const result: PagerDutyEntityMappingsResponse = @@ -794,7 +852,7 @@ export async function createRouter( } }); - router.post('/mapping/entities', MappingsController.getMappingEntities(store, catalogApi)); + router.post('/mapping/entities', MappingsController.getMappingEntities(store, catalogApi, logger)); // GET /mapping/entity router.get( @@ -1010,7 +1068,7 @@ export async function createRouter( return; } - const service = await getServiceById(serviceId, account); + const service = await getServiceById(serviceId, account, cache); const serviceResponse: PagerDutyServiceResponse = { service: service, }; @@ -1083,7 +1141,7 @@ export async function createRouter( } // Case 3: Fetch all services (default) - const services = await getAllServices(); + const services = await getAllServices(cache); const servicesResponse: PagerDutyServicesResponse = { services: services, }; @@ -1126,6 +1184,7 @@ export async function createRouter( serviceId, vendorId, account, + cache, }); response.json(integrationKey); diff --git a/plugins/backstage-plugin-backend/src/services/autoMatchRunner.ts b/plugins/backstage-plugin-backend/src/services/autoMatchRunner.ts index 9773ab4..c9e208b 100644 --- a/plugins/backstage-plugin-backend/src/services/autoMatchRunner.ts +++ b/plugins/backstage-plugin-backend/src/services/autoMatchRunner.ts @@ -1,3 +1,4 @@ +import type { CacheService } from '@backstage/backend-plugin-api'; import type { CatalogApi } from '@backstage/catalog-client'; import { AutoMatchEntityMappingsResponse } from '@pagerduty/backstage-plugin-common'; import { loadBothSources } from './dataLoader'; @@ -17,7 +18,10 @@ const getConfidenceLevel = ( return 'low'; }; -export function createAutoMatchRunner(catalogApi: CatalogApi) { +export function createAutoMatchRunner( + catalogApi: CatalogApi, + cache?: CacheService, +) { return async function runAutoMatch( params: AutoMatchJobParams, ): Promise { @@ -27,6 +31,7 @@ export function createAutoMatchRunner(catalogApi: CatalogApi) { const { pdServices, bsComponents } = await loadBothSources({ catalogApi, teamFilter: team, + cache, }); const filteredPdServices = account diff --git a/plugins/backstage-plugin-backend/src/services/dataLoader.ts b/plugins/backstage-plugin-backend/src/services/dataLoader.ts index 1ece48f..34a8e73 100644 --- a/plugins/backstage-plugin-backend/src/services/dataLoader.ts +++ b/plugins/backstage-plugin-backend/src/services/dataLoader.ts @@ -4,6 +4,7 @@ import { normalizeBackstageComponent, type NormalizedService, } from '../utils/normalization'; +import type { CacheService } from '@backstage/backend-plugin-api'; import type { CatalogApi } from '@backstage/catalog-client'; import type { PagerDutyService } from '@pagerduty/backstage-plugin-common'; import type { Entity } from '@backstage/catalog-model'; @@ -18,6 +19,7 @@ export class ServiceLoadError extends Error { export interface DataLoaderContext { catalogApi: CatalogApi; teamFilter?: string; + cache?: CacheService; } export interface LoadedSources { @@ -25,9 +27,11 @@ export interface LoadedSources { bsComponents: NormalizedService[]; } -export async function loadPagerDutyServices(): Promise { +export async function loadPagerDutyServices( + cache?: CacheService, +): Promise { try { - const services: PagerDutyService[] = await getAllServices(); + const services: PagerDutyService[] = await getAllServices(cache); const normalizedServices: NormalizedService[] = services.map(service => { const teamName = service.teams?.[0]?.summary ?? ''; @@ -101,7 +105,7 @@ export async function loadBothSources( context: DataLoaderContext, ): Promise { const [pdServices, bsComponents] = await Promise.all([ - loadPagerDutyServices(), + loadPagerDutyServices(context.cache), loadBackstageComponents(context), ]); diff --git a/plugins/backstage-plugin-backend/src/services/syncLogsCleanup.test.ts b/plugins/backstage-plugin-backend/src/services/syncLogsCleanup.test.ts new file mode 100644 index 0000000..4102220 --- /dev/null +++ b/plugins/backstage-plugin-backend/src/services/syncLogsCleanup.test.ts @@ -0,0 +1,177 @@ +import { mockServices } from '@backstage/backend-test-utils'; +import { PagerDutyBackendStore } from '../db'; +import { + readSyncLogsCleanupConfig, + runSyncLogsCleanup, +} from './syncLogsCleanup'; + +describe('readSyncLogsCleanupConfig', () => { + it('returns defaults when no config is set', () => { + const config = mockServices.rootConfig({ data: {} }); + + expect(readSyncLogsCleanupConfig(config)).toEqual({ + enabled: true, + retentionDays: 30, + maxRows: 500_000, + frequencyMinutes: 15, + batchSize: 10_000, + maxBatchesPerRun: 500, + }); + }); + + it('reads configured values', () => { + const config = mockServices.rootConfig({ + data: { + pagerDuty: { + customFieldsSyncLogs: { + retentionDays: 7, + maxRows: 50_000, + cleanup: { + enabled: false, + frequencyMinutes: 60, + batchSize: 1_000, + maxBatchesPerRun: 10, + }, + }, + }, + }, + }); + + expect(readSyncLogsCleanupConfig(config)).toEqual({ + enabled: false, + retentionDays: 7, + maxRows: 50_000, + frequencyMinutes: 60, + batchSize: 1_000, + maxBatchesPerRun: 10, + }); + }); + + it('clamps values to sane minimums', () => { + const config = mockServices.rootConfig({ + data: { + pagerDuty: { + customFieldsSyncLogs: { + retentionDays: 0, + maxRows: 10, + cleanup: { + frequencyMinutes: 0, + batchSize: 1, + maxBatchesPerRun: 0, + }, + }, + }, + }, + }); + + expect(readSyncLogsCleanupConfig(config)).toEqual({ + enabled: true, + retentionDays: 1, + maxRows: 1000, + frequencyMinutes: 1, + batchSize: 100, + maxBatchesPerRun: 1, + }); + }); +}); + +describe('runSyncLogsCleanup', () => { + const cleanupConfig = { + enabled: true, + retentionDays: 30, + maxRows: 500_000, + frequencyMinutes: 15, + batchSize: 10_000, + maxBatchesPerRun: 500, + }; + + function createStore( + cleanupSyncLogs: jest.Mock, + ): PagerDutyBackendStore { + return { cleanupSyncLogs } as unknown as PagerDutyBackendStore; + } + + it('runs the cleanup with the configured thresholds and logs the result', async () => { + const cleanupSyncLogs = jest.fn().mockResolvedValue({ + deletedByAge: 12, + deletedByCap: 3, + batchesUsed: 2, + }); + const logger = mockServices.logger.mock(); + + await runSyncLogsCleanup({ + store: createStore(cleanupSyncLogs), + logger, + cleanupConfig, + }); + + expect(cleanupSyncLogs).toHaveBeenCalledWith({ + olderThanDays: 30, + maxRows: 500_000, + batchSize: 10_000, + maxBatchesPerRun: 500, + }); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('deleted 12 expired and 3 over-cap row(s)'), + ); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('logs correctly when age phase empties the table (no cap deletions)', async () => { + const cleanupSyncLogs = jest.fn().mockResolvedValue({ + deletedByAge: 50, + deletedByCap: 0, + batchesUsed: 1, + }); + const logger = mockServices.logger.mock(); + + await runSyncLogsCleanup({ + store: createStore(cleanupSyncLogs), + logger, + cleanupConfig, + }); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('deleted 50 expired and 0 over-cap row(s)'), + ); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('warns when the batch budget was exhausted', async () => { + const cleanupSyncLogs = jest.fn().mockResolvedValue({ + deletedByAge: 5_000_000, + deletedByCap: 0, + batchesUsed: 500, + }); + const logger = mockServices.logger.mock(); + + await runSyncLogsCleanup({ + store: createStore(cleanupSyncLogs), + logger, + cleanupConfig, + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('exhausted its batch budget'), + ); + }); + + it('logs store errors instead of throwing', async () => { + const cleanupSyncLogs = jest + .fn() + .mockRejectedValue(new Error('database is locked')); + const logger = mockServices.logger.mock(); + + await expect( + runSyncLogsCleanup({ + store: createStore(cleanupSyncLogs), + logger, + cleanupConfig, + }), + ).resolves.toBeUndefined(); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('database is locked'), + ); + }); +}); diff --git a/plugins/backstage-plugin-backend/src/services/syncLogsCleanup.ts b/plugins/backstage-plugin-backend/src/services/syncLogsCleanup.ts new file mode 100644 index 0000000..8023e48 --- /dev/null +++ b/plugins/backstage-plugin-backend/src/services/syncLogsCleanup.ts @@ -0,0 +1,107 @@ +import { + LoggerService, + RootConfigService, +} from '@backstage/backend-plugin-api'; +import { PagerDutyBackendStore } from '../db'; + +export interface SyncLogsCleanupConfig { + enabled: boolean; + retentionDays: number; + maxRows: number; + frequencyMinutes: number; + batchSize: number; + maxBatchesPerRun: number; +} + +const DEFAULTS: SyncLogsCleanupConfig = { + enabled: true, + retentionDays: 30, + maxRows: 500_000, + frequencyMinutes: 15, + batchSize: 10_000, + maxBatchesPerRun: 500, +}; + +const CONFIG_ROOT = 'pagerDuty.customFieldsSyncLogs'; + +export const SYNC_LOGS_CLEANUP_TASK_ID = + 'pagerduty-custom-field-sync-logs-cleanup'; +export const SYNC_LOGS_CLEANUP_TIMEOUT_MINUTES = 10; +export const SYNC_LOGS_CLEANUP_INITIAL_DELAY_MINUTES = 1; + +export function readSyncLogsCleanupConfig( + config: RootConfigService, +): SyncLogsCleanupConfig { + const clamp = (value: number | undefined, fallback: number, min: number) => + Math.max(Math.floor(value ?? fallback), min); + + return { + enabled: + config.getOptionalBoolean(`${CONFIG_ROOT}.cleanup.enabled`) ?? + DEFAULTS.enabled, + retentionDays: clamp( + config.getOptionalNumber(`${CONFIG_ROOT}.retentionDays`), + DEFAULTS.retentionDays, + 1, + ), + maxRows: clamp( + config.getOptionalNumber(`${CONFIG_ROOT}.maxRows`), + DEFAULTS.maxRows, + 1000, + ), + frequencyMinutes: clamp( + config.getOptionalNumber(`${CONFIG_ROOT}.cleanup.frequencyMinutes`), + DEFAULTS.frequencyMinutes, + 1, + ), + batchSize: clamp( + config.getOptionalNumber(`${CONFIG_ROOT}.cleanup.batchSize`), + DEFAULTS.batchSize, + 100, + ), + maxBatchesPerRun: clamp( + config.getOptionalNumber(`${CONFIG_ROOT}.cleanup.maxBatchesPerRun`), + DEFAULTS.maxBatchesPerRun, + 1, + ), + }; +} + +export async function runSyncLogsCleanup(options: { + store: PagerDutyBackendStore; + logger: LoggerService; + cleanupConfig: SyncLogsCleanupConfig; +}): Promise { + const { store, logger, cleanupConfig } = options; + const startedAt = Date.now(); + + try { + const result = await store.cleanupSyncLogs({ + olderThanDays: cleanupConfig.retentionDays, + maxRows: cleanupConfig.maxRows, + batchSize: cleanupConfig.batchSize, + maxBatchesPerRun: cleanupConfig.maxBatchesPerRun, + }); + + const durationMs = Date.now() - startedAt; + logger.info( + `Sync log cleanup: deleted ${result.deletedByAge} expired and ${result.deletedByCap} over-cap row(s) in ${result.batchesUsed} batch(es) (${durationMs}ms)`, + ); + + if (result.batchesUsed >= cleanupConfig.maxBatchesPerRun) { + logger.warn( + `Sync log cleanup exhausted its batch budget (${cleanupConfig.maxBatchesPerRun} batches of ${cleanupConfig.batchSize}); ` + + `a backlog remains. To catch up faster, increase pagerDuty.customFieldsSyncLogs.cleanup.maxBatchesPerRun ` + + `or decrease pagerDuty.customFieldsSyncLogs.cleanup.frequencyMinutes.`, + ); + } + } catch (error) { + // A failed cleanup must never crash the backend; the scheduler retries on + // the next cycle. + logger.error( + `Sync log cleanup failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} diff --git a/plugins/backstage-plugin-common/src/types.ts b/plugins/backstage-plugin-common/src/types.ts index 7027321..fbaeb46 100644 --- a/plugins/backstage-plugin-common/src/types.ts +++ b/plugins/backstage-plugin-common/src/types.ts @@ -436,3 +436,147 @@ export type AutoMatchStatusResponse = { result?: AutoMatchEntityMappingsResponse; error?: string; }; + +/** @public */ +export type PagerDutyCustomField = { + id: string; + data_type: string; + description?: string; + display_name: string; + enabled: boolean; + field_type: string; + name: string; + backstageEntityMappingPath?: string; +}; + +/** @public */ +export type PagerDutyCustomFieldCreateRequest = { + field: { + data_type: 'string'; + description?: string; + display_name: string; + enabled: boolean; + field_type: 'single_value'; + name: string; + }; +}; + +/** @public */ +export type PagerDutyCustomFieldResponse = { + field: PagerDutyCustomField; +}; + +/** @public */ +export type PagerDutyCustomFieldsResponse = { + fields: PagerDutyCustomField[]; +}; + +/** @public */ +export type BackstageCustomField = { + id: number; + pagerdutyCustomFieldId: string; + pagerdutyCustomFieldDisplayName: string; + pagerdutyCustomFieldEnabled: boolean; + backstageEntityMappingPath: string; + pagerdutySubdomain: string; + description?: string; + createdAt: Date; + updatedAt: Date; +}; + +/** @public */ +export type BackstageCustomFieldCreateRequest = { + name: string; + entityPath: string; + description?: string; +}; + +/** @public */ +export type BackstageCustomFieldUpdateRequest = { + name: string; + entityPath: string; + description?: string; +}; + +/** @public */ +export type BackstageCustomFieldToggleEnabledRequest = { + enabled: boolean; +}; + +/** @public */ +export type PagerDutyCustomFieldUpdateRequest = { + field: { + display_name: string; + description?: string; + enabled?: boolean; + }; +}; + +/** @public */ +export type BackstageCustomFieldsResponse = { + customFields: BackstageCustomField[]; +}; + +/** @public */ +export type PagerDutyServiceCustomFieldValue = { + id: string; + value: string | null; +}; + +/** @public */ +export type PagerDutyServiceCustomFieldValuesRequest = { + custom_fields: PagerDutyServiceCustomFieldValue[]; +}; + +// The PagerDuty API echoes back the same shape it receives, so these two types +// are structurally identical but kept separate to distinguish call-site intent. +/** @public */ +export type PagerDutyServiceCustomFieldValuesResponse = { + custom_fields: PagerDutyServiceCustomFieldValue[]; +}; + +/** @public */ +export type CustomFieldSyncLog = { + id: number; + timestamp: Date; + errorCode: string; + customFieldId: string; + customFieldName: string; + entityPath: string; + serviceId: string; + serviceName: string; + errorMessage: string; + subdomain: string; +}; + +/** @public */ +export type CustomFieldSyncLogCreateRequest = { + errorCode: string; + customFieldId: string; + customFieldName: string; + entityPath: string; + serviceId: string; + serviceName: string; + errorMessage: string; +}; + +/** @public */ +export type CustomFieldSyncLogSeverity = 'info' | 'warning' | 'error'; + +/** @public */ +export type CustomFieldSyncLogFilters = { + search?: string; + severity?: CustomFieldSyncLogSeverity; + customFieldName?: string; + entityPath?: string; + serviceName?: string; +}; + +/** @public */ +export type CustomFieldSyncLogsResponse = { + logs: CustomFieldSyncLog[]; + total: number; + customFieldNames: string[]; + entityPaths: string[]; + serviceNames: string[]; +}; diff --git a/plugins/backstage-plugin-entity-processor/src/apis/client.ts b/plugins/backstage-plugin-entity-processor/src/apis/client.ts index 36b7eb5..c4c6107 100644 --- a/plugins/backstage-plugin-entity-processor/src/apis/client.ts +++ b/plugins/backstage-plugin-entity-processor/src/apis/client.ts @@ -3,11 +3,15 @@ import type { RequestInit, Response } from 'node-fetch'; import type { EntityMapping } from '../types'; import { AuthService, DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; import { + BackstageCustomField, + BackstageCustomFieldsResponse, + CustomFieldSyncLogCreateRequest, PagerDutyEntityMapping, PagerDutyEntityMappingResponse, PagerDutyServiceResponse, PagerDutyServiceDependency, PagerDutyServiceDependencyResponse, + PagerDutyServiceCustomFieldValue, PagerDutySetting, PagerDutyEntityMappingsResponse, } from '@pagerduty/backstage-plugin-common'; @@ -625,6 +629,187 @@ export class PagerDutyClient { } } + async isDataSyncEnabled(): Promise { + const DATA_SYNC_SETTING_ID = 'settings::data-sync'; + + let response: Response; + + if (this.baseUrl === '') { + this.baseUrl = await this.discovery.getBaseUrl('pagerduty'); + } + + const options: RequestInit = { + method: 'GET', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Accept: 'application/json, text/plain, */*', + Authorization: await this.generatePluginToPluginToken(), + }, + }; + + const url = `${await this.discovery.getBaseUrl( + 'pagerduty', + )}/settings/${DATA_SYNC_SETTING_ID}`; + + try { + response = await fetchWithRetries(url, options); + + if (response.status >= 500) { + throw new Error( + `Failed to get data sync setting. API returned a server error. Retrying with the same arguments will not work.`, + ); + } + + switch (response.status) { + case 400: + throw new Error(await response.text()); + case 404: + return false; // if setting does not exist, default to disabled (opt-in) + default: { + // 200 — the data-sync setting stores its own 'enabled'/'disabled' + // value, distinct from the dependency-strategy PagerDutySetting union. + const setting: { id: string; value: string } = await response.json(); + return setting.value === 'enabled'; + } + } + } catch (error) { + this.logger.error(`Error getting value for setting: ${error}`); + throw new Error(`Error getting value for setting: ${error}`); + } + } + + async getEnabledCustomFields(account?: string): Promise { + let response: Response; + + if (this.baseUrl === '') { + this.baseUrl = await this.discovery.getBaseUrl('pagerduty'); + } + + const options: RequestInit = { + method: 'GET', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Accept: 'application/json, text/plain, */*', + Authorization: await this.generatePluginToPluginToken(), + }, + }; + + const params = new URLSearchParams({ enabled: 'true' }); + if (account) params.set('account', account); + + const url = `${await this.discovery.getBaseUrl( + 'pagerduty', + )}/custom-fields?${params.toString()}`; + + try { + response = await fetchWithRetries(url, options); + + if (response.status >= 500) { + throw new Error( + `Failed to get enabled custom fields. API returned a server error.`, + ); + } + + switch (response.status) { + case 400: + throw new Error(await response.text()); + case 404: + throw new Error(`Custom fields endpoint not found. Ensure the PagerDuty backend plugin is running.`); + default: { + const body: BackstageCustomFieldsResponse = await response.json(); + return body.customFields ?? []; + } + } + } catch (error) { + this.logger.error(`Failed to retrieve enabled custom fields: ${error}`); + return []; + } + } + + async pushCustomFieldValues( + serviceId: string, + values: PagerDutyServiceCustomFieldValue[], + account?: string, + ): Promise { + if (values.length === 0) return; + + if (this.baseUrl === '') { + this.baseUrl = await this.discovery.getBaseUrl('pagerduty'); + } + + const params = new URLSearchParams(); + if (account) params.set('account', account); + const query = params.toString() ? `?${params}` : ''; + + const options: RequestInit = { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Accept: 'application/json, text/plain, */*', + Authorization: await this.generatePluginToPluginToken(), + }, + body: JSON.stringify({ serviceId, values }), + }; + + const url = `${await this.discovery.getBaseUrl( + 'pagerduty', + )}/custom-fields/sync${query}`; + + const response = await fetchWithRetries(url, options); + + if (response.status >= 500) { + const body = await response.text().catch(() => ''); + throw new Error( + `Failed to push custom field values for service ${serviceId}. API returned ${response.status}: ${body}`, + ); + } + + if (!response.ok && response.status !== 204) { + const body = await response.text(); + throw new Error(`status=${response.status}: ${body}`); + } + } + + async createSyncLog( + log: CustomFieldSyncLogCreateRequest, + account?: string, + ): Promise { + if (this.baseUrl === '') { + this.baseUrl = await this.discovery.getBaseUrl('pagerduty'); + } + + const params = new URLSearchParams(); + if (account) params.set('account', account); + const query = params.toString() ? `?${params}` : ''; + + const options: RequestInit = { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Accept: 'application/json, text/plain, */*', + Authorization: await this.generatePluginToPluginToken(), + }, + body: JSON.stringify(log), + }; + + const url = `${await this.discovery.getBaseUrl( + 'pagerduty', + )}/custom-fields/sync-logs${query}`; + + try { + const response = await fetchWithRetries(url, options); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + this.logger.warn( + `Failed to write custom field sync log (status ${response.status}): ${text}`, + ); + } + } catch (error) { + this.logger.warn(`Failed to write custom field sync log: ${error}`); + } + } + private async generatePluginToPluginToken(): Promise { this.logger.debug('Generating plugin to plugin token for pagerduty') diff --git a/plugins/backstage-plugin-entity-processor/src/module.ts b/plugins/backstage-plugin-entity-processor/src/module.ts index 3ff9e51..cc865d0 100644 --- a/plugins/backstage-plugin-entity-processor/src/module.ts +++ b/plugins/backstage-plugin-entity-processor/src/module.ts @@ -2,8 +2,8 @@ import { coreServices, createBackendModule, } from '@backstage/backend-plugin-api'; -import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; -import { PagerDutyEntityProcessor } from './processor'; +import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; +import { PagerDutyEntityProcessor, PagerDutyCustomFieldsProcessor } from './processor'; /** @public */ export const pagerDutyEntityProcessor = createBackendModule({ @@ -21,6 +21,9 @@ export const pagerDutyEntityProcessor = createBackendModule({ catalog.addProcessor( new PagerDutyEntityProcessor({ auth, logger, discovery }), ); + catalog.addProcessor( + new PagerDutyCustomFieldsProcessor({ auth, logger, discovery }), + ); }, }); }, diff --git a/plugins/backstage-plugin-entity-processor/src/processor/PagerDutyCustomFieldsProcessor.test.ts b/plugins/backstage-plugin-entity-processor/src/processor/PagerDutyCustomFieldsProcessor.test.ts new file mode 100644 index 0000000..61c9ec8 --- /dev/null +++ b/plugins/backstage-plugin-entity-processor/src/processor/PagerDutyCustomFieldsProcessor.test.ts @@ -0,0 +1,172 @@ +import { + AuthService, + DiscoveryService, + LoggerService, +} from '@backstage/backend-plugin-api'; +import { Entity } from '@backstage/catalog-model'; +import { LocationSpec } from '@backstage/plugin-catalog-common'; +import { PagerDutyClient } from '../apis/client'; +import { PagerDutyCustomFieldsProcessor } from './PagerDutyCustomFieldsProcessor'; + +jest.mock('../apis/client'); + +const MockedPagerDutyClient = PagerDutyClient as jest.MockedClass< + typeof PagerDutyClient +>; + +const location: LocationSpec = { + type: 'url', + target: 'https://example.com/catalog-info.yaml', +}; + +const emit = jest.fn(); + +const componentEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-service', + annotations: { + 'pagerduty.com/service-id': 'PSERVICE', + }, + }, + spec: { type: 'service' }, +}; + +const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn(), +} as unknown as LoggerService; + +function buildProcessor() { + return new PagerDutyCustomFieldsProcessor({ + logger, + discovery: {} as DiscoveryService, + auth: {} as AuthService, + }); +} + +describe('PagerDutyCustomFieldsProcessor', () => { + let mockClient: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not push when the data sync is disabled', async () => { + const processor = buildProcessor(); + mockClient = MockedPagerDutyClient.mock + .instances[0] as jest.Mocked; + mockClient.isDataSyncEnabled.mockResolvedValue(false); + mockClient.getEnabledCustomFields.mockResolvedValue([]); + mockClient.pushCustomFieldValues.mockResolvedValue(undefined as never); + + await processor.postProcessEntity(componentEntity, location, emit); + + expect(mockClient.isDataSyncEnabled).toHaveBeenCalledTimes(1); + expect(mockClient.getEnabledCustomFields).not.toHaveBeenCalled(); + expect(mockClient.pushCustomFieldValues).not.toHaveBeenCalled(); + }); + + it('pushes values when the data sync is enabled', async () => { + const processor = buildProcessor(); + mockClient = MockedPagerDutyClient.mock + .instances[0] as jest.Mocked; + mockClient.isDataSyncEnabled.mockResolvedValue(true); + mockClient.getEnabledCustomFields.mockResolvedValue([ + { + pagerdutyCustomFieldId: 'CF1', + pagerdutyCustomFieldDisplayName: 'Tier', + backstageEntityMappingPath: 'metadata.name', + } as never, + ]); + mockClient.pushCustomFieldValues.mockResolvedValue(undefined as never); + mockClient.createSyncLog.mockResolvedValue(undefined as never); + + await processor.postProcessEntity(componentEntity, location, emit); + + expect(mockClient.isDataSyncEnabled).toHaveBeenCalledTimes(1); + expect(mockClient.getEnabledCustomFields).toHaveBeenCalledTimes(1); + expect(mockClient.pushCustomFieldValues).toHaveBeenCalledWith( + 'PSERVICE', + [{ id: 'CF1', value: 'my-service' }], + undefined, + ); + }); + + it('writes a SYNC_SUCCESS log per pushed field on a successful push', async () => { + const processor = buildProcessor(); + mockClient = MockedPagerDutyClient.mock + .instances[0] as jest.Mocked; + mockClient.isDataSyncEnabled.mockResolvedValue(true); + mockClient.getEnabledCustomFields.mockResolvedValue([ + { + pagerdutyCustomFieldId: 'CF1', + pagerdutyCustomFieldDisplayName: 'Tier', + backstageEntityMappingPath: 'metadata.name', + } as never, + { + pagerdutyCustomFieldId: 'CF2', + pagerdutyCustomFieldDisplayName: 'Description', + backstageEntityMappingPath: 'spec.type', + } as never, + ]); + mockClient.pushCustomFieldValues.mockResolvedValue(undefined as never); + mockClient.createSyncLog.mockResolvedValue(undefined as never); + + await processor.postProcessEntity(componentEntity, location, emit); + + expect(mockClient.createSyncLog).toHaveBeenCalledTimes(2); + expect(mockClient.createSyncLog).toHaveBeenCalledWith( + expect.objectContaining({ + errorCode: 'SYNC_SUCCESS', + customFieldId: 'CF1', + serviceId: 'PSERVICE', + }), + undefined, + ); + expect(mockClient.createSyncLog).toHaveBeenCalledWith( + expect.objectContaining({ + errorCode: 'SYNC_SUCCESS', + customFieldId: 'CF2', + }), + undefined, + ); + }); + + it('writes PD_API_ERROR (not SYNC_SUCCESS) when the push fails', async () => { + const processor = buildProcessor(); + mockClient = MockedPagerDutyClient.mock + .instances[0] as jest.Mocked; + mockClient.isDataSyncEnabled.mockResolvedValue(true); + mockClient.getEnabledCustomFields.mockResolvedValue([ + { + pagerdutyCustomFieldId: 'CF1', + pagerdutyCustomFieldDisplayName: 'Tier', + backstageEntityMappingPath: 'metadata.name', + } as never, + ]); + mockClient.pushCustomFieldValues.mockRejectedValue(new Error('404')); + mockClient.createSyncLog.mockResolvedValue(undefined as never); + + await processor.postProcessEntity(componentEntity, location, emit); + + const codes = mockClient.createSyncLog.mock.calls.map(c => c[0].errorCode); + expect(codes).toContain('PD_API_ERROR'); + expect(codes).not.toContain('SYNC_SUCCESS'); + }); + + it('skips non-Component entities before checking the data sync', async () => { + const processor = buildProcessor(); + mockClient = MockedPagerDutyClient.mock + .instances[0] as jest.Mocked; + + const apiEntity: Entity = { ...componentEntity, kind: 'API' }; + await processor.postProcessEntity(apiEntity, location, emit); + + expect(mockClient.isDataSyncEnabled).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/backstage-plugin-entity-processor/src/processor/PagerDutyCustomFieldsProcessor.ts b/plugins/backstage-plugin-entity-processor/src/processor/PagerDutyCustomFieldsProcessor.ts new file mode 100644 index 0000000..e0b289d --- /dev/null +++ b/plugins/backstage-plugin-entity-processor/src/processor/PagerDutyCustomFieldsProcessor.ts @@ -0,0 +1,141 @@ +import { AuthService, DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; +import { Entity } from '@backstage/catalog-model'; +import { CatalogProcessor, CatalogProcessorEmit } from '@backstage/plugin-catalog-node'; +import { LocationSpec } from '@backstage/plugin-catalog-common'; +import { PagerDutyClient } from '../apis/client'; +import { extractValueAtPath } from './extractEntityValue'; +import { + BackstageCustomField, + PagerDutyServiceCustomFieldValue, +} from '@pagerduty/backstage-plugin-common'; + +export interface PagerDutyCustomFieldsProcessorOptions { + logger: LoggerService; + discovery: DiscoveryService; + auth: AuthService; +} + +export class PagerDutyCustomFieldsProcessor implements CatalogProcessor { + private readonly logger: LoggerService; + private readonly client: PagerDutyClient; + + constructor({ auth, logger, discovery }: PagerDutyCustomFieldsProcessorOptions) { + this.logger = logger; + this.client = new PagerDutyClient({ auth, discovery, logger }); + } + + getProcessorName(): string { + return 'PagerDutyCustomFieldsProcessor'; + } + + async postProcessEntity( + entity: Entity, + _location: LocationSpec, + _emit: CatalogProcessorEmit, + ): Promise { + if (entity.kind !== 'Component') return entity; + + const serviceId = entity.metadata.annotations?.['pagerduty.com/service-id']; + if (!serviceId) return entity; + + const account = entity.metadata.annotations?.['pagerduty.com/account']; + const serviceName = entity.metadata.name; + + try { + // Customers must explicitly opt in to the data sync before any data is + // pushed to PagerDuty. The toggle is stored org-wide in the settings table. + const dataSyncEnabled = await this.client.isDataSyncEnabled(); + if (!dataSyncEnabled) return entity; + + const fields = await this.client.getEnabledCustomFields(account); + if (fields.length === 0) return entity; + + const values: PagerDutyServiceCustomFieldValue[] = []; + const pushedFields: BackstageCustomField[] = []; + for (const field of fields) { + const result = extractValueAtPath(entity, field.backstageEntityMappingPath); + if (!result.ok) { + this.logger.warn( + `Skipping custom field "${field.pagerdutyCustomFieldDisplayName}" (path="${field.backstageEntityMappingPath}") for entity ${entity.metadata.name} (service ${serviceId}): ${result.reason}`, + ); + void this.client + .createSyncLog( + { + errorCode: 'INVALID_PATH', + customFieldId: field.pagerdutyCustomFieldId, + customFieldName: field.pagerdutyCustomFieldDisplayName, + entityPath: field.backstageEntityMappingPath, + serviceId, + serviceName, + errorMessage: result.reason, + }, + account, + ) + .catch(error => + this.logger.warn( + `Sync log write failed (best-effort): ${error}`, + ), + ); + continue; + } + values.push({ id: field.pagerdutyCustomFieldId, value: result.value }); + pushedFields.push(field); + } + + if (values.length > 0) { + try { + await this.client.pushCustomFieldValues(serviceId, values, account); + // Record a per-field success so operators can see positive sync + // signal, not just failures. Best-effort, same as the failure path. + for (const field of pushedFields) { + void this.client + .createSyncLog( + { + errorCode: 'SYNC_SUCCESS', + customFieldId: field.pagerdutyCustomFieldId, + customFieldName: field.pagerdutyCustomFieldDisplayName, + entityPath: field.backstageEntityMappingPath, + serviceId, + serviceName, + errorMessage: 'Synced successfully', + }, + account, + ) + .catch(err => + this.logger.warn(`Sync log write failed (best-effort): ${err}`), + ); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error( + `Failed to push custom field values for entity ${entity.metadata.name} (service ${serviceId}, account=${account ?? 'default'}): ${errorMessage}`, + ); + for (const field of pushedFields) { + void this.client + .createSyncLog( + { + errorCode: 'PD_API_ERROR', + customFieldId: field.pagerdutyCustomFieldId, + customFieldName: field.pagerdutyCustomFieldDisplayName, + entityPath: field.backstageEntityMappingPath, + serviceId, + serviceName, + errorMessage, + }, + account, + ) + .catch(err => + this.logger.warn(`Sync log write failed (best-effort): ${err}`), + ); + } + } + } + } catch (error) { + this.logger.error( + `Failed to process custom field values for entity ${entity.metadata.name} (service ${serviceId}): ${error}`, + ); + } + + return entity; + } +} diff --git a/plugins/backstage-plugin-entity-processor/src/processor/PagerDutyEntityProcessor.ts b/plugins/backstage-plugin-entity-processor/src/processor/PagerDutyEntityProcessor.ts index 9c1cdd6..a8f5320 100644 --- a/plugins/backstage-plugin-entity-processor/src/processor/PagerDutyEntityProcessor.ts +++ b/plugins/backstage-plugin-entity-processor/src/processor/PagerDutyEntityProcessor.ts @@ -199,6 +199,9 @@ export class PagerDutyEntityProcessor implements CatalogProcessor { entity.metadata.annotations?.['pagerduty.com/service-id']; if (serviceId) { + const account = + entity.metadata.annotations?.['pagerduty.com/account']; + const strategySetting = await client.getServiceDependencyStrategySetting(); @@ -218,8 +221,6 @@ export class PagerDutyEntityProcessor implements CatalogProcessor { await buildExistingDependencies(dependencyAnnotations); // Get dependencies from PagerDuty for the service - const account = - entity.metadata.annotations?.['pagerduty.com/account']; const dependencies = await client.getServiceDependencies( serviceId, account, diff --git a/plugins/backstage-plugin-entity-processor/src/processor/extractEntityValue.test.ts b/plugins/backstage-plugin-entity-processor/src/processor/extractEntityValue.test.ts new file mode 100644 index 0000000..29694f7 --- /dev/null +++ b/plugins/backstage-plugin-entity-processor/src/processor/extractEntityValue.test.ts @@ -0,0 +1,97 @@ +import { Entity } from '@backstage/catalog-model'; +import { extractValueAtPath } from './extractEntityValue'; + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-service', + namespace: 'default', + description: 'My service', + annotations: { + 'pagerduty.com/service-id': 'PXXX', + 'backstage.io/source-location': 'url:https://example.com', + }, + tags: ['team-a', 'go'], + }, + spec: { + type: 'service', + owner: 'team-a', + lifecycle: 'production', + }, +}; + +describe('extractValueAtPath', () => { + it('returns dot-path values as strings', () => { + expect(extractValueAtPath(entity, 'metadata.name')).toEqual({ + ok: true, + value: 'my-service', + }); + expect(extractValueAtPath(entity, 'spec.owner')).toEqual({ + ok: true, + value: 'team-a', + }); + }); + + it('supports bracket notation for keys with dots', () => { + expect( + extractValueAtPath( + entity, + 'metadata.annotations["pagerduty.com/service-id"]', + ), + ).toEqual({ ok: true, value: 'PXXX' }); + }); + + it('supports single-quoted bracket notation', () => { + expect( + extractValueAtPath( + entity, + "metadata.annotations['backstage.io/source-location']", + ), + ).toEqual({ ok: true, value: 'url:https://example.com' }); + }); + + it('supports unquoted bracket notation', () => { + expect(extractValueAtPath(entity, 'metadata[name]')).toEqual({ + ok: true, + value: 'my-service', + }); + }); + + it('coerces non-string scalars to string', () => { + const e = { + ...entity, + metadata: { ...entity.metadata, count: 42 } as unknown as Entity['metadata'], + }; + expect(extractValueAtPath(e, 'metadata.count')).toEqual({ + ok: true, + value: '42', + }); + }); + + it('returns not-ok when path resolves to undefined', () => { + const result = extractValueAtPath(entity, 'metadata.missing'); + expect(result.ok).toBe(false); + }); + + it('returns not-ok when traversing into a missing intermediate', () => { + const result = extractValueAtPath(entity, 'metadata.nope.deeper'); + expect(result.ok).toBe(false); + }); + + it('returns not-ok when path resolves to an object', () => { + const result = extractValueAtPath(entity, 'metadata.annotations'); + expect(result.ok).toBe(false); + }); + + it('returns not-ok when path resolves to an array', () => { + const result = extractValueAtPath(entity, 'metadata.tags'); + expect(result.ok).toBe(false); + }); + + it('returns not-ok for empty/invalid paths', () => { + expect(extractValueAtPath(entity, '').ok).toBe(false); + expect(extractValueAtPath(entity, '[]').ok).toBe(false); + expect(extractValueAtPath(entity, 'metadata[unterminated').ok).toBe(false); + }); +}); diff --git a/plugins/backstage-plugin-entity-processor/src/processor/extractEntityValue.ts b/plugins/backstage-plugin-entity-processor/src/processor/extractEntityValue.ts new file mode 100644 index 0000000..8ed6053 --- /dev/null +++ b/plugins/backstage-plugin-entity-processor/src/processor/extractEntityValue.ts @@ -0,0 +1,80 @@ +import { Entity } from '@backstage/catalog-model'; + +export type ExtractResult = + | { ok: true; value: string } + | { ok: false; reason: string }; + +export function extractValueAtPath(entity: Entity, path: string): ExtractResult { + if (!path || typeof path !== 'string') { + return { ok: false, reason: 'empty path' }; + } + + const segments = parsePath(path); + if (segments === null) { + return { ok: false, reason: `invalid path syntax: ${path}` }; + } + + let current: unknown = entity; + for (const segment of segments) { + if (current === null || current === undefined) { + return { ok: false, reason: `path resolves to ${current} at "${segment}"` }; + } + if (typeof current !== 'object') { + return { ok: false, reason: `cannot index into non-object at "${segment}"` }; + } + current = (current as Record)[segment]; + } + + if (current === null || current === undefined) { + return { ok: false, reason: 'path resolves to undefined' }; + } + if (typeof current === 'object') { + return { ok: false, reason: 'path resolves to an object/array, expected scalar' }; + } + return { ok: true, value: String(current) }; +} + +function parsePath(path: string): string[] | null { + const segments: string[] = []; + let i = 0; + while (i < path.length) { + if (path[i] === '[') { + const end = findMatchingBracket(path, i); + if (end === -1) return null; + const inner = path.slice(i + 1, end).trim(); + const unquoted = unquote(inner); + if (unquoted === null) return null; + segments.push(unquoted); + i = end + 1; + if (i < path.length && path[i] === '.') i++; + continue; + } + + let end = i; + while (end < path.length && path[end] !== '.' && path[end] !== '[') end++; + const segment = path.slice(i, end); + if (!segment) return null; + segments.push(segment); + i = end; + if (path[i] === '.') i++; + } + return segments.length > 0 ? segments : null; +} + +function findMatchingBracket(path: string, openIdx: number): number { + for (let i = openIdx + 1; i < path.length; i++) { + if (path[i] === ']') return i; + } + return -1; +} + +function unquote(s: string): string | null { + if (s.length === 0) return null; + if ( + (s.startsWith('"') && s.endsWith('"')) || + (s.startsWith("'") && s.endsWith("'")) + ) { + return s.slice(1, -1); + } + return s; +} diff --git a/plugins/backstage-plugin-entity-processor/src/processor/index.ts b/plugins/backstage-plugin-entity-processor/src/processor/index.ts index ae4732e..3d2da8a 100644 --- a/plugins/backstage-plugin-entity-processor/src/processor/index.ts +++ b/plugins/backstage-plugin-entity-processor/src/processor/index.ts @@ -1 +1,2 @@ export { PagerDutyEntityProcessor } from './PagerDutyEntityProcessor'; +export { PagerDutyCustomFieldsProcessor } from './PagerDutyCustomFieldsProcessor'; diff --git a/plugins/backstage-plugin/dev/index.tsx b/plugins/backstage-plugin/dev/index.tsx index 219425d..b2d1a38 100644 --- a/plugins/backstage-plugin/dev/index.tsx +++ b/plugins/backstage-plugin/dev/index.tsx @@ -15,6 +15,7 @@ */ import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { AnyApiFactory, ExtensionBlueprintDefineParams } from '@backstage/frontend-plugin-api'; import { pagerDutyApiRef } from '../src/api'; import { mockPagerDutyApi } from './mockPagerDutyApi'; /* eslint-disable-next-line @backstage/no-ui-css-imports-in-non-frontend */ @@ -29,7 +30,7 @@ import { mockCatalogApi } from './mockCatalogApi'; const catalogPluginOverrides = catalogPlugin.withOverrides({ extensions: [ catalogPlugin.getExtension('api:catalog').override({ - params: defineParams => + params: (defineParams: ExtensionBlueprintDefineParams) => defineParams({ api: catalogApiRef, deps: {}, @@ -42,7 +43,7 @@ const catalogPluginOverrides = catalogPlugin.withOverrides({ const pagerDutyPluginOverrides = pagerDutyPlugin.withOverrides({ extensions: [ pagerDutyPlugin.getExtension('api:pagerduty').override({ - params: defineParams => + params: (defineParams: ExtensionBlueprintDefineParams) => defineParams({ api: pagerDutyApiRef, deps: {}, diff --git a/plugins/backstage-plugin/dev/mockPagerDutyApi.ts b/plugins/backstage-plugin/dev/mockPagerDutyApi.ts index 310d9d6..68f79a7 100644 --- a/plugins/backstage-plugin/dev/mockPagerDutyApi.ts +++ b/plugins/backstage-plugin/dev/mockPagerDutyApi.ts @@ -24,6 +24,8 @@ import { PagerDutyUser, FormattedBackstageEntity, PagerDutyEnhancedEntityMappingsResponse, + BackstageCustomFieldCreateRequest, + BackstageCustomFieldUpdateRequest, } from '@pagerduty/backstage-plugin-common'; import { Entity } from '@backstage/catalog-model'; import { v4 as uuidv4 } from 'uuid'; @@ -501,5 +503,79 @@ export const mockPagerDutyApi: PagerDutyApi = { async removeServiceMapping(_entityRef: string) { return true; - } + }, + + async getCustomFields() { + return { + customFields: [], + }; + }, + + async createCustomField(request: BackstageCustomFieldCreateRequest) { + const now = new Date(); + return { + status: 'ok' as const, + data: { + id: Math.floor(Math.random() * 10000), + pagerdutyCustomFieldId: `cf_${uuidv4()}`, + pagerdutyCustomFieldDisplayName: request.name || 'Custom Field', + pagerdutyCustomFieldEnabled: true, + backstageEntityMappingPath: request.entityPath || '', + pagerdutySubdomain: 'test-subdomain', + description: request.description, + createdAt: now, + updatedAt: now, + }, + error: null, + }; + }, + + updateCustomField: async ( + _id: number, + request: BackstageCustomFieldUpdateRequest, + ) => ({ + status: 'ok' as const, + data: { + id: 1, + pagerdutyCustomFieldId: 'PD123', + pagerdutyCustomFieldDisplayName: request.name, + pagerdutyCustomFieldEnabled: true, + backstageEntityMappingPath: request.entityPath, + pagerdutySubdomain: 'default', + description: request.description ?? '', + createdAt: new Date(), + updatedAt: new Date(), + }, + error: null, + }), + + setCustomFieldEnabled: async (id: number, enabled: boolean) => ({ + status: 'ok' as const, + data: { + id, + pagerdutyCustomFieldId: 'PD123', + pagerdutyCustomFieldDisplayName: 'Custom Field', + pagerdutyCustomFieldEnabled: enabled, + backstageEntityMappingPath: '', + pagerdutySubdomain: 'default', + description: '', + createdAt: new Date(), + updatedAt: new Date(), + }, + error: null, + }), + + deleteCustomField: async (_id: number) => ({ + status: 'ok' as const, + data: undefined, + error: null, + }), + + getSyncLogs: async () => ({ + logs: [], + total: 0, + customFieldNames: [], + entityPaths: [], + serviceNames: [], + }), }; diff --git a/plugins/backstage-plugin/package.json b/plugins/backstage-plugin/package.json index 50fb6ad..53276a6 100644 --- a/plugins/backstage-plugin/package.json +++ b/plugins/backstage-plugin/package.json @@ -50,7 +50,7 @@ "@backstage/plugin-catalog-react": "backstage:^", "@backstage/plugin-home-react": "backstage:^", "@backstage/theme": "backstage:^", - "@backstage/ui": "^0.14.3", + "@backstage/ui": "backstage:^", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@material-ui/core": "^4.12.2", diff --git a/plugins/backstage-plugin/src/alpha/nav-items.tsx b/plugins/backstage-plugin/src/alpha/nav-items.tsx deleted file mode 100644 index 8622cce..0000000 --- a/plugins/backstage-plugin/src/alpha/nav-items.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { NavItemBlueprint } from "@backstage/frontend-plugin-api"; -import { rootRouteRef } from "../routes"; -import { PagerDutyIcon } from "../components"; -import { convertLegacyRouteRef } from "@backstage/core-compat-api"; - -/** @alpha */ -export const pagerDutyNavBarItem = NavItemBlueprint.make({ - name: 'PagerDutyNavBarItem', - params: { - title: 'PagerDuty', - icon: PagerDutyIcon, - routeRef: convertLegacyRouteRef(rootRouteRef), - } -}); diff --git a/plugins/backstage-plugin/src/alpha/pages.tsx b/plugins/backstage-plugin/src/alpha/pages.tsx index 907b2ed..d60d0e0 100644 --- a/plugins/backstage-plugin/src/alpha/pages.tsx +++ b/plugins/backstage-plugin/src/alpha/pages.tsx @@ -1,13 +1,60 @@ import { convertLegacyRouteRef } from '@backstage/core-compat-api'; import { rootRouteRef } from '../routes'; -import { PageBlueprint } from '@backstage/frontend-plugin-api'; +import { PageBlueprint, SubPageBlueprint } from '@backstage/frontend-plugin-api'; +import { PagerDutyIcon } from '../components'; /** @alpha */ export const pagerDutyPage = PageBlueprint.make({ params: { path: '/pagerduty', + title: 'PagerDuty', + icon: , routeRef: convertLegacyRouteRef(rootRouteRef), - loader: () => - import('../components/PagerDutyPage').then(m => ), - } + // No `loader` here on purpose: when a page has a loader the frontend + // system renders only that content and ignores the `pages` input. Omitting + // it makes the attached SubPageBlueprint extensions render as tabs, with + // the index route redirecting to the first tab (Service Mapping). + }, +}); + +/** @alpha */ +export const pagerDutyServiceMappingSubPage = SubPageBlueprint.make({ + name: 'service-mapping', + attachTo: { id: 'page:pagerduty', input: 'pages' }, + params: { + path: 'service-mapping', + title: 'Service Mapping', + loader: () => + import('../components/PagerDutyPage/ServiceMappingPage').then(m => ( + + )), + }, +}); + +/** @alpha */ +export const pagerDutyCustomFieldsSubPage = SubPageBlueprint.make({ + name: 'custom-fields', + attachTo: { id: 'page:pagerduty', input: 'pages' }, + params: { + path: 'custom-fields', + title: 'Custom Fields', + loader: () => + import('../components/PagerDutyPage/CustomFields').then(m => ( + + )), + }, +}); + +/** @alpha */ +export const pagerDutyConfigurationSubPage = SubPageBlueprint.make({ + name: 'configuration', + attachTo: { id: 'page:pagerduty', input: 'pages' }, + params: { + path: 'settings', + title: 'Configuration', + loader: () => + import('../components/PagerDutyPage/ConfigurationPage').then(m => ( + + )), + }, }); diff --git a/plugins/backstage-plugin/src/alpha/plugin.ts b/plugins/backstage-plugin/src/alpha/plugin.ts index 162b8b8..bef40a9 100644 --- a/plugins/backstage-plugin/src/alpha/plugin.ts +++ b/plugins/backstage-plugin/src/alpha/plugin.ts @@ -17,8 +17,12 @@ import { createFrontendPlugin } from "@backstage/frontend-plugin-api"; import { pagerDutyEntityCard, pagerDutyEntitySmallCard } from "../alpha/entity-cards"; import { pagerDutyApi } from "../alpha/api"; -import { pagerDutyPage } from "../alpha/pages"; -import { pagerDutyNavBarItem } from "../alpha/nav-items"; +import { + pagerDutyPage, + pagerDutyServiceMappingSubPage, + pagerDutyCustomFieldsSubPage, + pagerDutyConfigurationSubPage, +} from "../alpha/pages"; import { convertLegacyRouteRefs } from "@backstage/core-compat-api"; import { rootRouteRef } from "../routes"; @@ -31,7 +35,9 @@ export const pagerDutyPlugin = createFrontendPlugin({ pagerDutyEntitySmallCard, pagerDutyApi, pagerDutyPage, - pagerDutyNavBarItem + pagerDutyServiceMappingSubPage, + pagerDutyCustomFieldsSubPage, + pagerDutyConfigurationSubPage, ], routes: convertLegacyRouteRefs({ root: rootRouteRef diff --git a/plugins/backstage-plugin/src/api/client.test.ts b/plugins/backstage-plugin/src/api/client.test.ts index 8fee8cc..f0fefbc 100644 --- a/plugins/backstage-plugin/src/api/client.test.ts +++ b/plugins/backstage-plugin/src/api/client.test.ts @@ -15,10 +15,12 @@ */ import { MockFetchApi } from '@backstage/test-utils'; import { DiscoveryApi } from '@backstage/core-plugin-api'; -import { PagerDutyClient, UnauthorizedError } from './client'; +import { PagerDutyClient, UnauthorizedError, ForbiddenError } from './client'; import { PagerDutyService, PagerDutySetting, + BackstageCustomField, + BackstageCustomFieldsResponse, } from '@pagerduty/backstage-plugin-common'; import { NotFoundError } from '@backstage/errors'; import { Entity } from '@backstage/catalog-model'; @@ -444,4 +446,360 @@ describe('getSetting', () => { ); }); }); +describe('createCustomField', () => { + const customFieldRequest = { + name: 'custom-field-1', + entityPath: 'metadata.annotations["custom-field-1"]', + description: 'Test custom field', + }; + + const customFieldResponse: BackstageCustomField = { + id: 1, + pagerdutyCustomFieldId: 'FIELD1D', + pagerdutyCustomFieldDisplayName: 'Custom Field 1', + pagerdutyCustomFieldEnabled: true, + backstageEntityMappingPath: 'metadata.annotations["custom-field-1"]', + pagerdutySubdomain: 'test-subdomain', + description: 'Test custom field', + createdAt: new Date('2026-03-04T00:00:00.000Z'), + updatedAt: new Date('2026-03-04T00:00:00.000Z'), + }; + + beforeEach(() => { + mockFetch.mockReset(); + client = new PagerDutyClient({ + eventsBaseUrl: 'https://events.pagerduty.com/v2', + discoveryApi: mockDiscoveryApi, + fetchApi: mockFetchApi, + }); + }); + + it('should send a POST request with the custom field data', async () => { + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve({ customField: customFieldResponse }), + }); + + const result = await client.createCustomField(customFieldRequest); + + expect(result.status).toEqual('ok'); + expect(result.data).toEqual(customFieldResponse); + expect(result.error).toBeNull(); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:7007/pagerduty/custom-fields', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Accept: 'application/json, text/plain, */*', + }, + body: JSON.stringify(customFieldRequest), + }, + ); + }); + + describe('on 401 response code', () => { + beforeEach(() => { + mockFetch.mockResolvedValueOnce({ + status: 401, + ok: false, + json: () => Promise.resolve({}), + }); + }); + + it('returns error result with unauthorized message', async () => { + const result = await client.createCustomField(customFieldRequest); + + expect(result.status).toEqual('error'); + expect(result.data).toBeNull(); + expect(result.error).toContain("Unauthorized"); + }); + }); + + describe('on 403 response code', () => { + beforeEach(() => { + mockFetch.mockResolvedValueOnce({ + status: 403, + ok: false, + json: () => Promise.resolve({}), + }); + }); + + it('returns error result with forbidden message', async () => { + const result = await client.createCustomField(customFieldRequest); + + expect(result.status).toEqual('error'); + expect(result.data).toBeNull(); + expect(result.error).toContain("Forbidden"); + }); + }); + + describe('on 404 response code', () => { + beforeEach(() => { + mockFetch.mockResolvedValueOnce({ + status: 404, + ok: false, + json: () => Promise.resolve({}), + }); + }); + + it('returns error result with not found message', async () => { + const result = await client.createCustomField(customFieldRequest); + + expect(result.status).toEqual('error'); + expect(result.data).toBeNull(); + expect(result.error).toContain("Not Found"); + }); + }); + + describe('on other non-ok response', () => { + beforeEach(() => { + mockFetch.mockResolvedValueOnce({ + status: 500, + ok: false, + json: () => + Promise.resolve({ + errors: ['Internal server error'], + }), + }); + }); + + it('returns error result with error message', async () => { + const result = await client.createCustomField(customFieldRequest); + + expect(result.status).toEqual('error'); + expect(result.data).toBeNull(); + expect(result.error).toContain('Internal server error'); + }); + }); +}); + +describe('getCustomFields', () => { + const customFieldsResponse: BackstageCustomFieldsResponse = { + customFields: [ + { + id: 1, + pagerdutyCustomFieldId: 'FIELD1D', + pagerdutyCustomFieldDisplayName: 'Custom Field 1', + pagerdutyCustomFieldEnabled: true, + backstageEntityMappingPath: 'metadata.annotations["custom-field-1"]', + pagerdutySubdomain: 'test-subdomain', + description: 'Test custom field 1', + createdAt: new Date('2026-03-04T00:00:00.000Z'), + updatedAt: new Date('2026-03-04T00:00:00.000Z'), + }, + { + id: 2, + pagerdutyCustomFieldId: 'FIELD2D', + pagerdutyCustomFieldDisplayName: 'Custom Field 2', + pagerdutyCustomFieldEnabled: true, + backstageEntityMappingPath: 'metadata.annotations["custom-field-2"]', + pagerdutySubdomain: 'test-subdomain', + description: 'Test custom field 2', + createdAt: new Date('2026-03-04T00:00:00.000Z'), + updatedAt: new Date('2026-03-04T00:00:00.000Z'), + }, + ], + }; + + beforeEach(() => { + mockFetch.mockReset(); + client = new PagerDutyClient({ + eventsBaseUrl: 'https://events.pagerduty.com/v2', + discoveryApi: mockDiscoveryApi, + fetchApi: mockFetchApi, + }); + }); + + it('should fetch custom fields from the correct URL', async () => { + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve(customFieldsResponse), + }); + + const result = await client.getCustomFields(); + + expect(result).toEqual(customFieldsResponse); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:7007/pagerduty/custom-fields', + requestHeaders, + ); + }); + + describe('on 401 response code', () => { + beforeEach(() => { + mockFetch.mockResolvedValueOnce({ + status: 401, + ok: false, + json: () => Promise.resolve({}), + }); + }); + + it('throws UnauthorizedError', async () => { + await expect(client.getCustomFields()).rejects.toThrow( + UnauthorizedError, + ); + }); + }); + + describe('on 403 response code', () => { + beforeEach(() => { + mockFetch.mockResolvedValueOnce({ + status: 403, + ok: false, + json: () => Promise.resolve({}), + }); + }); + + it('throws ForbiddenError', async () => { + await expect(client.getCustomFields()).rejects.toThrow(ForbiddenError); + }); + }); + + describe('on 404 response code', () => { + beforeEach(() => { + mockFetch.mockResolvedValueOnce({ + status: 404, + ok: false, + json: () => Promise.resolve({}), + }); + }); + + it('throws NotFoundError', async () => { + await expect(client.getCustomFields()).rejects.toThrow(NotFoundError); + }); + }); + + describe('on other non-ok response', () => { + beforeEach(() => { + mockFetch.mockResolvedValueOnce({ + status: 500, + ok: false, + json: () => + Promise.resolve({ + errors: ['Internal server error'], + }), + }); + }); + + it('throws error with status and message', async () => { + await expect(client.getCustomFields()).rejects.toThrow( + 'Request failed with 500, Internal server error', + ); + }); + }); + + describe('when response is empty', () => { + beforeEach(() => { + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve({ + customFields: [] + }), + }); + }); + + it('returns response with empty customFields array', async () => { + const result = await client.getCustomFields(); + + expect(result).toEqual({ + customFields: [] + }); + }); + }); +}); + +describe('updateCustomField', () => { + const mockField: BackstageCustomField = { + id: 1, + pagerdutyCustomFieldId: 'PD123', + pagerdutyCustomFieldDisplayName: 'New Name', + pagerdutyCustomFieldEnabled: true, + backstageEntityMappingPath: 'metadata.new', + pagerdutySubdomain: 'default', + description: 'desc', + createdAt: new Date('2026-03-04T00:00:00.000Z'), + updatedAt: new Date('2026-03-04T00:00:00.000Z'), + }; + + beforeEach(() => { + mockFetch.mockReset(); + client = new PagerDutyClient({ + eventsBaseUrl: 'https://events.pagerduty.com/v2', + discoveryApi: mockDiscoveryApi, + fetchApi: mockFetchApi, + }); + }); + + it('returns ok result with updated custom field on success', async () => { + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve({ customField: mockField }), + }); + + const result = await client.updateCustomField(1, { + name: 'New Name', + entityPath: 'metadata.new', + description: 'desc', + }); + + expect(result.status).toEqual('ok'); + expect(result.data?.pagerdutyCustomFieldDisplayName).toEqual('New Name'); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:7007/pagerduty/custom-fields/1', + expect.objectContaining({ + method: 'PUT', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Accept: 'application/json, text/plain, */*', + }, + body: JSON.stringify({ + name: 'New Name', + entityPath: 'metadata.new', + description: 'desc', + }), + }), + ); + }); + + it('returns error result when field not found (404)', async () => { + mockFetch.mockResolvedValueOnce({ + status: 404, + ok: false, + json: () => Promise.resolve({}), + }); + + const result = await client.updateCustomField(999, { + name: 'X', + entityPath: 'metadata.x', + }); + + expect(result.status).toEqual('error'); + }); + + it('returns error result on name conflict (409)', async () => { + mockFetch.mockResolvedValueOnce({ + status: 409, + ok: false, + json: () => + Promise.resolve({ + errors: [ + 'A custom field with this name already exists in PagerDuty', + ], + }), + }); + + const result = await client.updateCustomField(1, { + name: 'Taken', + entityPath: 'metadata.taken', + }); + + expect(result.status).toEqual('error'); + expect(result.error).toContain('already exists'); + }); +}); }); diff --git a/plugins/backstage-plugin/src/api/client.ts b/plugins/backstage-plugin/src/api/client.ts index 77dbad5..8d642ac 100644 --- a/plugins/backstage-plugin/src/api/client.ts +++ b/plugins/backstage-plugin/src/api/client.ts @@ -20,6 +20,7 @@ import { PagerDutyClientApiDependencies, PagerDutyClientApiConfig, RequestOptions, + Result, } from './types'; import { PagerDutyChangeEventsResponse, @@ -35,6 +36,12 @@ import { AutoMatchStartResponse, AutoMatchStatusResponse, PagerDutyTeam, + BackstageCustomFieldCreateRequest, + BackstageCustomFieldUpdateRequest, + BackstageCustomField, + BackstageCustomFieldsResponse, + CustomFieldSyncLogsResponse, + CustomFieldSyncLogFilters, } from '@pagerduty/backstage-plugin-common'; import { createApiRef, ConfigApi } from '@backstage/core-plugin-api'; import { NotFoundError } from '@backstage/errors'; @@ -53,6 +60,26 @@ export const pagerDutyApiRef = createApiRef({ id: 'plugin.pagerduty.api', }); +function parseCustomFieldError( + error: unknown, + fallbackMessage: string, +): Result { + if (error instanceof Error) { + const msg = error.message; + if (msg.includes('already been taken')) { + return { status: 'error', data: null, error: 'A custom field with this name already exists' }; + } + if (msg.toLowerCase().includes('entity path already exists')) { + return { status: 'error', data: null, error: 'A custom field with this entity path already exists' }; + } + if (msg.toLowerCase().includes('limit reached')) { + return { status: 'error', data: null, error: 'Custom field limit reached. Maximum number of custom fields has been exceeded.' }; + } + return { status: 'error', data: null, error: msg }; + } + return { status: 'error', data: null, error: fallbackMessage }; +} + /** @public */ export class PagerDutyClient implements PagerDutyApi { static fromConfig( @@ -498,6 +525,167 @@ export class PagerDutyClient implements PagerDutyApi { return response.accounts; } + async createCustomField( + request: BackstageCustomFieldCreateRequest, + account?: string, + ): Promise> { + const body = JSON.stringify(request); + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Accept: 'application/json, text/plain, */*', + }, + body, + }; + + let url = `${await this.config.discoveryApi.getBaseUrl( + 'pagerduty', + )}/custom-fields`; + + if (account) { + url = url.concat(`?account=${account}`); + } + + try { + const response = await this.request(url, options); + const result = await response.json(); + return { status: 'ok', data: result.customField, error: null }; + } catch (error) { + return parseCustomFieldError(error, 'Failed to create custom field'); + } + } + + async getCustomFields(account?: string): Promise { + let url = `${await this.config.discoveryApi.getBaseUrl( + 'pagerduty', + )}/custom-fields`; + + if (account) { + url = url.concat(`?account=${account}`); + } + + return await this.findByUrl(url); + } + + async getSyncLogs( + account?: string, + options?: { limit?: number; offset?: number } & CustomFieldSyncLogFilters, + ): Promise { + const params = new URLSearchParams(); + if (account) params.set('account', account); + if (options?.limit) params.set('limit', String(options.limit)); + if (options?.offset !== undefined) params.set('offset', String(options.offset)); + if (options?.search) params.set('search', options.search); + if (options?.severity) params.set('severity', options.severity); + if (options?.customFieldName) params.set('customFieldName', options.customFieldName); + if (options?.entityPath) params.set('entityPath', options.entityPath); + if (options?.serviceName) params.set('serviceName', options.serviceName); + + const query = params.toString() ? `?${params.toString()}` : ''; + const url = `${await this.config.discoveryApi.getBaseUrl( + 'pagerduty', + )}/custom-fields/sync-logs${query}`; + + return await this.findByUrl(url); + } + + async updateCustomField( + id: number, + request: BackstageCustomFieldUpdateRequest, + account?: string, + ): Promise> { + const body = JSON.stringify(request); + + const options = { + method: 'PUT', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Accept: 'application/json, text/plain, */*', + }, + body, + }; + + let url = `${await this.config.discoveryApi.getBaseUrl( + 'pagerduty', + )}/custom-fields/${id}`; + + if (account) { + url = url.concat(`?account=${account}`); + } + + try { + const response = await this.request(url, options); + const result = await response.json(); + return { status: 'ok', data: result.customField, error: null }; + } catch (error) { + return parseCustomFieldError(error, 'Failed to update custom field'); + } + } + + async setCustomFieldEnabled( + id: number, + enabled: boolean, + account?: string, + ): Promise> { + const options = { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Accept: 'application/json, text/plain, */*', + }, + body: JSON.stringify({ enabled }), + }; + + let url = `${await this.config.discoveryApi.getBaseUrl( + 'pagerduty', + )}/custom-fields/${id}/enabled`; + + if (account) { + url = url.concat(`?account=${account}`); + } + + try { + const response = await this.request(url, options); + const result = await response.json(); + return { status: 'ok', data: result.customField, error: null }; + } catch (error) { + return parseCustomFieldError( + error, + `Failed to ${enabled ? 'enable' : 'disable'} custom field`, + ); + } + } + + async deleteCustomField( + id: number, + account?: string, + ): Promise> { + const options = { + method: 'DELETE', + headers: { + Accept: 'application/json, text/plain, */*', + }, + }; + + let url = `${await this.config.discoveryApi.getBaseUrl( + 'pagerduty', + )}/custom-fields/${id}`; + + if (account) { + url = url.concat(`?account=${account}`); + } + + try { + await this.request(url, options); + return { status: 'ok', data: undefined, error: null }; + } catch (error) { + const result = parseCustomFieldError(error, 'Failed to delete custom field'); + return { status: 'error', data: null, error: result.error! }; + } + } + private async findByUrl(url: string): Promise { const options = { method: 'GET', diff --git a/plugins/backstage-plugin/src/api/types.ts b/plugins/backstage-plugin/src/api/types.ts index fcdef94..735c252 100644 --- a/plugins/backstage-plugin/src/api/types.ts +++ b/plugins/backstage-plugin/src/api/types.ts @@ -29,6 +29,12 @@ import { PagerDutyEnhancedEntityMappingsResponse, AutoMatchStartResponse, AutoMatchStatusResponse, + BackstageCustomField, + BackstageCustomFieldCreateRequest, + BackstageCustomFieldUpdateRequest, + BackstageCustomFieldsResponse, + CustomFieldSyncLogsResponse, + CustomFieldSyncLogFilters, } from '@pagerduty/backstage-plugin-common'; import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; @@ -246,6 +252,55 @@ export interface PagerDutyApi { * Fetches the list of configured PagerDuty accounts. */ getAccounts(): Promise>; + + /** + * Creates a custom field in PagerDuty and stores the mapping. + */ + createCustomField( + request: BackstageCustomFieldCreateRequest, + account?: string, + ): Promise>; + + /** + * Fetches all custom fields. + */ + getCustomFields(account?: string): Promise; + + /** + * Updates an existing custom field mapping. + */ + updateCustomField( + id: number, + request: BackstageCustomFieldUpdateRequest, + account?: string, + ): Promise>; + + /** + * Enables or disables a custom field. Disabled fields are not synced to + * PagerDuty and do not count against the PagerDuty custom field hard limit. + */ + setCustomFieldEnabled( + id: number, + enabled: boolean, + account?: string, + ): Promise>; + + /** + * Deletes a custom field from both Backstage and PagerDuty, reclaiming the slot. + */ + deleteCustomField( + id: number, + account?: string, + ): Promise>; + + /** + * Fetches sync logs (paginated, filtered) along with the distinct + * filter values for the given account. + */ + getSyncLogs( + account?: string, + options?: { limit?: number; offset?: number } & CustomFieldSyncLogFilters, + ): Promise; } /** @public */ @@ -264,3 +319,8 @@ export type RequestOptions = { headers: HeadersInit; body?: BodyInit; }; + +/** @public */ +export type Result = + | { status: 'ok'; data: T; error: null } + | { status: 'error'; data: null; error: string }; diff --git a/plugins/backstage-plugin/src/components/PagerDutyPage/ConfigurationPage.tsx b/plugins/backstage-plugin/src/components/PagerDutyPage/ConfigurationPage.tsx new file mode 100644 index 0000000..04e5afd --- /dev/null +++ b/plugins/backstage-plugin/src/components/PagerDutyPage/ConfigurationPage.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from 'react'; +import { createStyles, makeStyles, Typography } from '@material-ui/core'; +import { Card, RadioGroup, Radio, Box, Alert } from '@backstage/ui'; +import { useApi, alertApiRef } from '@backstage/core-plugin-api'; +import { NotFoundError } from '@backstage/errors'; +import { pagerDutyApiRef } from '../../api'; + +enum StoreSettings { + backstage = 'backstage', + pagerduty = 'pagerduty', + both = 'both', + disabled = 'disabled', +} + +const SERVICE_DEPENDENCY_SYNC_STRATEGY = + 'settings::service-dependency-sync-strategy'; + +const useStyles = makeStyles(() => + createStyles({ + cardStyles: { + padding: '15px', + marginTop: '8px', + paddingBottom: "20px" + }, + textContainerStyles: { + marginTop: '16px', + }, + linkStyles: { + color: 'cadetblue', + }, + }), +); + +/** @public */ +export const ConfigurationPage = () => { + const { cardStyles, textContainerStyles, linkStyles } = useStyles(); + const pagerDutyApi = useApi(pagerDutyApiRef); + const alertApi = useApi(alertApiRef); + const [ + selectedServiceDependencyStrategy, + setSelectedServiceDependencyStrategy, + ] = useState('disabled'); + + useEffect(() => { + function fetchSetting() { + pagerDutyApi + .getSetting(SERVICE_DEPENDENCY_SYNC_STRATEGY) + .then(result => { + if (result !== undefined) { + setSelectedServiceDependencyStrategy(result.value); + } + }) + .catch(error => { + if (error instanceof NotFoundError) { + // If the setting is not found, set the default value to "disabled" + setSelectedServiceDependencyStrategy('disabled'); + } + }); + } + + fetchSetting(); + }, [pagerDutyApi]); + + const handleChange = async (value: StoreSettings) => { + // Optimistically reflect the selection, remembering the previous value so we + // can roll back if the save fails — otherwise the radio would show a setting + // that was never persisted and silently revert on the next page load. + const previousValue = selectedServiceDependencyStrategy; + setSelectedServiceDependencyStrategy(value); + + try { + await pagerDutyApi.storeSettings([ + { + id: SERVICE_DEPENDENCY_SYNC_STRATEGY, + value, + }, + ]); + } catch (error) { + setSelectedServiceDependencyStrategy(previousValue); + alertApi.post({ + message: `Failed to save service dependency synchronization strategy. ${ + error instanceof Error ? error.message : error + }`, + severity: 'error', + }); + } + }; + + return ( + + + Check the{' '} + + documentation + {' '} + for more information. + + } + /> + + + Service dependency synchronization strategy + + void handleChange(value as StoreSettings)} + > + Backstage + PagerDuty + Both + Disabled + + + + ); +}; diff --git a/plugins/backstage-plugin/src/components/PagerDutyPage/CustomFieldModal.tsx b/plugins/backstage-plugin/src/components/PagerDutyPage/CustomFieldModal.tsx new file mode 100644 index 0000000..d5f56c9 --- /dev/null +++ b/plugins/backstage-plugin/src/components/PagerDutyPage/CustomFieldModal.tsx @@ -0,0 +1,204 @@ +import { useState, useEffect } from 'react'; +import { + Box, + Button, + Dialog, + DialogBody, + DialogFooter, + DialogHeader, + Flex, + Text, + TextField, +} from '@backstage/ui'; +import { makeStyles, createStyles } from '@material-ui/core/styles'; +import { BackstageTheme } from '@backstage/theme'; + +const useStyles = makeStyles(theme => + createStyles({ + generalErrorBox: { + backgroundColor: 'rgba(211, 47, 47, 0.08)', + borderRadius: theme.shape.borderRadius, + padding: `${theme.spacing(1)}px ${theme.spacing(1.5)}px`, + marginBottom: theme.spacing(2), + }, + fieldErrorText: { + color: theme.palette.error.main, + fontSize: '0.75rem', + margin: `${theme.spacing(0.5)}px 0 0 0`, + }, + descriptionLabel: { + display: 'block', + fontSize: '0.875rem', + marginBottom: theme.spacing(0.75), + }, + textarea: { + width: '100%', + boxSizing: 'border-box' as const, + padding: `${theme.spacing(1)}px ${theme.spacing(1.5)}px`, + fontSize: '0.875rem', + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + resize: 'vertical' as const, + fontFamily: 'inherit', + color: 'inherit', + backgroundColor: 'transparent', + '&:disabled': { + backgroundColor: theme.palette.action.disabledBackground, + }, + }, + }), +); + +interface FormData { + name: string; + entityPath: string; + description: string; +} + +/** @public */ +export interface FieldErrors { + name?: string; + entityPath?: string; + general?: string; +} + +interface CustomFieldModalProps { + open: boolean; + saving: boolean; + error: FieldErrors | null; + onClose: () => void; + onSave: (data: FormData) => Promise; + mode?: 'add' | 'edit'; + initialValues?: FormData; +} + +/** @public */ +export const CustomFieldModal = ({ + open, + saving, + error, + onClose, + onSave, + mode = 'add', + initialValues, +}: CustomFieldModalProps) => { + const classes = useStyles(); + const [formData, setFormData] = useState( + initialValues ?? { name: '', entityPath: '', description: '' }, + ); + const [localErrors, setLocalErrors] = useState({}); + + useEffect(() => { + if (open) { + setFormData(initialValues ?? { name: '', entityPath: '', description: '' }); + setLocalErrors({}); + } + // initialValues is intentionally excluded: the form must only reset when the + // modal opens, not on every re-render of the parent that creates a new object + // reference while the modal is already open (e.g. while saving). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + useEffect(() => { + setLocalErrors(error ?? {}); + }, [error]); + + const handleFormChange = (field: keyof FormData) => (value: string) => { + setFormData(prev => ({ ...prev, [field]: value })); + if (field in localErrors) { + setLocalErrors(prev => ({ ...prev, [field]: undefined })); + } + }; + + const handleClose = () => { + setFormData({ name: '', entityPath: '', description: '' }); + setLocalErrors({}); + onClose(); + }; + + const handleSave = async () => { + await onSave(formData); + }; + + const getSaveLabel = () => { + return mode === 'edit' ? 'Save' : 'Add'; + }; + const saveLabel = getSaveLabel(); + + return ( + !isOpen && handleClose()} + width={500} + > + + {mode === 'edit' ? 'Edit Custom Field' : 'Add New Custom Field'} + + + {localErrors.general && ( + + + {localErrors.general} + + + )} + + + + {localErrors.name && ( + + {localErrors.name} + + )} + + + + {localErrors.entityPath && ( + + {localErrors.entityPath} + + )} + + + + Description + +