Skip to content

Commit 6dbb8d0

Browse files
authored
feat(fleet): add support for duration variable type (#231027)
This commit introduces support for a `duration` variable type in Fleet package policies, aligning with the package-spec v3.5.0. This change adds `min_duration` and `max_duration` constraints, allowing packages to define acceptable ranges for duration values. This provides immediate validation and feedback for users in the UI and API, preventing invalid or out-of-range duration inputs. Relates elastic/package-spec#948
1 parent e425a96 commit 6dbb8d0

File tree

3 files changed

+364
-0
lines changed

3 files changed

+364
-0
lines changed

x-pack/platform/plugins/shared/fleet/common/services/validate_package_policy.test.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
validatePackagePolicy,
2121
validatePackagePolicyConfig,
2222
validationHasErrors,
23+
parseDuration,
2324
} from './validate_package_policy';
2425
import { AWS_PACKAGE, INVALID_AWS_POLICY, VALID_AWS_POLICY } from './fixtures/aws_package';
2526

@@ -1641,6 +1642,203 @@ describe('Fleet - validatePackagePolicyConfig', () => {
16411642
});
16421643
});
16431644

1645+
describe('Duration', () => {
1646+
it('should validate a valid duration format', () => {
1647+
const res = validatePackagePolicyConfig(
1648+
{
1649+
type: 'duration',
1650+
value: '1h30m45s',
1651+
},
1652+
{
1653+
name: 'timeout',
1654+
type: 'duration',
1655+
},
1656+
'timeout',
1657+
load
1658+
);
1659+
1660+
expect(res).toEqual(null);
1661+
});
1662+
1663+
it('should validate a valid duration with milliseconds', () => {
1664+
const res = validatePackagePolicyConfig(
1665+
{
1666+
type: 'duration',
1667+
value: '2h15m30s500ms',
1668+
},
1669+
{
1670+
name: 'timeout',
1671+
type: 'duration',
1672+
},
1673+
'timeout',
1674+
load
1675+
);
1676+
1677+
expect(res).toEqual(null);
1678+
});
1679+
1680+
it('should validate a valid duration with a single unit', () => {
1681+
const res = validatePackagePolicyConfig(
1682+
{
1683+
type: 'duration',
1684+
value: '60s',
1685+
},
1686+
{
1687+
name: 'timeout',
1688+
type: 'duration',
1689+
},
1690+
'timeout',
1691+
load
1692+
);
1693+
1694+
expect(res).toEqual(null);
1695+
});
1696+
1697+
it('should return error for invalid duration format', () => {
1698+
const res = validatePackagePolicyConfig(
1699+
{
1700+
type: 'duration',
1701+
value: 'invalid',
1702+
},
1703+
{
1704+
name: 'timeout',
1705+
type: 'duration',
1706+
},
1707+
'timeout',
1708+
load
1709+
);
1710+
1711+
expect(res).toContain('Invalid duration format. Expected format like "1h30m45s"');
1712+
});
1713+
1714+
it('should validate duration with min_duration constraint (valid)', () => {
1715+
const res = validatePackagePolicyConfig(
1716+
{
1717+
type: 'duration',
1718+
value: '1h30m',
1719+
},
1720+
{
1721+
name: 'timeout',
1722+
type: 'duration',
1723+
min_duration: '1h',
1724+
},
1725+
'timeout',
1726+
load
1727+
);
1728+
1729+
expect(res).toEqual(null);
1730+
});
1731+
1732+
it('should return error for duration below min_duration', () => {
1733+
const res = validatePackagePolicyConfig(
1734+
{
1735+
type: 'duration',
1736+
value: '30m',
1737+
},
1738+
{
1739+
name: 'timeout',
1740+
type: 'duration',
1741+
min_duration: '1h',
1742+
},
1743+
'timeout',
1744+
load
1745+
);
1746+
1747+
expect(res).toContain('Duration is below the minimum allowed value of 1h');
1748+
});
1749+
1750+
it('should validate duration with max_duration constraint (valid)', () => {
1751+
const res = validatePackagePolicyConfig(
1752+
{
1753+
type: 'duration',
1754+
value: '1h30m',
1755+
},
1756+
{
1757+
name: 'timeout',
1758+
type: 'duration',
1759+
max_duration: '2h',
1760+
},
1761+
'timeout',
1762+
load
1763+
);
1764+
1765+
expect(res).toEqual(null);
1766+
});
1767+
1768+
it('should return error for duration above max_duration', () => {
1769+
const res = validatePackagePolicyConfig(
1770+
{
1771+
type: 'duration',
1772+
value: '3h',
1773+
},
1774+
{
1775+
name: 'timeout',
1776+
type: 'duration',
1777+
max_duration: '2h',
1778+
},
1779+
'timeout',
1780+
load
1781+
);
1782+
1783+
expect(res).toContain('Duration is above the maximum allowed value of 2h');
1784+
});
1785+
1786+
it('should validate duration with both min and max constraints (valid)', () => {
1787+
const res = validatePackagePolicyConfig(
1788+
{
1789+
type: 'duration',
1790+
value: '1h30m',
1791+
},
1792+
{
1793+
name: 'timeout',
1794+
type: 'duration',
1795+
min_duration: '1h',
1796+
max_duration: '2h',
1797+
},
1798+
'timeout',
1799+
load
1800+
);
1801+
1802+
expect(res).toEqual(null);
1803+
});
1804+
1805+
it('should return error for invalid min_duration specification', () => {
1806+
const res = validatePackagePolicyConfig(
1807+
{
1808+
type: 'duration',
1809+
value: '1h30m',
1810+
},
1811+
{
1812+
name: 'timeout',
1813+
type: 'duration',
1814+
min_duration: 'invalid',
1815+
},
1816+
'timeout',
1817+
load
1818+
);
1819+
1820+
expect(res).toContain('Invalid min_duration specification');
1821+
});
1822+
1823+
it('should return error for invalid max_duration specification', () => {
1824+
const res = validatePackagePolicyConfig(
1825+
{
1826+
type: 'duration',
1827+
value: '1h30m',
1828+
},
1829+
{
1830+
name: 'timeout',
1831+
type: 'duration',
1832+
max_duration: 'invalid',
1833+
},
1834+
'timeout',
1835+
load
1836+
);
1837+
1838+
expect(res).toContain('Invalid max_duration specification');
1839+
});
1840+
});
1841+
16441842
describe('Dataset', () => {
16451843
const datasetError = 'Dataset contains invalid characters';
16461844

@@ -1990,3 +2188,28 @@ describe('Fleet - validatePackagePolicyConfig', () => {
19902188
});
19912189
});
19922190
});
2191+
2192+
describe('Fleet - parseDuration()', () => {
2193+
it('correctly calculates nanoseconds for a valid duration string', () => {
2194+
const result = parseDuration('1h30m45s500ms');
2195+
2196+
const expectedNs =
2197+
3_600_000_000_000 + // 1 hour
2198+
1_800_000_000_000 + // 30 minutes
2199+
45_000_000_000 + // 45 seconds
2200+
500_000_000; // 500 milliseconds
2201+
2202+
expect(result.isValid).toBe(true);
2203+
expect(result.valueNs).toBe(expectedNs);
2204+
expect(result.errors).toHaveLength(0);
2205+
});
2206+
2207+
it('produces an error when given an invalid duration', () => {
2208+
// Test with an invalid duration format
2209+
const result = parseDuration('invalid');
2210+
2211+
expect(result.isValid).toBe(false);
2212+
expect(result.errors).toHaveLength(1);
2213+
expect(result.errors[0]).toContain('Invalid duration format');
2214+
});
2215+
});

