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/CHANGELOG.md b/plugins/backstage-plugin-backend/CHANGELOG.md index 481d98e..8587f11 100644 --- a/plugins/backstage-plugin-backend/CHANGELOG.md +++ b/plugins/backstage-plugin-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @pagerduty/backstage-plugin-backend +## 0.14.0 + +### Minor Changes + +- bee64f1: Release the service mappings feature + +### Patch Changes + +- Updated dependencies [bee64f1] + - @pagerduty/backstage-plugin-common@0.5.0 + ## 0.13.0 ### Minor Changes 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/package.json b/plugins/backstage-plugin-backend/package.json index 447b77d..c078818 100644 --- a/plugins/backstage-plugin-backend/package.json +++ b/plugins/backstage-plugin-backend/package.json @@ -1,6 +1,6 @@ { "name": "@pagerduty/backstage-plugin-backend", - "version": "0.13.0", + "version": "0.14.0", "main": "dist/index.cjs.js", "types": "dist/index.d.ts", "license": "Apache-2.0", 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/CHANGELOG.md b/plugins/backstage-plugin-common/CHANGELOG.md index c670a82..092eec0 100644 --- a/plugins/backstage-plugin-common/CHANGELOG.md +++ b/plugins/backstage-plugin-common/CHANGELOG.md @@ -1,5 +1,11 @@ # @pagerduty/backstage-plugin-common +## 0.5.0 + +### Minor Changes + +- bee64f1: Release the service mappings feature + ## 0.4.0 ### Minor Changes diff --git a/plugins/backstage-plugin-common/package.json b/plugins/backstage-plugin-common/package.json index c40f2c9..ff79603 100644 --- a/plugins/backstage-plugin-common/package.json +++ b/plugins/backstage-plugin-common/package.json @@ -1,7 +1,7 @@ { "name": "@pagerduty/backstage-plugin-common", "description": "Common components for PagerDuty plugins for Backstage", - "version": "0.4.0", + "version": "0.5.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", 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/CHANGELOG.md b/plugins/backstage-plugin-entity-processor/CHANGELOG.md index 60c6c66..151ed02 100644 --- a/plugins/backstage-plugin-entity-processor/CHANGELOG.md +++ b/plugins/backstage-plugin-entity-processor/CHANGELOG.md @@ -1,5 +1,16 @@ # @pagerduty/backstage-plugin-entity-processor +## 0.4.0 + +### Minor Changes + +- bee64f1: Release the service mappings feature + +### Patch Changes + +- Updated dependencies [bee64f1] + - @pagerduty/backstage-plugin-common@0.5.0 + ## 0.3.11 ### Patch Changes diff --git a/plugins/backstage-plugin-entity-processor/package.json b/plugins/backstage-plugin-entity-processor/package.json index bbc6aba..97a2147 100644 --- a/plugins/backstage-plugin-entity-processor/package.json +++ b/plugins/backstage-plugin-entity-processor/package.json @@ -1,6 +1,6 @@ { "name": "@pagerduty/backstage-plugin-entity-processor", - "version": "0.3.11", + "version": "0.4.0", "main": "dist/index.cjs.js", "types": "dist/index.d.ts", "license": "Apache-2.0", 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-scaffolder-actions/CHANGELOG.md b/plugins/backstage-plugin-scaffolder-actions/CHANGELOG.md index 5eef5e5..48ba5c3 100644 --- a/plugins/backstage-plugin-scaffolder-actions/CHANGELOG.md +++ b/plugins/backstage-plugin-scaffolder-actions/CHANGELOG.md @@ -1,5 +1,12 @@ # @pagerduty/backstage-plugin-scaffolder-actions +## 0.3.1 + +### Patch Changes + +- Updated dependencies [bee64f1] + - @pagerduty/backstage-plugin-common@0.5.0 + ## 0.3.0 ### Minor Changes diff --git a/plugins/backstage-plugin-scaffolder-actions/package.json b/plugins/backstage-plugin-scaffolder-actions/package.json index c2e4b49..519cac0 100644 --- a/plugins/backstage-plugin-scaffolder-actions/package.json +++ b/plugins/backstage-plugin-scaffolder-actions/package.json @@ -1,6 +1,6 @@ { "name": "@pagerduty/backstage-plugin-scaffolder-actions", - "version": "0.3.0", + "version": "0.3.1", "main": "dist/index.cjs.js", "types": "dist/index.d.ts", "license": "Apache-2.0", diff --git a/plugins/backstage-plugin/CHANGELOG.md b/plugins/backstage-plugin/CHANGELOG.md index 5fca00c..6ab5e15 100644 --- a/plugins/backstage-plugin/CHANGELOG.md +++ b/plugins/backstage-plugin/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-pagerduty +## 0.21.0 + +### Minor Changes + +- bee64f1: Release the service mappings feature + +### Patch Changes + +- Updated dependencies [bee64f1] + - @pagerduty/backstage-plugin-common@0.5.0 + ## 0.20.0 ### Minor Changes 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..fcf64c5 100644 --- a/plugins/backstage-plugin/package.json +++ b/plugins/backstage-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@pagerduty/backstage-plugin", "description": "A Backstage plugin that integrates towards PagerDuty", - "version": "0.20.0", + "version": "0.21.0", "main": "dist/index.esm.js", "types": "dist/index.d.ts", "license": "Apache-2.0", @@ -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 + +