From d52d7f99358b360f00f4a43936494050e138f1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sun, 17 Nov 2024 18:54:56 +0100 Subject: [PATCH] implement support for string form human durations in config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/clean-pears-roll.md | 5 + .changeset/serious-gorillas-work.md | 12 + packages/backend-defaults/config.d.ts | 10 +- .../entrypoints/cache/CacheManager.test.ts | 2 +- .../src/entrypoints/cache/CacheManager.ts | 12 +- .../services/definitions/SchedulerService.ts | 19 +- packages/config/package.json | 3 +- .../config/src/readDurationFromConfig.test.ts | 334 +++++++++++++----- packages/config/src/readDurationFromConfig.ts | 219 +++++++++++- .../config.d.ts | 2 +- plugins/auth-backend/config.d.ts | 2 +- plugins/catalog-backend/config.d.ts | 4 +- .../catalog-backend/src/stitching/types.ts | 9 +- .../events-backend-module-aws-sqs/config.d.ts | 8 +- .../src/publisher/config.ts | 6 +- .../config.d.ts | 4 +- .../processor/NotificationsEmailProcessor.ts | 18 +- plugins/scaffolder-backend/config.d.ts | 8 +- yarn.lock | 1 + 19 files changed, 515 insertions(+), 163 deletions(-) create mode 100644 .changeset/clean-pears-roll.md create mode 100644 .changeset/serious-gorillas-work.md diff --git a/.changeset/clean-pears-roll.md b/.changeset/clean-pears-roll.md new file mode 100644 index 0000000000..9c3c20fadc --- /dev/null +++ b/.changeset/clean-pears-roll.md @@ -0,0 +1,5 @@ +--- +'@backstage/config': minor +--- + +Make `readDurationFromConfig` support both ISO and ms formats as well, to make it easier to enter time as an end user diff --git a/.changeset/serious-gorillas-work.md b/.changeset/serious-gorillas-work.md new file mode 100644 index 0000000000..a17a2a44e6 --- /dev/null +++ b/.changeset/serious-gorillas-work.md @@ -0,0 +1,12 @@ +--- +'@backstage/plugin-auth-backend-module-cloudflare-access-provider': patch +'@backstage/plugin-notifications-backend-module-email': patch +'@backstage/plugin-events-backend-module-aws-sqs': patch +'@backstage/backend-plugin-api': patch +'@backstage/plugin-scaffolder-backend': patch +'@backstage/backend-defaults': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-auth-backend': patch +--- + +Support ISO and ms string forms of durations in config too diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index a8c6e1c290..649846697f 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -474,8 +474,8 @@ export interface Config { cache?: | { store: 'memory'; - /** An optional default TTL (in milliseconds). */ - defaultTtl?: number | HumanDuration; + /** An optional default TTL (in milliseconds, if given as a number). */ + defaultTtl?: number | HumanDuration | string; } | { store: 'redis'; @@ -484,8 +484,8 @@ export interface Config { * @visibility secret */ connection: string; - /** An optional default TTL (in milliseconds). */ - defaultTtl?: number | HumanDuration; + /** An optional default TTL (in milliseconds, if given as a number). */ + defaultTtl?: number | HumanDuration | string; /** * Whether or not [useRedisSets](https://github.com/jaredwray/keyv/tree/main/packages/redis#useredissets) should be configured to this redis cache. * Defaults to true if unspecified. @@ -500,7 +500,7 @@ export interface Config { */ connection: string; /** An optional default TTL (in milliseconds). */ - defaultTtl?: number | HumanDuration; + defaultTtl?: number | HumanDuration | string; }; cors?: { diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts b/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts index 997739faa2..c075bccaa1 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts @@ -185,6 +185,6 @@ describe('CacheManager integration', () => { }, }), ), - ).toThrow(/Invalid configuration backend.cache.defaultTtl/); + ).toThrow(/Invalid duration 'hello' in config/); }); }); diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts index a54959f155..2bc3d2afdb 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts @@ -24,6 +24,7 @@ import Keyv from 'keyv'; import { DefaultCacheClient } from './CacheClient'; import { CacheManagerOptions, ttlToMilliseconds } from './types'; import { durationToMilliseconds } from '@backstage/types'; +import { readDurationFromConfig } from '@backstage/config'; type StoreFactory = (pluginId: string, defaultTtl: number | undefined) => Keyv; @@ -75,17 +76,12 @@ export class CacheManager { }); let defaultTtl: number | undefined; - if (defaultTtlConfig !== undefined && defaultTtlConfig !== null) { + if (defaultTtlConfig !== undefined) { if (typeof defaultTtlConfig === 'number') { defaultTtl = defaultTtlConfig; - } else if ( - typeof defaultTtlConfig === 'object' && - !Array.isArray(defaultTtlConfig) - ) { - defaultTtl = durationToMilliseconds(defaultTtlConfig); } else { - throw new Error( - `Invalid configuration backend.cache.defaultTtl: ${defaultTtlConfig}, expected milliseconds number or HumanDuration object`, + defaultTtl = durationToMilliseconds( + readDurationFromConfig(config, { key: 'backend.cache.defaultTtl' }), ); } } diff --git a/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts b/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts index a8b84b9084..24219228df 100644 --- a/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts +++ b/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts @@ -354,19 +354,6 @@ export interface SchedulerService { getScheduledTasks(): Promise; } -function readDuration(config: Config, key: string): HumanDuration { - if (typeof config.get(key) === 'string') { - const value = config.getString(key); - const duration = Duration.fromISO(value); - if (!duration.isValid) { - throw new Error(`Invalid duration: ${value}`); - } - return duration.toObject(); - } - - return readDurationFromConfig(config, { key }); -} - function readFrequency( config: Config, key: string, @@ -382,7 +369,7 @@ function readFrequency( return { trigger: 'manual' }; } - return readDuration(config, key); + return readDurationFromConfig(config, { key }); } /** @@ -396,10 +383,10 @@ export function readSchedulerServiceTaskScheduleDefinitionFromConfig( config: Config, ): SchedulerServiceTaskScheduleDefinition { const frequency = readFrequency(config, 'frequency'); - const timeout = readDuration(config, 'timeout'); + const timeout = readDurationFromConfig(config, { key: 'timeout' }); const initialDelay = config.has('initialDelay') - ? readDuration(config, 'initialDelay') + ? readDurationFromConfig(config, { key: 'initialDelay' }) : undefined; const scope = config.getOptionalString('scope'); diff --git a/packages/config/package.json b/packages/config/package.json index 35bc0719e3..fa53350b97 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -37,7 +37,8 @@ }, "dependencies": { "@backstage/errors": "workspace:^", - "@backstage/types": "workspace:^" + "@backstage/types": "workspace:^", + "ms": "^2.1.3" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/packages/config/src/readDurationFromConfig.test.ts b/packages/config/src/readDurationFromConfig.test.ts index 3da790ddcf..f3fa0819b0 100644 --- a/packages/config/src/readDurationFromConfig.test.ts +++ b/packages/config/src/readDurationFromConfig.test.ts @@ -15,114 +15,254 @@ */ import { - readDurationFromConfig, propsOfHumanDuration, + readDurationFromConfig, } from './readDurationFromConfig'; import { ConfigReader } from './reader'; describe('readDurationFromConfig', () => { - it('reads all known keys', () => { - const config = new ConfigReader({ - milliseconds: 1, - seconds: 2, - minutes: 3, - hours: 4, - days: 5, - weeks: 6, - months: 7, - years: 8, + describe('ISO form', () => { + it('parses the known forms', () => { + const config = new ConfigReader({ + d1: 'P2DT6H', + d2: 'PT0.5S', + d3: 'PT3.1S', + d4: 'P1Y2M3W4DT5H6M7.8S', + }); + + expect(readDurationFromConfig(config, { key: 'd1' })).toEqual({ + days: 2, + hours: 6, + }); + expect(readDurationFromConfig(config, { key: 'd2' })).toEqual({ + milliseconds: 500, + }); + expect(readDurationFromConfig(config, { key: 'd3' })).toEqual({ + seconds: 3, + milliseconds: 100, + }); + expect(readDurationFromConfig(config, { key: 'd4' })).toEqual({ + years: 1, + months: 2, + weeks: 3, + days: 4, + hours: 5, + minutes: 6, + seconds: 7, + milliseconds: 800, + }); }); - expect(readDurationFromConfig(config)).toEqual({ - milliseconds: 1, - seconds: 2, - minutes: 3, - hours: 4, - days: 5, - weeks: 6, - months: 7, - years: 8, + + it('throws on errors', () => { + const config = new ConfigReader({ + d1: 'P 1Y', + d2: 'P1L', + d3: 'P', + }); + + expect(() => + readDurationFromConfig(config, { key: 'd1' }), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration 'P 1Y' in config at 'd1', Error: Invalid ISO format, expected a value similar to 'P2DT6H' (2 days 6 hours) or 'PT1M' (1 minute)"`, + ); + expect(() => + readDurationFromConfig(config, { key: 'd2' }), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration 'P1L' in config at 'd2', Error: Invalid ISO format, expected a value similar to 'P2DT6H' (2 days 6 hours) or 'PT1M' (1 minute)"`, + ); + expect(() => + readDurationFromConfig(config, { key: 'd3' }), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration 'P' in config at 'd3', Error: Invalid ISO format, no values given"`, + ); }); }); - it('reads all known keys, for a subkey', () => { - const config = new ConfigReader({ - sub: { - key: { - milliseconds: 1, - seconds: 2, - minutes: 3, - hours: 4, - days: 5, - weeks: 6, - months: 7, - years: 8, + describe('ms form', () => { + it('parses the known units', () => { + // this is not exhaustive, but tests all supported units to ensure that + // our conversion to HumanDuration form works + const config = new ConfigReader({ + d1: '1y', + d2: '2 years', + d3: '4w', + d4: '5h', + d5: '6 hrs', + d6: '7min', + d7: '9 minutes', + d8: '3.5 seconds', + d9: '25 ms', + d10: '1850ms', + }); + + expect(readDurationFromConfig(config, { key: 'd1' })).toEqual({ + years: 1, + }); + expect(readDurationFromConfig(config, { key: 'd2' })).toEqual({ + years: 2, + }); + expect(readDurationFromConfig(config, { key: 'd3' })).toEqual({ + weeks: 4, + }); + expect(readDurationFromConfig(config, { key: 'd4' })).toEqual({ + hours: 5, + }); + expect(readDurationFromConfig(config, { key: 'd5' })).toEqual({ + hours: 6, + }); + expect(readDurationFromConfig(config, { key: 'd6' })).toEqual({ + minutes: 7, + }); + expect(readDurationFromConfig(config, { key: 'd7' })).toEqual({ + minutes: 9, + }); + expect(readDurationFromConfig(config, { key: 'd8' })).toEqual({ + seconds: 3, + milliseconds: 500, + }); + expect(readDurationFromConfig(config, { key: 'd9' })).toEqual({ + milliseconds: 25, + }); + expect(readDurationFromConfig(config, { key: 'd10' })).toEqual({ + seconds: 1, + milliseconds: 850, + }); + }); + + it('throws on errors', () => { + const config = new ConfigReader({ + d1: '1m 3s', + d2: '-3s', + d3: '', + }); + + expect(() => + readDurationFromConfig(config, { key: 'd1' }), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration '1m 3s' in config at 'd1', Error: Not a valid duration string, try a number followed by a unit such as '1d' or '2 seconds'"`, + ); + expect(() => + readDurationFromConfig(config, { key: 'd2' }), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration '-3s' in config at 'd2', Error: Negative durations are not allowed"`, + ); + expect(() => + readDurationFromConfig(config, { key: 'd3' }), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid type in config for key 'd3' in 'mock-config', got empty-string, wanted string"`, + ); + }); + }); + + describe('object form', () => { + it('reads all known keys', () => { + const config = new ConfigReader({ + milliseconds: 1, + seconds: 2, + minutes: 3, + hours: 4, + days: 5, + weeks: 6, + months: 7, + years: 8, + }); + expect(readDurationFromConfig(config)).toEqual({ + milliseconds: 1, + seconds: 2, + minutes: 3, + hours: 4, + days: 5, + weeks: 6, + months: 7, + years: 8, + }); + }); + + it('reads all known keys, for a subkey', () => { + const config = new ConfigReader({ + sub: { + key: { + milliseconds: 1, + seconds: 2, + minutes: 3, + hours: 4, + days: 5, + weeks: 6, + months: 7, + years: 8, + }, }, + }); + expect(readDurationFromConfig(config, { key: 'sub.key' })).toEqual({ + milliseconds: 1, + seconds: 2, + minutes: 3, + hours: 4, + days: 5, + weeks: 6, + months: 7, + years: 8, + }); + }); + + it('rejects wrong type of target, for a subkey', () => { + const config = new ConfigReader({ + sub: { key: 7 }, + }); + expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow( + "Failed to read duration from config, TypeError: Invalid type in config for key 'sub.key' in 'mock-config', got number, wanted object", + ); + }); + + it('rejects no keys', () => { + const config = new ConfigReader({}); + expect(() => readDurationFromConfig(config)).toThrow( + `Failed to read duration from config, Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`, + ); + }); + + it('rejects no keys, for a subkey', () => { + const config = new ConfigReader({ sub: { key: {} } }); + expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow( + `Failed to read duration from config at 'sub.key', Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`, + ); + }); + + it('rejects unknown keys', () => { + const config = new ConfigReader({ + minutes: 3, + invalid: 'value', + }); + expect(() => readDurationFromConfig(config)).toThrow( + `Failed to read duration from config, Error: Unknown property 'invalid'; expected one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`, + ); + }); + + it.each(propsOfHumanDuration)('rejects non-number %p', prop => { + const config = new ConfigReader({ + [prop]: 'value', + }); + expect(() => readDurationFromConfig(config)).toThrow( + `Failed to read duration from config, Error: Unable to convert config value for key '${prop}' in 'mock-config' to a number`, + ); + }); + + it.each(propsOfHumanDuration)( + 'rejects non-number %p, for a subkey', + prop => { + const config = new ConfigReader({ + sub: { + key: { + [prop]: 'value', + }, + }, + }); + expect(() => + readDurationFromConfig(config, { key: 'sub.key' }), + ).toThrow( + `Failed to read duration from config, Error: Unable to convert config value for key 'sub.key.${prop}' in 'mock-config' to a number`, + ); }, - }); - expect(readDurationFromConfig(config, { key: 'sub.key' })).toEqual({ - milliseconds: 1, - seconds: 2, - minutes: 3, - hours: 4, - days: 5, - weeks: 6, - months: 7, - years: 8, - }); - }); - - it('rejects wrong type of target, for a subkey', () => { - const config = new ConfigReader({ - sub: { key: 7 }, - }); - expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow( - "Failed to read duration from config, TypeError: Invalid type in config for key 'sub.key' in 'mock-config', got number, wanted object", - ); - }); - - it('rejects no keys', () => { - const config = new ConfigReader({}); - expect(() => readDurationFromConfig(config)).toThrow( - `Failed to read duration from config, Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`, - ); - }); - - it('rejects no keys, for a subkey', () => { - const config = new ConfigReader({ sub: { key: {} } }); - expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow( - `Failed to read duration from config at 'sub.key', Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`, - ); - }); - - it('rejects unknown keys', () => { - const config = new ConfigReader({ - minutes: 3, - invalid: 'value', - }); - expect(() => readDurationFromConfig(config)).toThrow( - `Failed to read duration from config, Error: Unknown property 'invalid'; expected one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`, - ); - }); - - it.each(propsOfHumanDuration)('rejects non-number %p', prop => { - const config = new ConfigReader({ - [prop]: 'value', - }); - expect(() => readDurationFromConfig(config)).toThrow( - `Failed to read duration from config, Error: Unable to convert config value for key '${prop}' in 'mock-config' to a number`, - ); - }); - - it.each(propsOfHumanDuration)('rejects non-number %p, for a subkey', prop => { - const config = new ConfigReader({ - sub: { - key: { - [prop]: 'value', - }, - }, - }); - expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow( - `Failed to read duration from config, Error: Unable to convert config value for key 'sub.key.${prop}' in 'mock-config' to a number`, ); }); }); diff --git a/packages/config/src/readDurationFromConfig.ts b/packages/config/src/readDurationFromConfig.ts index 2f59e9a976..e02651084e 100644 --- a/packages/config/src/readDurationFromConfig.ts +++ b/packages/config/src/readDurationFromConfig.ts @@ -15,8 +15,9 @@ */ import { Config } from '@backstage/config'; -import { InputError } from '@backstage/errors'; +import { InputError, stringifyError } from '@backstage/errors'; import { HumanDuration } from '@backstage/types'; +import ms from 'ms'; export const propsOfHumanDuration = [ 'years', @@ -30,18 +31,29 @@ export const propsOfHumanDuration = [ ]; /** - * Reads a duration from a config object. + * Reads a duration from config. * * @public * @remarks * + * The supported formats are: + * + * - A string in the format of '1d', '2 seconds' etc. as supported by the `ms` + * library. + * - A standard ISO formatted duration string, e.g. 'P2DT6H' or 'PT1M'. + * - An object with individual units (in plural) as keys, e.g. `{ days: 2, hours: 6 }`. + * + * The string forms are naturally only supported if the `options.key` argument + * is passed, since a `Config` argument always represents an object by its + * nature. + * * This does not support optionality; if you want to support optional durations, * you need to first check the presence of the target with `config.has(...)` and * then call this function. * * @param config - A configuration object - * @param key - If specified, read the duration from the given subkey - * under the config object + * @param key - If specified, read the duration from the given subkey under the + * config object * @returns A duration object */ export function readDurationFromConfig( @@ -49,6 +61,33 @@ export function readDurationFromConfig( options?: { key?: string; }, +): HumanDuration { + if (options?.key && typeof config.getOptional(options.key) === 'string') { + const value = config.getString(options.key).trim(); + try { + return value.startsWith('P') + ? parseIsoDuration(value) + : parseMsDuration(value); + } catch (error) { + throw new InputError( + `Invalid duration '${value}' in config at '${ + options.key + }', ${stringifyError(error)}`, + ); + } + } + + return parseObjectDuration(config, options); +} + +/** + * Parses the object form of durations. + */ +export function parseObjectDuration( + config: Config, + options?: { + key?: string; + }, ): HumanDuration { let root: Config; let found = false; @@ -94,8 +133,178 @@ export function readDurationFromConfig( if (options?.key) { prefix += ` at '${options.key}'`; } - throw new InputError(`${prefix}, ${error}`); + throw new Error(`${prefix}, ${error}`); } return result as HumanDuration; } + +/** + * Parses friendly string durations like '1d', '2 seconds' etc using the ms + * library. + */ +export function parseMsDuration(input: string): HumanDuration { + if (/^\d+$/.exec(input)) { + // We explicitly disallow the only-digits form of the ms library, because + // from a configuration perspective it's just confusing to even be able to + // specify that + throw new Error( + `The value cannot be a plain number; try adding a unit like 'ms' or 'seconds'`, + ); + } + + let milliseconds = ms(input); + if (!Number.isFinite(milliseconds)) { + throw new Error( + `Not a valid duration string, try a number followed by a unit such as '1d' or '2 seconds'`, + ); + } else if (milliseconds < 0) { + throw new Error('Negative durations are not allowed'); + } else if (milliseconds === 0) { + return { milliseconds: 0 }; + } + + // As used by the ms library + const s = 1000; + const m = s * 60; + const h = m * 60; + const d = h * 24; + const w = d * 7; + const y = d * 365.25; + + const result: HumanDuration = {}; + + if (milliseconds >= y) { + const years = Math.floor(milliseconds / y); + milliseconds -= years * y; + result.years = years; + } + + if (milliseconds >= w) { + const weeks = Math.floor(milliseconds / w); + milliseconds -= weeks * w; + result.weeks = weeks; + } + + if (milliseconds >= d) { + const days = Math.floor(milliseconds / d); + milliseconds -= days * d; + result.days = days; + } + + if (milliseconds >= h) { + const hours = Math.floor(milliseconds / h); + milliseconds -= hours * h; + result.hours = hours; + } + + if (milliseconds >= m) { + const minutes = Math.floor(milliseconds / m); + milliseconds -= minutes * m; + result.minutes = minutes; + } + + if (milliseconds >= s) { + const seconds = Math.floor(milliseconds / s); + milliseconds -= seconds * s; + result.seconds = seconds; + } + + if (milliseconds > 0) { + result.milliseconds = milliseconds; + } + + return result; +} + +/** + * Parses an ISO formatted duration string. + * + * Implementation taken from luxon's Duration.fromISO to not force that + * dependency on everyone. + */ +export function parseIsoDuration(input: string): HumanDuration { + const match = + /^-?P(?:(?:(-?\d{1,20}(?:\.\d{1,20})?)Y)?(?:(-?\d{1,20}(?:\.\d{1,20})?)M)?(?:(-?\d{1,20}(?:\.\d{1,20})?)W)?(?:(-?\d{1,20}(?:\.\d{1,20})?)D)?(?:T(?:(-?\d{1,20}(?:\.\d{1,20})?)H)?(?:(-?\d{1,20}(?:\.\d{1,20})?)M)?(?:(-?\d{1,20})(?:[.,](-?\d{1,20}))?S)?)?)$/.exec( + input, + ); + if (!match) { + throw new Error( + `Invalid ISO format, expected a value similar to 'P2DT6H' (2 days 6 hours) or 'PT1M' (1 minute)`, + ); + } + + const [ + s, + yearStr, + monthStr, + weekStr, + dayStr, + hourStr, + minuteStr, + secondStr, + millisecondsStr, + ] = match; + + const hasNegativePrefix = s[0] === '-'; + const negativeSeconds = !!secondStr && secondStr[0] === '-'; + + const maybeNegate = (num: number | undefined, force = false) => + num !== undefined && (force || (num && hasNegativePrefix)) ? -num : num; + + const parseFloating = (value: string) => { + if (typeof value === 'undefined' || value === null || value === '') { + return undefined; + } + return parseFloat(value); + }; + + const parseMillis = (fraction: string | undefined) => { + // Return undefined (instead of 0) in these cases, where fraction is not set + if ( + typeof fraction === 'undefined' || + fraction === null || + fraction === '' + ) { + return undefined; + } + const f = parseFloat(`0.${fraction}`) * 1000; + return Math.floor(f); + }; + + const years = maybeNegate(parseFloating(yearStr)); + const months = maybeNegate(parseFloating(monthStr)); + const weeks = maybeNegate(parseFloating(weekStr)); + const days = maybeNegate(parseFloating(dayStr)); + const hours = maybeNegate(parseFloating(hourStr)); + const minutes = maybeNegate(parseFloating(minuteStr)); + const seconds = maybeNegate(parseFloating(secondStr), secondStr === '-0'); + const milliseconds = maybeNegate( + parseMillis(millisecondsStr), + negativeSeconds, + ); + + if ( + years === undefined && + months === undefined && + weeks === undefined && + days === undefined && + hours === undefined && + minutes === undefined && + seconds === undefined && + milliseconds === undefined + ) { + throw new Error('Invalid ISO format, no values given'); + } + + return { + ...(years ? { years } : {}), + ...(months ? { months } : {}), + ...(weeks ? { weeks } : {}), + ...(days ? { days } : {}), + ...(hours ? { hours } : {}), + ...(minutes ? { minutes } : {}), + ...(seconds ? { seconds } : {}), + ...(milliseconds ? { milliseconds } : {}), + }; +} diff --git a/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts index 72dc599cea..b73c8d87a5 100644 --- a/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts +++ b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts @@ -42,7 +42,7 @@ export interface Config { /** * The backstage token expiration. */ - backstageTokenExpiration?: HumanDuration; + backstageTokenExpiration?: HumanDuration | string; }; }; } diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index a7ea18ed1d..d2725e99a5 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -143,7 +143,7 @@ export interface Config { /** * The backstage token expiration. */ - backstageTokenExpiration?: HumanDuration; + backstageTokenExpiration?: HumanDuration | string; }; /** * Additional app origins to allow for authenticating diff --git a/plugins/catalog-backend/config.d.ts b/plugins/catalog-backend/config.d.ts index 1ab107ef10..8dd5e50480 100644 --- a/plugins/catalog-backend/config.d.ts +++ b/plugins/catalog-backend/config.d.ts @@ -157,9 +157,9 @@ export interface Config { /** Defer stitching to be performed asynchronously */ mode: 'deferred'; /** Polling interval for tasks in seconds */ - pollingInterval?: HumanDuration; + pollingInterval?: HumanDuration | string; /** How long to wait for a stitch to complete before giving up in seconds */ - stitchTimeout?: HumanDuration; + stitchTimeout?: HumanDuration | string; }; /** diff --git a/plugins/catalog-backend/src/stitching/types.ts b/plugins/catalog-backend/src/stitching/types.ts index 7064dd1a1c..bbb6223968 100644 --- a/plugins/catalog-backend/src/stitching/types.ts +++ b/plugins/catalog-backend/src/stitching/types.ts @@ -70,15 +70,12 @@ export function stitchingStrategyFromConfig(config: Config): StitchingStrategy { const stitchTimeoutKey = 'catalog.stitchingStrategy.stitchTimeout'; const pollingInterval = config.has(pollingIntervalKey) - ? readDurationFromConfig(config, { - key: pollingIntervalKey, - }) + ? readDurationFromConfig(config, { key: pollingIntervalKey }) : { seconds: 1 }; const stitchTimeout = config.has(stitchTimeoutKey) - ? readDurationFromConfig(config, { - key: stitchTimeoutKey, - }) + ? readDurationFromConfig(config, { key: stitchTimeoutKey }) : { seconds: 60 }; + return { mode: 'deferred', pollingInterval: pollingInterval, diff --git a/plugins/events-backend-module-aws-sqs/config.d.ts b/plugins/events-backend-module-aws-sqs/config.d.ts index c2edfeae8e..f5a9457c82 100644 --- a/plugins/events-backend-module-aws-sqs/config.d.ts +++ b/plugins/events-backend-module-aws-sqs/config.d.ts @@ -48,12 +48,12 @@ export interface Config { /** * (Optional) Visibility timeout for messages in flight. */ - visibilityTimeout: HumanDuration; + visibilityTimeout: HumanDuration | string; /** * (Optional) Wait time when polling for available messages. * Default: 20 seconds. */ - waitTime: HumanDuration; + waitTime: HumanDuration | string; }; /** * (Optional) Timeout for the task execution which includes polling for messages @@ -62,12 +62,12 @@ export interface Config { * * Must be greater than `queue.waitTime` + `waitTimeAfterEmptyReceive`. */ - timeout: HumanDuration; + timeout: HumanDuration | string; /** * (Optional) Wait time before polling again if no message was received. * Default: 1 minute. */ - waitTimeAfterEmptyReceive: HumanDuration; + waitTimeAfterEmptyReceive: HumanDuration | string; }; }; }; diff --git a/plugins/events-backend-module-aws-sqs/src/publisher/config.ts b/plugins/events-backend-module-aws-sqs/src/publisher/config.ts index 7bad1e38d1..fb553fde40 100644 --- a/plugins/events-backend-module-aws-sqs/src/publisher/config.ts +++ b/plugins/events-backend-module-aws-sqs/src/publisher/config.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { Config } from '@backstage/config'; -import { HumanDuration, JsonObject } from '@backstage/types'; +import { Config, readDurationFromConfig } from '@backstage/config'; +import { HumanDuration } from '@backstage/types'; import { Duration } from 'luxon'; const CONFIG_PREFIX_MODULE = 'events.modules.awsSqs.'; @@ -39,7 +39,7 @@ function readOptionalHumanDuration( config: Config, key: string, ): HumanDuration | undefined { - return config.getOptional(key) as HumanDuration; + return config.has(key) ? readDurationFromConfig(config, { key }) : undefined; } function readOptionalDuration( diff --git a/plugins/notifications-backend-module-email/config.d.ts b/plugins/notifications-backend-module-email/config.d.ts index 04e6f7910f..eaed835485 100644 --- a/plugins/notifications-backend-module-email/config.d.ts +++ b/plugins/notifications-backend-module-email/config.d.ts @@ -99,7 +99,7 @@ export interface Config { /** * Throttle duration between email sending, defaults to 100ms */ - throttleInterval?: HumanDuration; + throttleInterval?: HumanDuration | string; /** * Configuration for broadcast notifications */ @@ -120,7 +120,7 @@ export interface Config { /** * Email cache TTL, defaults to 1 hour */ - ttl?: HumanDuration; + ttl?: HumanDuration | string; }; filter?: { /** diff --git a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts index 9337915ecc..c88dda2ac0 100644 --- a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { NotificationProcessor, NotificationSendOptions, @@ -75,14 +76,17 @@ export class NotificationsEmailProcessor implements NotificationProcessor { this.replyTo = emailProcessorConfig.getOptionalString('replyTo'); this.concurrencyLimit = emailProcessorConfig.getOptionalNumber('concurrencyLimit') ?? 2; - const throttleConfig = - emailProcessorConfig.getOptionalConfig('throttleInterval'); - this.throttleInterval = throttleConfig - ? durationToMilliseconds(readDurationFromConfig(throttleConfig)) + this.throttleInterval = emailProcessorConfig.has('throttleInterval') + ? durationToMilliseconds( + readDurationFromConfig(emailProcessorConfig, { + key: 'throttleInterval', + }), + ) : 100; - const cacheConfig = emailProcessorConfig.getOptionalConfig('cache.ttl'); - this.cacheTtl = cacheConfig - ? durationToMilliseconds(readDurationFromConfig(cacheConfig)) + this.cacheTtl = emailProcessorConfig.has('cache.ttl') + ? durationToMilliseconds( + readDurationFromConfig(emailProcessorConfig, { key: 'cache.ttl' }), + ) : 3_600_000; this.frontendBaseUrl = config.getString('app.baseUrl'); this.allowlistEmailAddresses = emailProcessorConfig.getOptionalStringArray( diff --git a/plugins/scaffolder-backend/config.d.ts b/plugins/scaffolder-backend/config.d.ts index f6b9522575..90040d68f1 100644 --- a/plugins/scaffolder-backend/config.d.ts +++ b/plugins/scaffolder-backend/config.d.ts @@ -26,6 +26,7 @@ export interface Config { name?: string; email?: string; }; + /** * The commit message used when new components are created. */ @@ -64,16 +65,15 @@ export interface Config { * be attempted to recover. * * If not specified, the default value is 5 seconds. - * */ - EXPERIMENTAL_recoverTasksTimeout?: HumanDuration; + EXPERIMENTAL_recoverTasksTimeout?: HumanDuration | string; /** * Makes sure to auto-expire and clean up things that time out or for other reasons should not be left lingering. * * By default, the frequency is every 5 minutes. */ - taskTimeoutJanitorFrequency?: HumanDuration; + taskTimeoutJanitorFrequency?: HumanDuration | string; /** * Sets the task's heartbeat timeout, when to consider a task to be staled. @@ -82,6 +82,6 @@ export interface Config { * * Default value is 24 hours. */ - taskTimeout?: HumanDuration; + taskTimeout?: HumanDuration | string; }; } diff --git a/yarn.lock b/yarn.lock index 80274b9284..f0409b6221 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4158,6 +4158,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" + ms: ^2.1.3 languageName: unknown linkType: soft