x-pack/platform/plugins/shared/fleet/common/services/validate_package_policy.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,92 @@ import { isValidDataset } from './is_valid_namespace';
3434

3535
type Errors = string[] | null;
3636

37+
interface DurationParseResult {
38+
isValid: boolean;
39+
valueNs: number; // in nanoseconds
40+
errors: string[];
41+
}
42+
43+
/**
44+
* Parses a duration string into nanoseconds and validates the format.
45+
* Valid time units are "ms", "s", "m", "h".
46+
*
47+
* @param durationStr - The duration string to parse (e.g., "1h30m45s")
48+
* @returns An object with parsing results
49+
*/
50+
export const parseDuration = (durationStr: string): DurationParseResult => {
51+
const result: DurationParseResult = {
52+
isValid: true,
53+
valueNs: 0,
54+
errors: [],
55+
};
56+
57+
if (!durationStr || typeof durationStr !== 'string' || durationStr.trim() === '') {
58+
result.isValid = false;
59+
result.errors.push(
60+
i18n.translate('xpack.fleet.packagePolicyValidation.emptyDurationErrorMessage', {
61+
defaultMessage: 'Duration cannot be empty',
62+
})
63+
);
64+
return result;
65+
}
66+
67+
// Regular expression to match duration components.
68+
const durationRegex = /(\d+)(ms|s|m|h)/g;
69+
const matches = [...durationStr.matchAll(durationRegex)];
70+
71+
if (matches.length === 0) {
72+
result.isValid = false;
73+
result.errors.push(
74+
i18n.translate('xpack.fleet.packagePolicyValidation.invalidDurationFormatErrorMessage', {
75+
defaultMessage: 'Invalid duration format. Expected format like "1h30m45s"',
76+
})
77+
);
78+
return result;
79+
}
80+
81+
// Check if the entire string is matched
82+
const fullMatch = matches.reduce((acc, match) => acc + match[0], '');
83+
if (fullMatch !== durationStr) {
84+
result.isValid = false;
85+
result.errors.push(
86+
i18n.translate('xpack.fleet.packagePolicyValidation.invalidDurationCharactersErrorMessage', {
87+
defaultMessage: 'Duration contains invalid characters',
88+
})
89+
);
90+
}
91+
92+
const NANOSECONDS_PER_MS = 1_000_000;
93+
const NANOSECONDS_PER_SECOND = 1_000_000_000;
94+
const NANOSECONDS_PER_MINUTE = 60 * NANOSECONDS_PER_SECOND;
95+
const NANOSECONDS_PER_HOUR = 60 * NANOSECONDS_PER_MINUTE;
96+
97+
// Calculate the total duration in nanoseconds
98+
let totalNs = 0;
99+
for (const match of matches) {
100+
const value = parseFloat(match[1]);
101+
const unit = match[2];
102+
103+
switch (unit) {
104+
case 'h':
105+
totalNs += value * NANOSECONDS_PER_HOUR;
106+
break;
107+
case 'm':
108+
totalNs += value * NANOSECONDS_PER_MINUTE;
109+
break;
110+
case 's':
111+
totalNs += value * NANOSECONDS_PER_SECOND;
112+
break;
113+
case 'ms':
114+
totalNs += value * NANOSECONDS_PER_MS;
115+
break;
116+
}
117+
}
118+
119+
result.valueNs = totalNs;
120+
return result;
121+
};
122+
37123
type ValidationEntry = Record<string, Errors>;
38124
interface ValidationRequiredVarsEntry {
39125
name: string;
@@ -517,6 +603,56 @@ export const validatePackagePolicyConfig = (
517603
}
518604
}
519605

