Compare commits

..

4 Commits

Author SHA1 Message Date
swapnil-signoz
5933b262b2 revert: restore frontend files to main state 2026-05-29 16:43:52 +05:30
swapnil-signoz
5b942d3b83 Merge branch 'main' into refactor/cloudintegration-service-details 2026-05-29 16:32:05 +05:30
swapnil-signoz
cf85659089 refactor: removing unused enrichDashboardIDs func 2026-05-28 00:06:14 +05:30
swapnil-signoz
528b5e2029 refactor: service response changes and dashboardID handling 2026-05-27 23:49:09 +05:30
23 changed files with 517 additions and 783 deletions

View File

@@ -309,6 +309,10 @@ components:
properties:
duration:
type: string
endTime:
format: date-time
nullable: true
type: string
repeatOn:
items:
$ref: '#/components/schemas/AlertmanagertypesRepeatOn'
@@ -316,7 +320,11 @@ components:
type: array
repeatType:
$ref: '#/components/schemas/AlertmanagertypesRepeatType'
startTime:
format: date-time
type: string
required:
- startTime
- duration
- repeatType
type: object
@@ -350,7 +358,6 @@ components:
type: string
required:
- timezone
- startTime
type: object
AuthtypesAttributeMapping:
properties:
@@ -863,14 +870,6 @@ components:
- timestampMillis
- data
type: object
CloudintegrationtypesAssets:
properties:
dashboards:
items:
$ref: '#/components/schemas/CloudintegrationtypesDashboard'
nullable: true
type: array
type: object
CloudintegrationtypesAzureAccountConfig:
properties:
deploymentRegion:
@@ -1018,17 +1017,6 @@ components:
- ingestionUrl
- ingestionKey
type: object
CloudintegrationtypesDashboard:
properties:
definition:
$ref: '#/components/schemas/DashboardtypesStorableDashboardData'
description:
type: string
id:
type: string
title:
type: string
type: object
CloudintegrationtypesDataCollected:
properties:
logs:
@@ -1202,7 +1190,7 @@ components:
CloudintegrationtypesService:
properties:
assets:
$ref: '#/components/schemas/CloudintegrationtypesAssets'
$ref: '#/components/schemas/CloudintegrationtypesServiceAssets'
cloudIntegrationService:
$ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService'
dataCollected:
@@ -1215,8 +1203,6 @@ components:
type: string
supportedSignals:
$ref: '#/components/schemas/CloudintegrationtypesSupportedSignals'
telemetryCollectionStrategy:
$ref: '#/components/schemas/CloudintegrationtypesTelemetryCollectionStrategy'
title:
type: string
required:
@@ -1227,9 +1213,18 @@ components:
- assets
- supportedSignals
- dataCollected
- telemetryCollectionStrategy
- cloudIntegrationService
type: object
CloudintegrationtypesServiceAssets:
properties:
dashboards:
items:
$ref: '#/components/schemas/CloudintegrationtypesServiceDashboard'
nullable: true
type: array
required:
- dashboards
type: object
CloudintegrationtypesServiceConfig:
properties:
aws:
@@ -1237,6 +1232,15 @@ components:
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
type: object
CloudintegrationtypesServiceDashboard:
properties:
description:
type: string
id:
type: string
title:
type: string
type: object
CloudintegrationtypesServiceID:
enum:
- alb
@@ -1278,13 +1282,6 @@ components:
metrics:
type: boolean
type: object
CloudintegrationtypesTelemetryCollectionStrategy:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy'
type: object
CloudintegrationtypesUpdatableAccount:
properties:
config:

View File

@@ -355,26 +355,32 @@ func (module *module) GetService(ctx context.Context, orgID valuer.UUID, service
var integrationService *cloudintegrationtypes.CloudIntegrationService
if !cloudIntegrationID.IsZero() {
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if storedService != nil {
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
if err != nil {
return nil, err
}
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
}
if err := module.enrichDashboardIDs(ctx, orgID, provider, serviceID, serviceDefinition); err != nil {
return nil, err
}
if cloudIntegrationID.IsZero() {
return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil
}
return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if storedService == nil {
return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil
}
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
if err != nil {
return nil, err
}
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
slugPrefix := cloudintegrationtypes.CloudIntegrationDashboardSlugPrefix(provider, serviceID)
integrationDashboards, err := module.store.ListIntegrationDashboardsBySlugPrefix(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slugPrefix)
if err != nil {
return nil, err
}
return cloudintegrationtypes.NewService(provider, serviceDefinition, integrationService, integrationDashboards), nil
}
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
@@ -583,20 +589,3 @@ func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UU
}
return nil
}
// enrichDashboardIDs replaces the raw dashboard name in each Dashboard.ID with the provisioned UUID.
// TODO: remove this hack and send idiomatic response to client.
func (module *module) enrichDashboardIDs(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
for i, d := range serviceDefinition.Assets.Dashboards {
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, serviceID, d.ID)
row, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
continue
}
return err
}
serviceDefinition.Assets.Dashboards[i].ID = row.DashboardID
}
return nil
}

View File

