feat(edge/helm): add atomic and timeout options [BE-12481] (#1849)

This commit is contained in:
Oscar Zhou
2026-02-16 09:21:19 +13:00
committed by GitHub
parent d3544fb9b3
commit 376071e408
5 changed files with 157 additions and 27 deletions

View File

@@ -358,13 +358,15 @@ type (
// HelmConfig represents the Helm configuration for an edge stack
HelmConfig struct {
// Path to a Helm chart folder for Helm git deployments
ChartPath string `json:"HelmChartPath,omitempty" example:"charts/my-app"`
ChartPath string `json:"ChartPath,omitempty" example:"charts/my-app"`
// Array of paths to Helm values YAML files for Helm git deployments
ValuesFiles []string `json:"HelmValuesFiles,omitempty" example:"['values/prod.yaml', 'values/secrets.yaml']"`
ValuesFiles []string `json:"ValuesFiles,omitempty" example:"['values/prod.yaml', 'values/secrets.yaml']"`
// Helm chart version from Chart.yaml (read-only, extracted during Git sync)
Version string `json:"HelmVersion,omitempty" example:"1.2.3"`
Version string `json:"Version,omitempty" example:"1.2.3"`
// Enable automatic rollback on deployment failure (equivalent to helm --atomic flag)
Atomic bool `json:"HelmAtomic,omitempty" example:"true"`
Atomic bool `json:"Atomic" example:"true"`
// Timeout for Helm operations (equivalent to helm --timeout flag)
Timeout string `json:"Timeout,omitempty" example:"5m0s"`
}
EdgeStackStatusForEnv struct {

View File

@@ -3,10 +3,10 @@ import { describe, it, expect } from 'vitest';
import { intervalValidation } from './IntervalField';
describe('intervalValidation', () => {
it('rejects empty value with required message', async () => {
it('rejects empty value with invalid duration format message', async () => {
const schema = intervalValidation();
await expect(schema.validate('')).rejects.toThrow(
'This field is required.'
'Invalid duration format. Use formats like 5m, 1h30m, 7d, or 300s'
);
});
@@ -26,13 +26,13 @@ describe('intervalValidation', () => {
await expect(schema.validate('90s')).resolves.toBe('90s');
});
it('rejects sub-minute durations and invalid formats with minimum-interval message', async () => {
it('rejects sub-minute durations and invalid formats', async () => {
const schema = intervalValidation();
await expect(schema.validate('30s')).rejects.toThrow(
'Minimum interval is 1m'
);
await expect(schema.validate('abc')).rejects.toThrow(
'Minimum interval is 1m'
'Invalid duration format. Use formats like 5m, 1h30m, 7d, or 300s'
);
});
});

View File

@@ -1,6 +1,7 @@
import { string } from 'yup';
import parse from 'parse-duration';
import { durationValidation } from '@/react/utils/validation';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { useCaretPosition } from '@@/form-components/useCaretPosition';
@@ -42,22 +43,13 @@ export function IntervalField({
}
export function intervalValidation() {
return (
string()
.required('This field is required.')
// TODO: find a regex that validates time.Duration
// .matches(
// // validate golang time.Duration format
// // https://cs.opensource.google/go/go/+/master:src/time/format.go;l=1590
// /[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+/g,
// 'Please enter a valid time interval.'
// )
.test('minimumInterval', 'Minimum interval is 1m', (value) => {
if (!value) {
return false;
}
const minutes = parse(value, 'minute');
return minutes !== null && minutes >= 1;
})
);
return durationValidation(false) // Don't allow empty - field is required
.required('This field is required.')
.test('minimumInterval', 'Minimum interval is 1m', (value) => {
if (!value) {
return false;
}
const minutes = parse(value, 'minute');
return minutes !== null && minutes >= 1;
});
}

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { durationValidation } from './validation';
describe('durationValidation', () => {
describe('empty value handling', () => {
it('accepts empty when allowEmpty=true (default)', async () => {
const schema = durationValidation();
await expect(schema.validate('')).resolves.toBe('');
await expect(schema.validate(undefined)).resolves.toBeUndefined();
});
it('rejects empty when allowEmpty=false', async () => {
const schema = durationValidation(false);
await expect(schema.validate('')).rejects.toThrow(
'Invalid duration format'
);
});
});
describe('valid durations', () => {
const schema = durationValidation();
it('accepts all time units', async () => {
await expect(schema.validate('100ns')).resolves.toBe('100ns');
await expect(schema.validate('500us')).resolves.toBe('500us');
await expect(schema.validate('500µs')).resolves.toBe('500µs');
await expect(schema.validate('100ms')).resolves.toBe('100ms');
await expect(schema.validate('30s')).resolves.toBe('30s');
await expect(schema.validate('5m')).resolves.toBe('5m');
await expect(schema.validate('1h')).resolves.toBe('1h');
await expect(schema.validate('7d')).resolves.toBe('7d');
});
it('accepts decimal values', async () => {
await expect(schema.validate('1.5h')).resolves.toBe('1.5h');
await expect(schema.validate('0.5d')).resolves.toBe('0.5d');
});
it('accepts chained units', async () => {
await expect(schema.validate('1h30m')).resolves.toBe('1h30m');
await expect(schema.validate('2d12h')).resolves.toBe('2d12h');
await expect(schema.validate('2h45m30s')).resolves.toBe('2h45m30s');
});
it('accepts zero and large values', async () => {
await expect(schema.validate('0s')).resolves.toBe('0s');
await expect(schema.validate('365d')).resolves.toBe('365d');
});
});
describe('invalid durations', () => {
const schema = durationValidation();
it('rejects numbers without units', async () => {
await expect(schema.validate('300')).rejects.toThrow(
'Invalid duration format'
);
});
it('rejects invalid units', async () => {
await expect(schema.validate('5minutes')).rejects.toThrow(
'Invalid duration format'
);
await expect(schema.validate('7days')).rejects.toThrow(
'Invalid duration format'
);
});
it('rejects spaces and invalid formats', async () => {
await expect(schema.validate('5 m')).rejects.toThrow(
'Invalid duration format'
);
await expect(schema.validate('m')).rejects.toThrow(
'Invalid duration format'
);
await expect(schema.validate('-5m')).rejects.toThrow(
'Invalid duration format'
);
await expect(schema.validate('abc')).rejects.toThrow(
'Invalid duration format'
);
});
});
});

View File

@@ -0,0 +1,51 @@
import { string } from 'yup';
/**
* Validates a duration string format (e.g., "5m0s", "10m", "1h30m")
*
* Valid units:
* - ns (nanoseconds)
* - us/µs (microseconds)
* - ms (milliseconds)
* - s (seconds)
* - m (minutes)
* - h (hours)
* - d (days)
*
* Examples of valid durations:
* - "300s"
* - "5m"
* - "1h30m"
* - "2h45m30s"
* - "1.5h"
* - "7d"
* - "2d12h"
*
* @param allowEmpty - Whether to allow empty strings (default: true)
* @returns Yup string schema with duration validation
*/
export function durationValidation(allowEmpty = true) {
// Regex pattern that matches duration format
// Allows: number (with optional decimal) + unit, can be chained
// Units: ns, us, µs, ms, s, m, h, d
const durationRegex = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h|d))+$/;
return string().test(
'duration',
'Invalid duration format. Use formats like 5m, 1h30m, 7d, or 300s',
(value) => {
// Empty string is valid if allowed
if (allowEmpty && (!value || value.trim() === '')) {
return true;
}
// Empty string is invalid if not allowed
if (!allowEmpty && (!value || value.trim() === '')) {
return false;
}
// Check if the format matches duration pattern
return value ? durationRegex.test(value.trim()) : false;
}
);
}