606+
if (varDef.type === 'duration' && parsedValue !== undefined && !Array.isArray(parsedValue)) {
607+
const durationResult = parseDuration(parsedValue);
608+
609+
if (!durationResult.isValid) {
610+
errors.push(...durationResult.errors);
611+
} else {
612+
// Check min_duration if specified
613+
if (varDef.min_duration !== undefined) {
614+
const minDurationResult = parseDuration(varDef.min_duration as string);
615+
if (!minDurationResult.isValid) {
616+
errors.push(
617+
i18n.translate('xpack.fleet.packagePolicyValidation.invalidMinDurationErrorMessage', {
618+
defaultMessage: 'Invalid min_duration specification',
619+
})
620+
);
621+
} else if (durationResult.valueNs < minDurationResult.valueNs) {
622+
errors.push(
623+
i18n.translate('xpack.fleet.packagePolicyValidation.durationBelowMinErrorMessage', {
624+
defaultMessage: 'Duration is below the minimum allowed value of {minDuration}',
625+
values: {
626+
minDuration: varDef.min_duration,
627+
},
628+
})
629+
);
630+
}
631+
}
632+
633+
// Check max_duration if specified
634+
if (varDef.max_duration !== undefined) {
635+
const maxDurationResult = parseDuration(varDef.max_duration as string);
636+
if (!maxDurationResult.isValid) {
637+
errors.push(
638+
i18n.translate('xpack.fleet.packagePolicyValidation.invalidMaxDurationErrorMessage', {
639+
defaultMessage: 'Invalid max_duration specification',
640+
})
641+
);
642+
} else if (durationResult.valueNs > maxDurationResult.valueNs) {
643+
errors.push(
644+
i18n.translate('xpack.fleet.packagePolicyValidation.durationAboveMaxErrorMessage', {
645+
defaultMessage: 'Duration is above the maximum allowed value of {maxDuration}',
646+
values: {
647+
maxDuration: varDef.max_duration,
648+
},
649+
})
650+
);
651+
}
652+
}
653+
}
654+
}
655+
520656
if (varDef.type === 'select' && parsedValue !== undefined) {
521657
if (!varDef.options?.map((o) => o.value).includes(parsedValue)) {
522658
errors.push(

0 commit comments

Comments
 (0)