@@ -162,11 +162,21 @@ export interface AlertmanagertypesRecurrenceDTO {
* @type string
*/
duration: string;
/**
* @type string,null
* @format date-time
*/
endTime?: string | null;
/**
* @type array,null
*/
repeatOn?: AlertmanagertypesRepeatOnDTO[] | null;
repeatType: AlertmanagertypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: string;
}
export interface AlertmanagertypesScheduleDTO {
@@ -180,7 +190,7 @@ export interface AlertmanagertypesScheduleDTO {
* @type string
* @format date-time
*/
startTime: string;
startTime?: string;
/**
* @type string
*/

View File

@@ -152,11 +152,6 @@ export function PlannedDowntimeForm(
const saveHandler = useCallback(
async (values: PlannedDowntimeFormData) => {
const { startTime, timezone } = values;
if (!startTime || !timezone) {
// unreachable
return;
}
const data: AlertmanagertypesPostablePlannedMaintenanceDTO = {
alertIds:
values.alertRuleScope === 'all'
@@ -167,9 +162,9 @@ export function PlannedDowntimeForm(
name: values.name,
scope: values.scope,
schedule: {
startTime: startTime.format(),
startTime: values.startTime?.format(),
endTime: values.endTime?.format(),
timezone,
timezone: values.timezone!,
recurrence: values.recurrence,
},
};
@@ -206,17 +201,25 @@ export function PlannedDowntimeForm(
],
);
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
const rec = values.recurrence;
const recurrence =
rec && rec.repeatType !== recurrenceOptions.doesNotRepeat.value
? {
duration: `${rec.duration}${durationUnit}`,
repeatOn: rec.repeatOn,
repeatType: rec.repeatType,
}
: undefined;
const { recurrence } = values;
const recurrenceData =
!recurrence ||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
? undefined
: {
duration: recurrence.duration
? `${recurrence.duration}${durationUnit}`
: '',
startTime: values.startTime!.format(),
endTime: values.endTime?.format(),
repeatOn: recurrence.repeatOn,
repeatType: recurrence.repeatType,
};
await saveHandler({ ...values, recurrence });
await saveHandler({
...values,
recurrence: recurrenceData,
});
};
const handleFormData = (data: Partial<PlannedDowntimeFormData>): void => {
@@ -273,6 +276,9 @@ export function PlannedDowntimeForm(
const formattedInitialValues = useMemo((): PlannedDowntimeFormData => {
const { schedule } = initialValues;
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
const initialAlertIds = initialValues.alertIds || [];
return {
@@ -280,12 +286,8 @@ export function PlannedDowntimeForm(
alertRuleScope:
isEditMode && initialAlertIds.length === 0 ? 'all' : 'specific',
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
startTime: schedule?.startTime
? dayjs(schedule.startTime).tz(schedule.timezone)
: null,
endTime: schedule?.endTime
? dayjs(schedule.endTime).tz(schedule.timezone)
: null,
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
recurrence: {
...schedule?.recurrence,
repeatType: !isScheduleRecurring(schedule)

View File

@@ -142,6 +142,7 @@ export function CollapseListContent({
updated_by_name?: string;
alertOptions?: DefaultOptionType[];
}): JSX.Element {
const repeats = schedule?.recurrence;
const renderItems = (title: string, value: ReactNode): JSX.Element => (
<div className="render-item-collapse-list">
<Typography>{title}</Typography>
@@ -192,7 +193,10 @@ export function CollapseListContent({
'Timezone',
<Typography>{schedule?.timezone || '-'}</Typography>,
)}
{renderItems('Repeats', <Typography>{recurrenceInfo(schedule)}</Typography>)}
{renderItems(
'Repeats',
<Typography>{recurrenceInfo(repeats, schedule?.timezone)}</Typography>,
)}
{renderItems(
'Alerts silenced',
alertOptions?.length ? (

View File

@@ -6,7 +6,7 @@ import type {
DeleteDowntimeScheduleByIDPathParameters,
RenderErrorResponseDTO,
AlertmanagertypesPlannedMaintenanceDTO,
AlertmanagertypesScheduleDTO,
AlertmanagertypesRecurrenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import { AxiosError } from 'axios';
@@ -66,17 +66,14 @@ export const getAlertOptionsFromIds = (
);
export const recurrenceInfo = (
schedule?: AlertmanagertypesScheduleDTO | null,
recurrence?: AlertmanagertypesRecurrenceDTO | null,
timezone?: string,
): string => {
if (!schedule) {
return 'No';
}
const { startTime, endTime, timezone, recurrence } = schedule;
if (!recurrence) {
return 'No';
}
const { duration, repeatOn, repeatType } = recurrence;
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
const formattedStartTime = startTime
? formatDateTime(startTime, timezone)
@@ -98,7 +95,7 @@ export const defaultInitialValues: Partial<AlertmanagertypesPlannedMaintenanceDT
timezone: '',
endTime: undefined,
recurrence: undefined,
startTime: '',
startTime: undefined,
},
alertIds: [],
createdAt: undefined,

View File

@@ -11,7 +11,7 @@ export const buildSchedule = (
schedule: Partial<AlertmanagertypesScheduleDTO>,
): AlertmanagertypesScheduleDTO => ({
timezone: schedule?.timezone ?? '',
startTime: schedule?.startTime ?? '',
startTime: schedule?.startTime,
endTime: schedule?.endTime,
recurrence: schedule?.recurrence,
});

View File

@@ -13,6 +13,7 @@ import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
@@ -29,7 +30,7 @@ import (
func newTestMaintenanceStore() alertmanagertypes.MaintenanceStore {
ss := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
return sqlalertmanagerstore.NewMaintenanceStore(ss)
return sqlalertmanagerstore.NewMaintenanceStore(ss, factorytest.NewSettings())
}
func TestServerSetConfigAndStop(t *testing.T) {

View File

@@ -2,9 +2,11 @@ package sqlalertmanagerstore
import (
"context"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
@@ -14,11 +16,13 @@ import (
type maintenance struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
func NewMaintenanceStore(store sqlstore.SQLStore) alertmanagertypes.MaintenanceStore {
func NewMaintenanceStore(store sqlstore.SQLStore, providerSettings factory.ProviderSettings) alertmanagertypes.MaintenanceStore {
return &maintenance{
sqlstore: store,
logger: providerSettings.Logger,
}
}
@@ -37,7 +41,11 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
gettablePlannedMaintenance := make([]*alertmanagertypes.PlannedMaintenance, 0)
for _, gettableMaintenancesRule := range gettableMaintenancesRules {
gettablePlannedMaintenance = append(gettablePlannedMaintenance, gettableMaintenancesRule.ToPlannedMaintenance())
m := gettableMaintenancesRule.ToPlannedMaintenance()
gettablePlannedMaintenance = append(gettablePlannedMaintenance, m)
if m.HasScheduleRecurrenceBoundsMismatch() {
r.logger.WarnContext(ctx, "planned_downtime_recurrence_schedule_mismatch", slog.String("maintenance_id", m.ID.StringValue()))
}
}
return gettablePlannedMaintenance, nil

View File

@@ -440,4 +440,3 @@ func (handler *handler) AgentCheckIn(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableAgentCheckIn(provider, resp))
}

View File

@@ -46,7 +46,7 @@ func NewFactory(
) factory.ProviderFactory[ruler.Ruler, ruler.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config ruler.Config) (ruler.Ruler, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore, providerSettings)
managerOpts := &rules.ManagerOptions{
TelemetryStore: telemetryStore,

View File

@@ -41,7 +41,7 @@ func TestNewHandlers(t *testing.T) {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore, providerSettings)
alertmanager, err := signozalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
require.NoError(t, err)
tokenizer := tokenizertest.NewMockTokenizer(t)

View File

@@ -42,7 +42,7 @@ func TestNewModules(t *testing.T) {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore, providerSettings)
alertmanager, err := signozalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
require.NoError(t, err)
tokenizer := tokenizertest.NewMockTokenizer(t)

View File

@@ -210,8 +210,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateInstalledIntegrationDashboardsFactory(sqlstore),
sqlmigration.NewAddDashboardNameFactory(sqlstore, sqlschema),
sqlmigration.NewFixChangelogOperationTypeFactory(sqlstore, sqlschema),
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
sqlmigration.NewMigrateRecurrenceBoundsFactory(sqlstore),
)
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
@@ -63,7 +64,7 @@ func TestNewProviderFactories(t *testing.T) {
store := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
orgGetter := implorganization.NewGetter(implorganization.NewStore(store), nil)
notificationManager := nfmanagertest.NewMock()
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(store)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(store, factorytest.NewSettings())
NewAlertmanagerProviderFactories(store, orgGetter, notificationManager, maintenanceStore)
})

View File

@@ -377,7 +377,7 @@ func New(
return nil, err
}
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore, providerSettings)
// Initialize alertmanager from the available alertmanager provider factories
alertmanager, err := factory.NewProviderFromNamedMap(

View File

@@ -1,130 +0,0 @@
package sqlmigration
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type cloudIntegrationRemoveCascadeDelete struct {
sqlschema sqlschema.SQLSchema
}
type ciServiceRow struct {
bun.BaseModel `bun:"table:cloud_integration_service"`
ID string `bun:"id"`
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
Type string `bun:"type"`
Config string `bun:"config"`
CloudIntegrationID string `bun:"cloud_integration_id"`
}
func NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("ci_remove_cascade_delete"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &cloudIntegrationRemoveCascadeDelete{sqlschema: sqlschema}, nil
},
)
}
func (migration *cloudIntegrationRemoveCascadeDelete) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *cloudIntegrationRemoveCascadeDelete) Up(ctx context.Context, db *bun.DB) error {
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, false); err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
// get all existing rows
var rows []*ciServiceRow
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
// get existing table
table, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("cloud_integration_service"))
if err != nil {
return err
}
// drop the existing table
for _, sql := range migration.sqlschema.Operator().DropTable(table) {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
// create new table without cascade delete FK
newTable := &sqlschema.Table{
Name: sqlschema.TableName("cloud_integration_service"),
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "type", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "config", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "cloud_integration_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("cloud_integration_id"),
ReferencedTableName: sqlschema.TableName("cloud_integration"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
}
// create table
for _, sql := range migration.sqlschema.Operator().CreateTable(newTable) {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
// add back existing rows
if len(rows) > 0 {
if _, err := tx.NewInsert().Model(&rows).Exec(ctx); err != nil {
return err
}
}
// create existing unique index on (cloud_integration_id, type)
indexSQLs := migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
TableName: "cloud_integration_service",
ColumnNames: []sqlschema.ColumnName{"cloud_integration_id", "type"},
})
for _, sql := range indexSQLs {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return migration.sqlschema.ToggleFKEnforcement(ctx, db, true)
}
func (migration *cloudIntegrationRemoveCascadeDelete) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -1,122 +0,0 @@
package sqlmigration
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type migrateRecurrenceBounds struct {
sqlstore sqlstore.SQLStore
}
type plannedMaintenanceScheduleRow struct {
bun.BaseModel `bun:"table:planned_maintenance"`
ID string `bun:"id"`
Schedule string `bun:"schedule"`
}
func NewMigrateRecurrenceBoundsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("migrate_recurrence_bounds"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateRecurrenceBounds{sqlstore: sqlstore}, nil
},
)
}
func (migration *migrateRecurrenceBounds) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
// Up moves the start/end bounds of a recurring planned maintenance from the
// nested recurrence object up to the schedule level. Until now both the
// schedule and its recurrence carried their own startTime/endTime, with the
// recurrence values taking precedence when a recurrence was present. The
// recurrence fields are being dropped, so the recurrence bounds (the source of
// truth for recurring maintenances) are promoted to the schedule before the
// struct loses those fields.
//
// We deliberately operate on the raw JSON instead of the Recurrence struct:
// that struct loses its StartTime/EndTime fields in the same change set, so it
// can no longer read the values this migration needs to move.
func (migration *migrateRecurrenceBounds) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
rows := make([]*plannedMaintenanceScheduleRow, 0)
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
for _, row := range rows {
schedule := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(row.Schedule), &schedule); err != nil {
return err
}
recurrenceRaw, ok := schedule["recurrence"]
if !ok || string(recurrenceRaw) == "null" {
continue
}
recurrence := make(map[string]json.RawMessage)
if err := json.Unmarshal(recurrenceRaw, &recurrence); err != nil {
return err
}
// Promote the recurrence bounds (source of truth) to the schedule
// level, then drop them from the recurrence.
if startTime, ok := recurrence["startTime"]; ok {
schedule["startTime"] = startTime
delete(recurrence, "startTime")
}
if endTime, ok := recurrence["endTime"]; ok && string(endTime) != "null" {
schedule["endTime"] = endTime
} else {
// The recurrence had no end time, so the schedule must not carry
// a stale one duplicated by the UI.
delete(schedule, "endTime")
}
delete(recurrence, "endTime")
newRecurrence, err := json.Marshal(recurrence)
if err != nil {
return err
}
schedule["recurrence"] = newRecurrence
newSchedule, err := json.Marshal(schedule)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model((*plannedMaintenanceScheduleRow)(nil)).
Set("schedule = ?", string(newSchedule)).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *migrateRecurrenceBounds) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -3,7 +3,6 @@ package alertmanagertypes
import (
"context"
"encoding/json"
"slices"
"time"
"github.com/expr-lang/expr"
@@ -108,12 +107,10 @@ func (p *PostablePlannedMaintenance) Validate() error {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "invalid timezone in the payload")
}
if p.Schedule.StartTime.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing start time in the payload")
}
if !p.Schedule.EndTime.IsZero() && p.Schedule.StartTime.After(p.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
if !p.Schedule.StartTime.IsZero() && !p.Schedule.EndTime.IsZero() {
if p.Schedule.StartTime.After(p.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
}
}
if p.Schedule.Recurrence != nil {
@@ -123,6 +120,9 @@ func (p *PostablePlannedMaintenance) Validate() error {
if p.Schedule.Recurrence.Duration.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing duration in the payload")
}
if p.Schedule.Recurrence.EndTime != nil && p.Schedule.Recurrence.EndTime.Before(p.Schedule.Recurrence.StartTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "end time cannot be before start time")
}
}
if p.Scope != "" {
if _, err := expr.Compile(p.Scope, expr.AllowUndefinedVariables(), expr.AsBool()); err != nil {
@@ -148,84 +148,134 @@ type PlannedMaintenanceWithRules struct {
Rules []*StorablePlannedMaintenanceRule `bun:"rel:has-many,join:id=planned_maintenance_id"`
}
// AppliesTo reports whether this maintenance applies to the given rule.
// An empty RuleIDs set means the maintenance applies to all rules.
func (m *PlannedMaintenance) AppliesTo(ruleID string) bool {
return len(m.RuleIDs) == 0 || slices.Contains(m.RuleIDs, ruleID)
// HasScheduleRecurrenceBoundsMismatch reports whether a recurring maintenance
// has different start/end bounds in Schedule and Schedule.Recurrence.
//
// This is used to detect if there are any entries with recurrence that don't
// have the same timestamps stored at the schedule-level.
// UI payloads duplicated those values in both places, but direct API users may
// have stored bounds that are missing from, or different than, the schedule-level bounds.
// We need to observe these before we can safely drop Recurrence.StartTime and
// Recurrence.EndTime.
func (m *PlannedMaintenance) HasScheduleRecurrenceBoundsMismatch() bool {
recurrence := m.Schedule.Recurrence
if recurrence == nil {
return false
}
return !recurrence.StartTime.Equal(m.Schedule.StartTime) ||
(recurrence.EndTime == nil && !m.Schedule.EndTime.IsZero()) ||
(recurrence.EndTime != nil && !recurrence.EndTime.Equal(m.Schedule.EndTime))
}
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time, lset model.LabelSet) (bool, error) {
if !m.AppliesTo(ruleID) || !m.IsActive(now) {
// Check if the alert ID is in the maintenance window
found := false
if len(m.RuleIDs) > 0 {
for _, alertID := range m.RuleIDs {
if alertID == ruleID {
found = true
break
}
}
}
// If no alert ids, then skip all alerts
if len(m.RuleIDs) == 0 {
found = true
}
if !found {
return false, nil
}
if !m.IsActive(now) {
return false, nil
}
if m.Scope != "" {
skip, err := EvalScopeExpression(m.Scope, lset)
if err != nil || !skip {
result, err := EvalScopeExpression(m.Scope, lset)
if err != nil {
return false, err
}
if !result {
return false, nil
}
}
return true, nil
}
// IsActive reports whether [now] falls inside the maintenance window's schedule.
func (m *PlannedMaintenance) IsActive(now time.Time) bool {
// Check if maintenance window has not started yet
if now.Before(m.Schedule.StartTime) {
return false
}
// Check if maintenance window has expired
if !m.Schedule.EndTime.IsZero() && now.After(m.Schedule.EndTime) {
return false
}
// Fixed schedule
if m.Schedule.Recurrence == nil {
return true
}
// If alert is found, we check if it should be skipped based on the schedule
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return false
}
now = now.In(loc)
switch m.Schedule.Recurrence.RepeatType {
case RepeatTypeDaily:
return m.checkDaily(now, loc)
case RepeatTypeWeekly:
return m.checkWeekly(now, loc)
case RepeatTypeMonthly:
return m.checkMonthly(now, loc)
default:
return false
startTime := m.Schedule.StartTime
endTime := m.Schedule.EndTime
recurrence := m.Schedule.Recurrence
// fixed schedule — only when no recurrence is configured.
// When recurrence is set, the recurring check below handles everything;
// falling through here would cause the window to match the absolute
// StartTimeEndTime range instead of the daily/weekly/monthly pattern.
if recurrence == nil && !startTime.IsZero() && !endTime.IsZero() {
if now.Equal(startTime) || now.Equal(endTime) ||
(now.After(startTime) && now.Before(endTime)) {
return true
}
}
// recurring schedule
if recurrence != nil {
// Make sure the recurrence has started
if now.Before(recurrence.StartTime) {
return false
}
// Check if recurrence has expired
if recurrence.EndTime != nil {
if !recurrence.EndTime.IsZero() && now.After(*recurrence.EndTime) {
return false
}
}
currentTime := now.In(loc)
switch recurrence.RepeatType {
case RepeatTypeDaily:
return m.checkDaily(currentTime, recurrence, loc)
case RepeatTypeWeekly:
return m.checkWeekly(currentTime, recurrence, loc)
case RepeatTypeMonthly:
return m.checkMonthly(currentTime, recurrence, loc)
}
}
return false
}
// checkDaily rebases the recurrence start to today (or yesterday if needed)
// and returns true if currentTime is within [candidate, candidate+Duration].
func (m *PlannedMaintenance) checkDaily(currentTime time.Time, loc *time.Location) bool {
func (m *PlannedMaintenance) checkDaily(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
m.Schedule.StartTime.Hour(), m.Schedule.StartTime.Minute(), 0, 0,
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
loc,
)
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, 0, -1)
}
return currentTime.Sub(candidate) <= m.Schedule.Recurrence.Duration.Duration()
return currentTime.Sub(candidate) <= rec.Duration.Duration()
}
// checkWeekly finds the most recent allowed occurrence by rebasing the recurrences
// time-of-day onto the allowed weekday. It does this for each allowed day and returns true
// if the current time falls within the candidate window.
func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, loc *time.Location) bool {
rec := m.Schedule.Recurrence
func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
// If no days specified, treat as every day (like daily).
if len(rec.RepeatOn) == 0 {
return m.checkDaily(currentTime, loc)
return m.checkDaily(currentTime, rec, loc)
}
for _, day := range rec.RepeatOn {
@@ -238,7 +288,7 @@ func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, loc *time.Locati
// Build a candidate occurrence by rebasing today's date to the allowed weekday.
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
m.Schedule.StartTime.Hour(), m.Schedule.StartTime.Minute(), 0, 0,
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
loc,
).AddDate(0, 0, delta)
// If the candidate is in the future, subtract 7 days.
@@ -254,9 +304,8 @@ func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, loc *time.Locati
// checkMonthly rebases the candidate occurrence using the recurrence's day-of-month.
// If the candidate for the current month is in the future, it uses the previous month.
func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, loc *time.Location) bool {
startTime := m.Schedule.StartTime
refDay := startTime.Day()
func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
refDay := rec.StartTime.Day()
year, month, _ := currentTime.Date()
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
day := refDay
@@ -264,7 +313,7 @@ func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, loc *time.Locat
day = lastDay
}
candidate := time.Date(year, month, day,
startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(),
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
if candidate.After(currentTime) {
@@ -274,30 +323,33 @@ func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, loc *time.Locat
lastDayPrev := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()
if refDay > lastDayPrev {
candidate = time.Date(y, m, lastDayPrev,
startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(),
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
} else {
candidate = time.Date(y, m, refDay,
startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(),
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
}
}
return currentTime.Sub(candidate) <= m.Schedule.Recurrence.Duration.Duration()
return currentTime.Sub(candidate) <= rec.Duration.Duration()
}
func (m *PlannedMaintenance) IsUpcoming() bool {
now := time.Now()
if m.IsRecurring() {
// Note: this would return true even if the maintenance is active.
// This isn't an issue right now because the only usage happens after the `IsActive` check.
return m.Schedule.EndTime.IsZero() || now.Before(m.Schedule.EndTime)
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return false
}
now := time.Now().In(loc)
// Fixed schedule
return now.Before(m.Schedule.StartTime)
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
return now.Before(m.Schedule.StartTime)
}
if m.Schedule.Recurrence != nil {
return now.Before(m.Schedule.Recurrence.StartTime)
}
return false
}
func (m *PlannedMaintenance) IsRecurring() bool {
@@ -315,16 +367,15 @@ func (m *PlannedMaintenance) Validate() error {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing timezone in the payload")
}
if _, err := time.LoadLocation(m.Schedule.Timezone); err != nil {
_, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "invalid timezone in the payload")
}
if m.Schedule.StartTime.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing start time in the payload")
}
if !m.Schedule.EndTime.IsZero() && m.Schedule.StartTime.After(m.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
if m.Schedule.StartTime.After(m.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
}
}
if m.Schedule.Recurrence != nil {
@@ -334,31 +385,28 @@ func (m *PlannedMaintenance) Validate() error {
if m.Schedule.Recurrence.Duration.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing duration in the payload")
}
}
if m.Scope != "" {
if _, err := expr.Compile(m.Scope, expr.AllowUndefinedVariables(), expr.AsBool()); err != nil {
err := errors.Newf(
errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload,
"invalid scope: %s", err.Error(),
)
return err.WithUrl(scopeDocUrl)
if m.Schedule.Recurrence.EndTime != nil && m.Schedule.Recurrence.EndTime.Before(m.Schedule.Recurrence.StartTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "end time cannot be before start time")
}
}
return nil
}
func (m PlannedMaintenance) MarshalJSON() ([]byte, error) {
now := time.Now().In(time.FixedZone(m.Schedule.Timezone, 0))
var status MaintenanceStatus
if m.IsActive(time.Now()) {
if m.IsActive(now) {
status = MaintenanceStatusActive
} else if m.IsUpcoming() {
status = MaintenanceStatusUpcoming
} else {
status = MaintenanceStatusExpired
}
var kind MaintenanceKind
kind := MaintenanceKindFixed
if m.Schedule.Recurrence != nil {
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() && m.Schedule.EndTime.After(m.Schedule.StartTime) {
kind = MaintenanceKindFixed
} else {
kind = MaintenanceKindRecurring
}

View File

@@ -8,6 +8,11 @@ import (
"github.com/prometheus/common/model"
)
// Helper function to create a time pointer.
func timePtr(t time.Time) *time.Time {
return &t
}
func TestShouldSkipMaintenance(t *testing.T) {
cases := []struct {
name string
@@ -19,9 +24,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "only-on-saturday",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "Europe/London",
StartTime: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
Timezone: "Europe/London",
Recurrence: &Recurrence{
StartTime: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("24h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday, RepeatOnTuesday, RepeatOnWednesday, RepeatOnThursday, RepeatOnFriday, RepeatOnSunday},
@@ -36,10 +41,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-across-midnight-previous-day",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
},
@@ -53,10 +58,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-across-midnight-previous-day",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
},
@@ -70,10 +75,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-across-midnight-previous-day",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("52h"), // Until Thursday 02:00
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Duration: valuer.MustParseTextDuration("52h"), // Until Thursday 02:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
},
@@ -87,10 +92,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-across-midnight-previous-day-not-in-repeaton",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 2, 22, 0, 0, 0, time.UTC), // Tuesday 22:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("4h"), // Until Wednesday 02:00
StartTime: time.Date(2024, 4, 2, 22, 0, 0, 0, time.UTC), // Tuesday 22:00
Duration: valuer.MustParseTextDuration("4h"), // Until Wednesday 02:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnTuesday}, // Only Tuesday
},
@@ -104,10 +109,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "daily-maintenance-across-midnight",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC), // 23:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"), // Until 01:00 next day
StartTime: time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC), // 23:00
Duration: valuer.MustParseTextDuration("2h"), // Until 01:00 next day
RepeatType: RepeatTypeDaily,
},
},
@@ -120,9 +125,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "at-start-time-boundary",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
@@ -136,9 +141,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "at-end-time-boundary",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
@@ -152,9 +157,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-multi-day-duration",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("72h"), // 3 days
RepeatType: RepeatTypeMonthly,
},
@@ -168,9 +173,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-multi-day-duration",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("72h"), // 3 days
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnSunday},
@@ -185,9 +190,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-crosses-to-next-month",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("48h"), // 2 days, crosses to Feb 1
RepeatType: RepeatTypeMonthly,
},
@@ -201,9 +206,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "timezone-offset-test",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "America/New_York", // UTC-5 or UTC-4 depending on DST
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.FixedZone("America/New_York", -5*3600)),
Timezone: "America/New_York", // UTC-5 or UTC-4 depending on DST
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.FixedZone("America/New_York", -5*3600)),
Duration: valuer.MustParseTextDuration("4h"),
RepeatType: RepeatTypeDaily,
},
@@ -217,9 +222,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "daily-maintenance-time-outside-window",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
@@ -233,10 +238,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring-maintenance-with-past-end-date",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
EndTime: time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
EndTime: timePtr(time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC)),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
@@ -250,10 +255,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-spans-month-end",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 3, 31, 22, 0, 0, 0, time.UTC), // March 31, 22:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("6h"), // Until April 1, 04:00
StartTime: time.Date(2024, 3, 31, 22, 0, 0, 0, time.UTC), // March 31, 22:00
Duration: valuer.MustParseTextDuration("6h"), // Until April 1, 04:00
RepeatType: RepeatTypeMonthly,
},
},
@@ -266,9 +271,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-empty-repeaton",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{}, // Empty - should apply to all days
@@ -283,9 +288,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-february-fewer-days",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -298,9 +303,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "daily-maintenance-crosses-midnight",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 23, 30, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 23, 30, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("1h"), // Crosses to 00:30 next day
RepeatType: RepeatTypeDaily,
},
@@ -313,9 +318,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-crosses-month-end",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -328,9 +333,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-crosses-month-end-and-duration-is-2-days",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("48h"), // 2 days duration
RepeatType: RepeatTypeMonthly,
},
@@ -343,10 +348,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-maintenance-crosses-midnight",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 23, 0, 0, 0, time.UTC), // Monday 23:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"), // Until Tuesday 01:00
StartTime: time.Date(2024, 4, 1, 23, 0, 0, 0, time.UTC), // Monday 23:00
Duration: valuer.MustParseTextDuration("2h"), // Until Tuesday 01:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
},
@@ -359,9 +364,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-crosses-month-end-and-duration-is-2-days",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -374,9 +379,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "daily-maintenance-crosses-midnight",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("4h"), // Until 02:00 next day
RepeatType: RepeatTypeDaily,
},
@@ -389,9 +394,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-crosses-month-end-and-duration-is-2-hours",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -440,9 +445,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat sunday, saturday, weekly for 24 hours, in Us/Eastern timezone",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "US/Eastern",
StartTime: time.Date(2025, 3, 29, 20, 0, 0, 0, time.FixedZone("US/Eastern", -4*3600)),
Timezone: "US/Eastern",
Recurrence: &Recurrence{
StartTime: time.Date(2025, 3, 29, 20, 0, 0, 0, time.FixedZone("US/Eastern", -4*3600)),
Duration: valuer.MustParseTextDuration("24h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnSunday, RepeatOnSaturday},
@@ -453,57 +458,57 @@ func TestShouldSkipMaintenance(t *testing.T) {
skip: true,
},
{
name: "recurring maintenance, repeat daily from 12:00 to 14:00, ts < start",
name: "recurring maintenance, repeat daily from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
ts: time.Date(2024, 1, 10, 11, 0, 0, 0, time.UTC),
skip: false,
},
{
name: "recurring maintenance, repeat daily from 12:00 to 14:00, start <= ts <= end",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
ts: time.Date(2024, 1, 10, 13, 0, 0, 0, time.UTC),
ts: time.Date(2024, 1, 1, 12, 10, 0, 0, time.UTC),
skip: true,
},
{
name: "recurring maintenance, repeat daily from 12:00 to 14:00, start > end",
name: "recurring maintenance, repeat daily from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
ts: time.Date(2024, 1, 10, 15, 0, 0, 0, time.UTC),
skip: false,
ts: time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC),
skip: true,
},
{
name: "recurring maintenance, repeat daily from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
ts: time.Date(2024, 4, 1, 12, 10, 0, 0, time.UTC),
skip: true,
},
{
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -517,9 +522,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -533,9 +538,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -549,9 +554,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -565,9 +570,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -581,9 +586,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -596,9 +601,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -611,9 +616,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -622,6 +627,45 @@ func TestShouldSkipMaintenance(t *testing.T) {
ts: time.Date(2024, 5, 4, 12, 10, 0, 0, time.UTC),
skip: true,
},
// The recurrence should govern, when set. Not the fixed range.
{
name: "recurring-daily-with-fixed-times-outside-daily-window",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
// These fixed fields should be ignored when Recurrence is set.
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC), // daily at 14:00
Duration: valuer.MustParseTextDuration("2h"), // until 16:00
RepeatType: RepeatTypeDaily,
},
},
},
// 2026-04-15 11:00 is inside the fixed range but outside the daily 14:00-16:00 window.
ts: time.Date(2026, 4, 15, 11, 0, 0, 0, time.UTC),
skip: false,
},
{
name: "recurring-daily-with-fixed-times-inside-daily-window",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC),
EndTime: timePtr(time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC)),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
// 15:00 is inside the daily 14:00-16:00 window. Should skip.
ts: time.Date(2026, 4, 15, 15, 0, 0, 0, time.UTC),
skip: true,
},
}
for idx, c := range cases {
@@ -635,211 +679,13 @@ func TestShouldSkipMaintenance(t *testing.T) {
}
}
func TestIsActiveFixedSchedule(t *testing.T) {
start := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
end := time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC)
cases := []struct {
name string
startTime time.Time
endTime time.Time
now time.Time
active bool
}{
{
name: "no end, t < start",
startTime: start,
now: start.Add(-time.Hour),
active: false,
},
{
name: "no end, start == t",
startTime: start,
now: start,
active: true,
},
{
// A fixed schedule with no end time stays active indefinitely.
name: "no end, start << t",
startTime: start,
now: start.AddDate(10, 0, 0),
active: true,
},
{
name: "with end, start < t < end",
startTime: start,
endTime: end,
now: start.Add(24 * time.Hour),
active: true,
},
{
name: "with end, t == end",
startTime: start,
endTime: end,
now: end,
active: true,
},
{
name: "with end, end < t",
startTime: start,
endTime: end,
now: end.Add(time.Hour),
active: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
m := &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: c.startTime,
EndTime: c.endTime,
},
}
if got := m.IsActive(c.now); got != c.active {
t.Errorf("IsActive() = %v, want %v", got, c.active)
}
})
}
}
func TestIsActiveRecurringSchedule(t *testing.T) {
// Daily window 12:00-14:00, starting 2024-01-01 (a Monday).
start := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
daily := &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
}
cases := []struct {
name string
startTime time.Time
endTime time.Time
recurrence *Recurrence
now time.Time
active bool
}{
{
// The recurrence has not begun yet, even though the time-of-day matches.
name: "daily: t < recurrence start",
startTime: start,
recurrence: daily,
now: time.Date(2023, 12, 31, 13, 0, 0, 0, time.UTC),
active: false,
},
{
name: "daily: no end, within window",
startTime: start,
recurrence: daily,
now: time.Date(2024, 6, 15, 13, 0, 0, 0, time.UTC),
active: true,
},
{
name: "daily: no end, outside window",
startTime: start,
recurrence: daily,
now: time.Date(2024, 6, 15, 15, 0, 0, 0, time.UTC),
active: false,
},
{
name: "daily: at window start boundary",
startTime: start,
recurrence: daily,
now: time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC),
active: true,
},
{
name: "daily: at window end boundary",
startTime: start,
recurrence: daily,
now: time.Date(2024, 6, 15, 14, 0, 0, 0, time.UTC),
active: true,
},
{
// Past the recurrence end, the time-of-day match no longer applies.
name: "daily: t > recurrence end",
startTime: start,
endTime: time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC),
recurrence: daily,
now: time.Date(2024, 1, 15, 13, 0, 0, 0, time.UTC),
active: false,
},
{
name: "daily: before recurrence end, within window",
startTime: start,
endTime: time.Date(2024, 1, 10, 23, 0, 0, 0, time.UTC),
recurrence: daily,
now: time.Date(2024, 1, 10, 13, 0, 0, 0, time.UTC),
active: true,
},
{
name: "weekly: on allowed day, within window",
startTime: start, // Monday
recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
now: time.Date(2024, 4, 15, 13, 0, 0, 0, time.UTC), // a Monday
active: true,
},
{
name: "weekly: on non-allowed day",
startTime: start,
recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
now: time.Date(2024, 4, 16, 13, 0, 0, 0, time.UTC), // a Tuesday
active: false,
},
{
name: "monthly: on day-of-month, within window",
startTime: time.Date(2024, 1, 4, 12, 0, 0, 0, time.UTC),
recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
now: time.Date(2024, 5, 4, 13, 0, 0, 0, time.UTC),
active: true,
},
{
name: "monthly: on different day-of-month",
startTime: time.Date(2024, 1, 4, 12, 0, 0, 0, time.UTC),
recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
now: time.Date(2024, 5, 5, 13, 0, 0, 0, time.UTC),
active: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
m := &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: c.startTime,
EndTime: c.endTime,
Recurrence: c.recurrence,
},
}
if got := m.IsActive(c.now); got != c.active {
t.Errorf("IsActive() = %v, want %v", got, c.active)
}
})
}
}
func TestShouldSkip_Scope(t *testing.T) {
activeSchedule := &Schedule{
Timezone: "UTC",
StartTime: time.Now().UTC().Add(-time.Hour),
EndTime: time.Now().UTC().Add(time.Hour),
activeSchedule := func() *Schedule {
return &Schedule{
Timezone: "UTC",
StartTime: time.Now().UTC().Add(-time.Hour),
EndTime: time.Now().UTC().Add(time.Hour),
}
}
now := time.Now().UTC()
@@ -853,7 +699,7 @@ func TestShouldSkip_Scope(t *testing.T) {
}{
{
name: "empty scope - no label filtering applied",
maintenance: &PlannedMaintenance{Schedule: activeSchedule},
maintenance: &PlannedMaintenance{Schedule: activeSchedule()},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -861,7 +707,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "scope matches labels",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -869,7 +715,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "scope does not match labels",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},
@@ -877,7 +723,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "AND expression - both conditions match",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" AND service = "api"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" AND service = "api"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production", "service": "api"},
@@ -885,7 +731,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "AND expression - one condition does not match",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" AND service = "api"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" AND service = "api"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production", "service": "worker"},
@@ -893,7 +739,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "OR expression - first alternative matches",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" OR env = "staging"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -901,7 +747,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "OR expression - second alternative matches",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" OR env = "staging"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},
@@ -909,7 +755,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "OR expression - neither alternative matches",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" OR env = "staging"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "development"},
@@ -917,7 +763,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "scope references label absent from lset",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"service": "api"},
@@ -925,7 +771,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "in expression - value is in list",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env in ["production", "staging"]`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env in ["production", "staging"]`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},
@@ -933,7 +779,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "in expression - value not in list",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env in ["production", "staging"]`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env in ["production", "staging"]`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "development"},
@@ -941,7 +787,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "ruleID in list and scope matches - should skip",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, RuleIDs: []string{"rule-1", "rule-2"}, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-1", "rule-2"}, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -949,7 +795,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "ruleID not in list and scope matches - ruleID gate prevents skip",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, RuleIDs: []string{"rule-2"}, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-2"}, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -957,7 +803,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "ruleID in list but scope does not match - should not skip",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, RuleIDs: []string{"rule-1"}, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-1"}, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},

View File

@@ -66,9 +66,9 @@ var RepeatOnAllMap = map[RepeatOn]time.Weekday{
RepeatOnSaturday: time.Saturday,
}
// Recurrence describes the repeat pattern of a planned maintenance.
// The window bounds (start/end) live on the enclosing Schedule.
type Recurrence struct {
StartTime time.Time `json:"startTime" required:"true"`
EndTime *time.Time `json:"endTime,omitempty"`
Duration valuer.TextDuration `json:"duration" required:"true"`
RepeatType RepeatType `json:"repeatType" required:"true"`
RepeatOn []RepeatOn `json:"repeatOn"`

View File

@@ -11,7 +11,7 @@ import (
type Schedule struct {
Timezone string `json:"timezone" required:"true"`
StartTime time.Time `json:"startTime" required:"true"`
StartTime time.Time `json:"startTime,omitempty"`
EndTime time.Time `json:"endTime,omitzero"`
Recurrence *Recurrence `json:"recurrence"`
}
@@ -39,12 +39,29 @@ func (s Schedule) MarshalJSON() ([]byte, error) {
return nil, err
}
// Marshal times in the selected timezone.
// This ensures that recurring events are handled correctly when DST is involved.
startTime := s.StartTime.In(loc)
var endTime time.Time
var startTime, endTime time.Time
if !s.StartTime.IsZero() {
startTime = time.Date(s.StartTime.Year(), s.StartTime.Month(), s.StartTime.Day(), s.StartTime.Hour(), s.StartTime.Minute(), s.StartTime.Second(), s.StartTime.Nanosecond(), loc)
}
if !s.EndTime.IsZero() {
endTime = s.EndTime.In(loc)
endTime = time.Date(s.EndTime.Year(), s.EndTime.Month(), s.EndTime.Day(), s.EndTime.Hour(), s.EndTime.Minute(), s.EndTime.Second(), s.EndTime.Nanosecond(), loc)
}
var recurrence *Recurrence
if s.Recurrence != nil {
recStartTime := time.Date(s.Recurrence.StartTime.Year(), s.Recurrence.StartTime.Month(), s.Recurrence.StartTime.Day(), s.Recurrence.StartTime.Hour(), s.Recurrence.StartTime.Minute(), s.Recurrence.StartTime.Second(), s.Recurrence.StartTime.Nanosecond(), loc)
var recEndTime *time.Time
if s.Recurrence.EndTime != nil {
end := time.Date(s.Recurrence.EndTime.Year(), s.Recurrence.EndTime.Month(), s.Recurrence.EndTime.Day(), s.Recurrence.EndTime.Hour(), s.Recurrence.EndTime.Minute(), s.Recurrence.EndTime.Second(), s.Recurrence.EndTime.Nanosecond(), loc)
recEndTime = &end
}
recurrence = &Recurrence{
StartTime: recStartTime,
EndTime: recEndTime,
Duration: s.Recurrence.Duration,
RepeatType: s.Recurrence.RepeatType,
RepeatOn: s.Recurrence.RepeatOn,
}
}
return json.Marshal(&struct {
@@ -56,7 +73,7 @@ func (s Schedule) MarshalJSON() ([]byte, error) {
Timezone: s.Timezone,
StartTime: startTime,
EndTime: endTime,
Recurrence: s.Recurrence,
Recurrence: recurrence,
})
}
@@ -76,11 +93,14 @@ func (s *Schedule) UnmarshalJSON(data []byte) error {
return err
}
startTime, err := time.Parse(time.RFC3339, aux.StartTime)
if err != nil {
return err
var startTime time.Time
if aux.StartTime != "" {
startTime, err = time.Parse(time.RFC3339, aux.StartTime)
if err != nil {
return err
}
s.StartTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(), loc)
}
startTime = startTime.In(loc)
var endTime time.Time
if aux.EndTime != "" {
@@ -88,14 +108,35 @@ func (s *Schedule) UnmarshalJSON(data []byte) error {
if err != nil {
return err
}
if !endTime.IsZero() {
endTime = endTime.In(loc)
}
// TODO(jatinderjit): if endTime.IsZero() then we should not set the endTime
s.EndTime = time.Date(endTime.Year(), endTime.Month(), endTime.Day(), endTime.Hour(), endTime.Minute(), endTime.Second(), endTime.Nanosecond(), loc)
}
s.Timezone = aux.Timezone
s.StartTime = startTime
s.EndTime = endTime
s.Recurrence = aux.Recurrence
if aux.Recurrence != nil {
recStartTime, err := time.Parse(time.RFC3339, aux.Recurrence.StartTime.Format(time.RFC3339))
if err != nil {
return err
}
var recEndTime *time.Time
if aux.Recurrence.EndTime != nil {
end, err := time.Parse(time.RFC3339, aux.Recurrence.EndTime.Format(time.RFC3339))
if err != nil {
return err
}
endConverted := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), end.Minute(), end.Second(), end.Nanosecond(), loc)
recEndTime = &endConverted
}
s.Recurrence = &Recurrence{
StartTime: time.Date(recStartTime.Year(), recStartTime.Month(), recStartTime.Day(), recStartTime.Hour(), recStartTime.Minute(), recStartTime.Second(), recStartTime.Nanosecond(), loc),
EndTime: recEndTime,
Duration: aux.Recurrence.Duration,
RepeatType: aux.Recurrence.RepeatType,
RepeatOn: aux.Recurrence.RepeatOn,
}
}
return nil
}

View File

@@ -50,10 +50,18 @@ type ListServicesMetadataParams struct {
// Service represents a cloud integration service with its definition,
// cloud integration service is non nil only when the service entry exists in DB with ANY config (enabled or disabled).
type Service struct {
ServiceDefinition
ServiceDefinitionMetadata
Overview string `json:"overview" required:"true"` // markdown
ServiceAssets ServiceAssets `json:"assets" required:"true"`
SupportedSignals SupportedSignals `json:"supportedSignals" required:"true"`
DataCollected DataCollected `json:"dataCollected" required:"true"`
CloudIntegrationService *CloudIntegrationService `json:"cloudIntegrationService" required:"true" nullable:"true"`
}
type ServiceAssets struct {
Dashboards []ServiceDashboard `json:"dashboards" required:"true"`
}
type GetServiceParams struct {
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
}
@@ -121,6 +129,12 @@ type Dashboard struct {
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}
type ServiceDashboard struct {
ID *valuer.UUID `json:"id,omitempty" required:"false" nullable:"false"`
Title string `json:"title"`
Description string `json:"description"`
}
func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.UUID, provider CloudProviderType, config *ServiceConfig) (*CloudIntegrationService, error) {
switch provider {
case CloudProviderTypeAWS:
@@ -164,11 +178,42 @@ func NewServiceMetadata(definition ServiceDefinition, enabled bool) *ServiceMeta
}
}
func NewService(def ServiceDefinition, storableService *CloudIntegrationService) *Service {
return &Service{
ServiceDefinition: def,
CloudIntegrationService: storableService,
func NewService(provider CloudProviderType, def *ServiceDefinition, integrationService *CloudIntegrationService, integrationDashboards []*StorableIntegrationDashboard) *Service {
service := &Service{
ServiceDefinitionMetadata: def.ServiceDefinitionMetadata,
Overview: def.Overview,
SupportedSignals: def.SupportedSignals,
DataCollected: def.DataCollected,
CloudIntegrationService: integrationService,
ServiceAssets: ServiceAssets{Dashboards: make([]ServiceDashboard, 0, len(def.Assets.Dashboards))},
}
integrationDashboardsMap := make(map[string]string)
for _, d := range integrationDashboards {
integrationDashboardsMap[d.Slug] = d.DashboardID
}
for _, d := range def.Assets.Dashboards {
dashboard := ServiceDashboard{
Title: d.Title,
Description: d.Description,
}
if integrationService != nil {
slug := CloudIntegrationDashboardSlug(provider, integrationService.Type, d.ID)
if dashboardIDStr, exists := integrationDashboardsMap[slug]; exists {
dashboardUUID, err := valuer.NewUUID(dashboardIDStr)
if err == nil && !dashboardUUID.IsZero() {
dashboard.ID = &dashboardUUID
}
}
}
service.ServiceAssets.Dashboards = append(service.ServiceAssets.Dashboards, dashboard)
}
return service
}
func NewGettableServicesMetadata(services []*ServiceMetadata) *GettableServicesMetadata {