diff --git a/README.md b/README.md
index c549be6..9582fe9 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,23 @@
# Time Resource
-Implements a resource that reports new versions on a configured interval. The
-interval can be arbitrarily long.
+Implements a resource that reports new versions on a configured interval or cron schedule. The time intervals can be
+arbitrarily long.
-This resource is built to satisfy "trigger this build at least once every 5
-minutes," not "trigger this build on the 10th hour of every Sunday." That
-level of precision is better left to other tools.
+This resource is built to satisfy needs like "trigger this build at least once every 5 minutes" or "trigger this build
+at specific times using cron expressions." For simple interval-based triggering, the interval configuration is simpler
+to use. For more complex scheduling, the cron configuration provides greater flexibility.
## Source Configuration
+### Interval-based Configuration
+
* `interval`: *Optional.* The interval on which to report new versions. Valid
- units are: “ns”, “us” (or “µs”), “ms”, “s”, “m”, “h”. Examples: `60s`, `90m`,
- `1h30m`. If not specified, this resource will generate exactly 1 new version
- per calendar day on each of the valid `days`.
+ units are: "s", "m", "h". Examples: `60s`, `90m`, `1h30m`. If not specified, this resource will
+ generate exactly 1 new version per calendar day on each of the valid `days`.
* `location`: *Optional. Default `UTC`.* The
[location](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) in
@@ -30,7 +31,7 @@ level of precision is better left to other tools.
* `start` and `stop`: *Optional.* Limit the creation of new versions to times
on/after `start` and before `stop`. The supported formats for the times are:
- `3:04 PM`, `3PM`, `3PM`, `15:04`, and `1504`. If a `start` is specified, a
+ `3:04 PM`, `3PM`, `3 PM`, `15:04`, and `1504`. If a `start` is specified, a
`stop` must also be specified, and vice versa. If neither value is specified,
both values will default to `00:00` and this resource can generate a new
version (based on `interval`) at any time of day.
@@ -70,72 +71,162 @@ level of precision is better left to other tools.
These can be combined to emit a new version on an interval during a particular
time period.
-* `initial_version`: *Optional.* When using `start` and `stop` as a trigger for
- a job, you will be unable to run the job manually until it goes into the
- configured time range for the first time (manual runs will work once the `time`
- resource has produced it's first version).
+### Cron-based Configuration
+
+* `cron`: *Optional.* A cron expression that defines when new versions should be created. Standard cron format is
+ supported with 5 fields (minute, hour, day of month, month, day of week).
+
+ **Important:** The 6-field format (including seconds) is not supported. You must use the standard 5-field format that
+ only specifies minute precision.
+
+ e.g.
+
+ ```
+ cron: "0 * * * *" # Every hour at minute 0
+ ```
+
+**Tags:** Shorthand aliases for common schedules:
+
+| Tag | Expression | Schedule |
+|-----|------------|----------|
+| `@yearly` / `@annually` | `0 0 1 1 *` | Midnight, Jan 1st |
+| `@monthly` | `0 0 1 * *` | Midnight, 1st of month |
+| `@weekly` | `0 0 * * 0` | Midnight, Sunday |
+| `@daily` | `0 0 * * *` | Midnight |
+| `@hourly` | `0 * * * *` | Start of every hour |
+| `@30minutes` | `0,30 * * * *` | Every 30 minutes (:00, :30) |
+| `@15minutes` | `*/15 * * * *` | Every 15 minutes |
+| `@10minutes` | `*/10 * * * *` | Every 10 minutes |
+| `@5minutes` | `*/5 * * * *` | Every 5 minutes |
+
+e.g.
+ ```
+ cron: "@daily" # Run once a day at midnight
+ ```
+
+**Modifiers:** Special modifiers for complex scheduling:
+
+| Field | Modifier | Example | Description |
+|-------|----------|---------|-------------|
+| Day of Month | `L` | `0 2 L * *` | Last day of month (e.g., 28th/29th/30th/31st) |
+| | `W` | `0 1 15W * *` | Nearest weekday to date (if 15th is Sat, triggers Fri 14th) |
+| Day of Week | `L` | `0 3 * * 5L` | Last occurrence in month (5L = last Friday) |
+| | `#` | `0 5 * * 1#2` | Nth occurrence in month (1#2 = second Monday) |
+
+**Note: You cannot use `cron` together with `interval`, `start`, `stop`, or `days`. Use either the cron-based or
+interval-based configuration.**
+
+* `location`: *Optional. Default `UTC`.* When used with `cron`, the cron schedule is evaluated in this timezone.
+ See interval-based configuration above for format details.
+
+### Common Configuration Options
+
+* `initial_version`: *Optional.* When using `start` and `stop` or `cron` as a trigger for
+ a job, you will be unable to run the job manually until it reaches the
+ configured time range or cron schedule for the first time (manual runs will work once the `time`
+ resource has produced its first version).
To get around this issue, there are two approaches:
- * Use `initial_version: true`, which will produce a new version that is
- set to the current time, if `check` runs and there isn't already a version
- specified. **NOTE: This has a downside that if used with `trigger: true`, it will
- kick off the correlating job when the pipeline is first created, even
- outside of the specified window**.
- * Alternatively, once you push a pipeline that utilizes `start` and `stop`, run the
- following fly command to run the resource check from a previous point
- in time (see [this issue](https://github.com/concourse/time-resource/issues/24#issuecomment-689422764)
- for 6.x.x+ or [this issue](https://github.com/concourse/time-resource/issues/11#issuecomment-562385742)
- for older Concourse versions).
-
- ```
- fly -t \
- check-resource --resource /
- --from "time:2000-01-01T00:00:00Z" # the important part
- ```
-
- This has the benefit that it shouldn't trigger that initial job run, but
- will still allow you to manually run the job if needed.
+ * Use `initial_version: true`, which will produce a new version that is
+ set to the current time, if `check` runs and there isn't already a version
+ specified. **NOTE: This has a downside that if used with `trigger: true`, it will
+ kick off the correlating job when the pipeline is first created, even
+ outside of the specified window**.
+ * Alternatively, once you push a pipeline that utilizes time-based constraints, run the
+ following fly command to run the resource check from a previous point
+ in time (see [this issue](https://github.com/concourse/time-resource/issues/24#issuecomment-689422764)
+ for 6.x.x+ or [this issue](https://github.com/concourse/time-resource/issues/11#issuecomment-562385742)
+ for older Concourse versions).
+
+ ```
+ fly -t \
+ check-resource --resource /
+ --from "time:2000-01-01T00:00:00Z" # the important part
+ ```
+ This has the benefit that it shouldn't trigger that initial job run, but
+ will still allow you to manually run the job if needed.
e.g.
```
initial_version: true
```
-* `start_after`: *Optional.* Specifies the earliest datetime from which new time-based versions can be created.
+* `start_after`: *Optional.* Specifies the earliest datetime from which new time-based versions can be created.
Supported formats are `2006-01-02 15:04:05`, `2006-01-02T15:04:05`, `2006-01-02T15:04`, `2006-01-02T15`, `2006-01-02`.
Behavior:
- - If the `start_after` datetime is specified and is in the future, it will determine when the first version is created.
- - If the `start_after` datetime is in the past, the resource will continue to generate versions based on the other configuration parameters.
- - When `initial_version` is set to true, the first version will be created based on the current time. Subsequent versions will only be generated if they fall after the `start_after` datetime.
- - If a `location` is provided, the `start_after` datetime will be interpreted in the context of the specified timezone, rather than in UTC.
+ - If the `start_after` datetime is specified and is in the future, it will determine when the first version is created.
+ - If the `start_after` datetime is in the past, the resource will continue to generate versions based on the other configuration parameters.
+ - When `initial_version` is set to true, the first version will be created based on the current time. Subsequent versions will only be generated if they fall after the `start_after` datetime.
+ - If a `location` is provided, the `start_after` datetime will be interpreted in the context of the specified timezone, rather than in UTC.
e.g.
```
start_after: 2023-10-01T00:00:00
```
+
+### Differences Between `interval` and `cron`
+
+There is a difference between `interval` and `cron` when trying to create similar schedules. `interval` will trigger regardless of calendar boundaries, while `cron` will trigger strictly following calendar boundaries. Let's look at an example.
+
+If we want something to run "every 2 days" you can do that in these two ways:
+
+* `interval: 48h` or
+* `cron: "0 0 */2 * *"`
+
+When these configurations trigger is very different.
+
+The `interval` configuration will trigger every 48 hours based on when the last trigger ran.
+
+The `cron` configuration will trigger every 2 calendar days at midnight. Cron also calculates "every 2 days" to be the 1st of each month and then every 2 days from then. So this cron schedule will trigger on the 1st, 3rd, 5th, 7th, etc. of every month. This also means if you're in a month with a 31st day, the resource will emit a version on the 31st and then again on the 1st of the next month, resulting in a trigger two days in a row.
+
+A similar convention is followed with minutes and hours. When trying to schedule cron intervals like "every x minute/hour", cron will actually trigger "every x minute/hour of the hour/day". For example:
+
+* `*/5 * * * *` "Every 5 minutes" is actually "every 5th minute of the hour" (00, 05, 10, 15, etc.)
+* `0 */6 * * *` "Every 6 hours" is actually "every 6th hour of the day" (00, 06, 12, 18)
+
+**Recommendation:** If you want true elapsed-time intervals (e.g., "every 48 hours from the last run"), use `interval`. If you want calendar-aligned schedules (e.g., "at midnight on specific days"), use `cron`.
+
+### Cron Diagnostic Output
+
+When a cron-triggered version is emitted, the resource logs a human-readable explanation to stderr:
+
+```
+cron: emitting version at 2025-01-07T00:00:00Z (previous: 2025-01-05T00:00:00Z)
+ triggers every 2 days from 1st of month, at 00:00; note: 31st then 1st = back-to-back triggers
+```
+
+This includes warnings for common cron pitfalls:
+
+| Condition | Warning |
+|-----------|---------|
+| Day step lands on 31st (e.g., `*/2`, `*/3`, `*/5`) | `note: 31st then 1st = back-to-back triggers` |
+| Day 31 specified | `note: only triggers in months with 31 days (Jan, Mar, May, Jul, Aug, Oct, Dec)` |
+| Day 30 specified | `note: skips February` |
+| Day 29 specified | `note: only triggers in leap years for February` |
+| Both day-of-month and day-of-week set | `note: day-of-month AND day-of-week uses OR logic, not AND (triggers on EITHER match)` |
+| Hour 1-3 specified | `note: may skip or double-trigger during DST transitions` |
+
## Behavior
-### `check`: Produce timestamps satisfying the interval.
+### `check`: Produce timestamps satisfying the interval or cron schedule.
Returns current version and new version only if it has been longer than `interval` since the
-given version, or if there is no version given.
-
+given version, or if the time matches the specified cron expression, or if there is no version given.
### `in`: Report the given time.
Fetches the given timestamp. Creates three files:
1. `input` which contains the request provided by Concourse
-1. `timestamp` which contains the fetched version in the following format: `2006-01-02 15:04:05.999999999 -0700 MST`
-1. `epoch` which contains the fetched version as a Unix epoch Timestamp (integer only)
+2. `timestamp` which contains the fetched version in the following format: `2006-01-02 15:04:05.999999999 -0700 MST`
+3. `epoch` which contains the fetched version as a Unix epoch Timestamp (integer only)
#### Parameters
*None.*
-
### `out`: Produce the current time.
Returns a version for the current timestamp. This can be used to record the
@@ -145,7 +236,6 @@ time within a build plan, e.g. after running some long-running task.
*None.*
-
## Examples
### Periodic trigger
@@ -165,6 +255,42 @@ jobs:
config: # ...
```
+### Cron trigger
+
+```yaml
+resources:
+- name: nightly-build
+ type: time
+ source:
+ cron: "0 0 * * *" # Every day at midnight
+
+jobs:
+- name: run-nightly-build
+ plan:
+ - get: nightly-build
+ trigger: true
+ - task: build
+ config: # ...
+```
+
+### Cron trigger using tags
+
+```yaml
+resources:
+- name: weekly-cleanup
+ type: time
+ source:
+ cron: "@weekly" # Every Sunday at midnight
+
+jobs:
+- name: run-weekly-cleanup
+ plan:
+ - get: weekly-cleanup
+ trigger: true
+ - task: cleanup
+ config: # ...
+```
+
### Trigger once within time range
```yaml
@@ -225,13 +351,70 @@ jobs:
config: # ...
```
+### Trigger only on specific days
+
+```yaml
+resources:
+- name: weekday-mornings
+ type: time
+ source:
+ interval: 1h
+ start: 9:00 AM
+ stop: 12:00 PM
+ days: [Monday, Tuesday, Wednesday, Thursday, Friday]
+ location: Europe/London
+
+jobs:
+- name: something-every-hour-on-weekday-mornings
+ plan:
+ - get: weekday-mornings
+ trigger: true
+ - task: something
+ config: # ...
+```
+
+### Cron trigger with modifiers
+
+```yaml
+resources:
+- name: last-day-of-month
+ type: time
+ source:
+ cron: "0 9 L * *" # 9:00 AM on the last day of each month
+ location: America/New_York
+
+jobs:
+- name: monthly-report
+ plan:
+ - get: last-day-of-month
+ trigger: true
+ - task: generate-report
+ config: # ...
+```
+```yaml
+resources:
+- name: second-monday
+ type: time
+ source:
+ cron: "0 7 * * 1#2" # 7:00 AM on the second Monday of each month
+ location: Europe/Berlin
+
+jobs:
+- name: bi-monthly-planning
+ plan:
+ - get: second-monday
+ trigger: true
+ - task: planning-meeting
+ config: # ...
+```
+
## Development
### Prerequisites
-* golang is *required* - version 1.9.x is tested; earlier versions may also
+* golang is *required* - version 1.25.x is tested; earlier versions may also
work.
-* docker is *required* - version 17.06.x is tested; earlier versions may also
+* docker is *required* - version 25.x is tested; earlier versions may also
work.
* go mod is used for dependency management of the golang packages.
@@ -251,4 +434,4 @@ docker build -t time-resource --target tests .
### Contributing
Please make all pull requests to the `master` branch and ensure tests pass
-locally.
+locally.
\ No newline at end of file
diff --git a/check_command.go b/check_command.go
index a653514..4e96090 100644
--- a/check_command.go
+++ b/check_command.go
@@ -1,6 +1,10 @@
package resource
import (
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
"time"
"github.com/concourse/time-resource/lord"
@@ -10,6 +14,255 @@ import (
type CheckCommand struct {
}
+// DescribeCron returns a human-readable explanation of a cron expression
+func DescribeCron(expr string) string {
+ // Handle common macros
+ switch expr {
+ case "@yearly", "@annually":
+ return "triggers once a year at midnight on January 1st"
+ case "@monthly":
+ return "triggers at midnight on the 1st of every month"
+ case "@weekly":
+ return "triggers at midnight every Sunday"
+ case "@daily", "@midnight":
+ return "triggers once a day at midnight"
+ case "@hourly":
+ return "triggers at the start of every hour"
+ }
+
+ fields := strings.Fields(expr)
+ if len(fields) != 5 {
+ return fmt.Sprintf("schedule: %s", expr)
+ }
+
+ minute, hour, dom, month, dow := fields[0], fields[1], fields[2], fields[3], fields[4]
+ var parts []string
+ var warnings []string
+
+ // Day-of-week
+ if dow != "*" && dow != "?" {
+ dowDesc, dowWarnings := describeDOW(dow)
+ parts = append(parts, "on "+dowDesc)
+ warnings = append(warnings, dowWarnings...)
+ }
+
+ // Day-of-month
+ domDesc, domWarnings := describeDOM(dom)
+ if domDesc != "" {
+ parts = append(parts, domDesc)
+ }
+ warnings = append(warnings, domWarnings...)
+
+ // DOM + DOW = OR logic warning
+ if dom != "*" && dom != "?" && dow != "*" && dow != "?" {
+ warnings = append(warnings, "note: day-of-month AND day-of-week uses OR logic, not AND (triggers on EITHER match)")
+ }
+
+ // Month
+ if month != "*" {
+ parts = append(parts, "in "+describeMonth(month))
+ }
+
+ // Time description
+ timeDesc := describeTime(minute, hour)
+ if timeDesc != "" {
+ parts = append(parts, timeDesc)
+ }
+
+ // DST warning for specific hours
+ if hour != "*" && !strings.Contains(hour, "/") && !strings.Contains(hour, ",") {
+ h, err := strconv.Atoi(hour)
+ if err == nil && h >= 1 && h <= 3 {
+ warnings = append(warnings, "note: may skip or double-trigger during DST transitions")
+ }
+ }
+
+ if len(parts) == 0 {
+ return fmt.Sprintf("schedule: %s", expr)
+ }
+
+ result := "triggers " + strings.Join(parts, ", ")
+ if len(warnings) > 0 {
+ result += "; " + strings.Join(warnings, "; ")
+ }
+ return result
+}
+
+func describeTime(minute, hour string) string {
+ // Every N minutes
+ if strings.HasPrefix(minute, "*/") {
+ step := strings.TrimPrefix(minute, "*/")
+ return fmt.Sprintf("every %s minutes", step)
+ }
+
+ // Every N hours
+ if strings.HasPrefix(hour, "*/") {
+ step := strings.TrimPrefix(hour, "*/")
+ if minute == "0" {
+ return fmt.Sprintf("every %s hours at minute 0", step)
+ }
+ return fmt.Sprintf("every %s hours at minute %s", step, minute)
+ }
+
+ // Specific time
+ if hour != "*" && minute != "*" {
+ h, _ := strconv.Atoi(hour)
+ m, _ := strconv.Atoi(minute)
+ return fmt.Sprintf("at %02d:%02d", h, m)
+ }
+
+ if hour != "*" {
+ return fmt.Sprintf("during hour %s", hour)
+ }
+
+ if minute != "*" && !strings.Contains(minute, "/") {
+ return fmt.Sprintf("at minute %s of every hour", minute)
+ }
+
+ return ""
+}
+
+func describeDOW(dow string) (string, []string) {
+ days := map[string]string{
+ "0": "Sunday", "1": "Monday", "2": "Tuesday", "3": "Wednesday",
+ "4": "Thursday", "5": "Friday", "6": "Saturday",
+ "SUN": "Sunday", "MON": "Monday", "TUE": "Tuesday", "WED": "Wednesday",
+ "THU": "Thursday", "FRI": "Friday", "SAT": "Saturday",
+ }
+
+ var warnings []string
+ dowUpper := strings.ToUpper(dow)
+
+ if strings.Contains(dow, "#") {
+ parts := strings.Split(dow, "#")
+ if len(parts) == 2 {
+ dayName := parts[0]
+ if name, ok := days[strings.ToUpper(dayName)]; ok {
+ dayName = name
+ }
+ nth := parts[1]
+ ordinal := toOrdinal(nth)
+ if nth == "5" {
+ warnings = append(warnings, "note: 5th occurrence only exists in some months")
+ }
+ return fmt.Sprintf("%s %s of the month", ordinal, dayName), warnings
+ }
+ }
+
+ if strings.HasSuffix(dowUpper, "L") {
+ dayNum := strings.TrimSuffix(dow, "L")
+ dayNum = strings.TrimSuffix(dayNum, "l")
+ if name, ok := days[dayNum]; ok {
+ return fmt.Sprintf("last %s of the month", name), warnings
+ }
+ return fmt.Sprintf("last %s of the month", dayNum), warnings
+ }
+
+ if strings.Contains(dow, "-") {
+ parts := strings.Split(dow, "-")
+ if len(parts) == 2 {
+ startDay := parts[0]
+ endDay := parts[1]
+ if name, ok := days[strings.ToUpper(startDay)]; ok {
+ startDay = name
+ }
+ if name, ok := days[strings.ToUpper(endDay)]; ok {
+ endDay = name
+ }
+ return fmt.Sprintf("%s through %s", startDay, endDay), warnings
+ }
+ }
+
+ if name, ok := days[dowUpper]; ok {
+ return name, warnings
+ }
+ return dow, warnings
+}
+
+func toOrdinal(n string) string {
+ num, err := strconv.Atoi(n)
+ if err != nil {
+ return n
+ }
+ suffix := "th"
+ if num%100 < 11 || num%100 > 13 {
+ switch num % 10 {
+ case 1:
+ suffix = "st"
+ case 2:
+ suffix = "nd"
+ case 3:
+ suffix = "rd"
+ }
+ }
+ return fmt.Sprintf("%d%s", num, suffix)
+}
+
+func describeDOM(dom string) (string, []string) {
+ var warnings []string
+
+ if dom == "*" || dom == "?" {
+ return "", warnings
+ }
+
+ if strings.HasPrefix(dom, "*/") {
+ step := strings.TrimPrefix(dom, "*/")
+ desc := fmt.Sprintf("every %s days from 1st of month", step)
+ stepNum, err := strconv.Atoi(step)
+ if err == nil && stepNum > 0 {
+ for day := 1; day <= 31; day += stepNum {
+ if day == 31 {
+ warnings = append(warnings, "note: 31st then 1st = back-to-back triggers")
+ break
+ }
+ }
+ }
+ return desc, warnings
+ }
+
+ domUpper := strings.ToUpper(dom)
+
+ if domUpper == "L" {
+ return "on the last day of the month", warnings
+ }
+
+ if strings.HasSuffix(domUpper, "W") {
+ dayNum := strings.TrimSuffix(dom, "W")
+ dayNum = strings.TrimSuffix(dayNum, "w")
+ desc := fmt.Sprintf("on the nearest weekday to the %s", toOrdinal(dayNum))
+ if dayNum == "31" {
+ warnings = append(warnings, "note: only triggers in months with 31 days")
+ } else if dayNum == "30" {
+ warnings = append(warnings, "note: skips February")
+ } else if dayNum == "29" {
+ warnings = append(warnings, "note: only triggers in leap years for February")
+ }
+ return desc, warnings
+ }
+
+ if dom == "31" {
+ warnings = append(warnings, "note: only triggers in months with 31 days (Jan, Mar, May, Jul, Aug, Oct, Dec)")
+ } else if dom == "30" {
+ warnings = append(warnings, "note: skips February")
+ } else if dom == "29" {
+ warnings = append(warnings, "note: only triggers in leap years for February")
+ }
+
+ return "on day " + dom + " of the month", warnings
+}
+
+func describeMonth(month string) string {
+ months := map[string]string{
+ "1": "January", "2": "February", "3": "March", "4": "April",
+ "5": "May", "6": "June", "7": "July", "8": "August",
+ "9": "September", "10": "October", "11": "November", "12": "December",
+ }
+ if name, ok := months[month]; ok {
+ return name
+ }
+ return "month " + month
+}
+
func (*CheckCommand) Run(request models.CheckRequest) ([]models.Version, error) {
err := request.Source.Validate()
if err != nil {
@@ -32,6 +285,7 @@ func (*CheckCommand) Run(request models.CheckRequest) ([]models.Version, error)
Interval: request.Source.Interval,
Days: request.Source.Days,
StartAfter: request.Source.StartAfter,
+ Cron: request.Source.Cron,
}
var versions []models.Version
@@ -39,12 +293,46 @@ func (*CheckCommand) Run(request models.CheckRequest) ([]models.Version, error)
if !previousTime.IsZero() {
versions = append(versions, models.Version{Time: previousTime})
} else if request.Source.InitialVersion {
- versions = append(versions, models.Version{Time: currentTime})
+ // For cron with initial_version, use the cron boundary time for consistency.
+ // For non-cron, use currentTime
+ versionTime := currentTime
+ if request.Source.Cron != nil {
+ cronTime := tl.Latest(currentTime)
+ if !cronTime.IsZero() {
+ versionTime = cronTime
+ fmt.Fprintf(os.Stderr, "cron: emitting initial version at %s\n %s\n",
+ versionTime.Format(time.RFC3339), DescribeCron(request.Source.Cron.Expression))
+ }
+ }
+ versions = append(versions, models.Version{Time: versionTime})
+ return versions, nil
+ } else if request.Source.Cron != nil {
+ // Cron with initial_version:false (or unset) and no previous version:
+ // don't emit any version until after the first cron trigger is observed.
return versions, nil
}
if tl.Check(currentTime) {
- versions = append(versions, models.Version{Time: currentTime})
+ var versionTime time.Time
+
+ // For cron expressions, use the actual scheduled cron time
+ // instead of the check time. This ensures versions are at cron boundaries.
+ // Example: cron @5minutes, check at 3:07pm → version time = 3:05pm
+ if request.Source.Cron != nil {
+ versionTime = tl.Latest(currentTime)
+ if !versionTime.IsZero() {
+ fmt.Fprintf(os.Stderr, "cron: emitting version at %s (previous: %s)\n %s\n",
+ versionTime.Format(time.RFC3339), previousTime.Format(time.RFC3339),
+ DescribeCron(request.Source.Cron.Expression))
+ }
+ }
+
+ // For non-cron (interval, start/stop ranges), use currentTime
+ if versionTime.IsZero() {
+ versionTime = currentTime
+ }
+
+ versions = append(versions, models.Version{Time: versionTime})
}
return versions, nil
diff --git a/check_command_test.go b/check_command_test.go
index b6f18b1..e182ecc 100644
--- a/check_command_test.go
+++ b/check_command_test.go
@@ -1,6 +1,7 @@
package resource_test
import (
+ "fmt"
"time"
resource "github.com/concourse/time-resource"
@@ -11,9 +12,7 @@ import (
)
var _ = Describe("Check", func() {
- var (
- now time.Time
- )
+ var now time.Time
BeforeEach(func() {
now = time.Now().UTC()
@@ -32,7 +31,6 @@ var _ = Describe("Check", func() {
JustBeforeEach(func() {
command := resource.CheckCommand{}
-
var err error
response, err = command.Run(models.CheckRequest{
Source: source,
@@ -50,29 +48,25 @@ var _ = Describe("Check", func() {
})
Context("when a version is given", func() {
- var prev time.Time
-
- Context("when the resource has already triggered on the current day", func() {
+ Context("when the resource has already triggered today", func() {
BeforeEach(func() {
- prev = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, now.Second(), now.Nanosecond(), now.Location())
- version.Time = prev
+ version.Time = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, now.Second(), now.Nanosecond(), now.Location())
})
- It("outputs a supplied version", func() {
+ It("outputs only the supplied version", func() {
Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1))
+ Expect(response[0].Time.Unix()).To(BeNumerically("~", version.Time.Unix(), 1))
})
})
Context("when the resource was triggered yesterday", func() {
BeforeEach(func() {
- prev = now.Add(-24 * time.Hour)
- version.Time = prev
+ version.Time = now.Add(-24 * time.Hour)
})
- It("outputs a version containing the current time and supplied version", func() {
+ It("outputs both previous and current versions", func() {
Expect(response).To(HaveLen(2))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1))
+ Expect(response[0].Time.Unix()).To(BeNumerically("~", version.Time.Unix(), 1))
Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
})
})
@@ -84,7 +78,6 @@ var _ = Describe("Check", func() {
BeforeEach(func() {
start := now.Add(-1 * time.Hour)
stop := now.Add(1 * time.Hour)
-
source.Start = tod(start.Hour(), start.Minute(), 0)
source.Stop = tod(stop.Hour(), stop.Minute(), 0)
})
@@ -96,58 +89,26 @@ var _ = Describe("Check", func() {
})
})
- Context("when a version is given", func() {
- var prev time.Time
-
- Context("when the resource has already triggered with in the current time range", func() {
- BeforeEach(func() {
- prev = now.Add(-30 * time.Minute)
- version.Time = prev
- })
-
- It("outputs a supplied version", func() {
- Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1))
- })
+ Context("when a version is given within the current time range", func() {
+ BeforeEach(func() {
+ version.Time = now.Add(-30 * time.Minute)
})
- Context("when the resource was triggered yesterday near the end of the time frame", func() {
- BeforeEach(func() {
- prev = now.Add(-23 * time.Hour)
- version.Time = prev
- })
-
- It("outputs a version containing the current time and supplied version", func() {
- Expect(response).To(HaveLen(2))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1))
- Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
+ It("outputs only the supplied version", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Unix()).To(BeNumerically("~", version.Time.Unix(), 1))
})
+ })
- Context("when the resource was triggered last year near the end of the time frame", func() {
- BeforeEach(func() {
- prev = now.AddDate(-1, 0, 0)
- version.Time = prev
- })
-
- It("outputs a version containing the current time and supplied version", func() {
- Expect(response).To(HaveLen(2))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1))
- Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
+ Context("when a version is given from yesterday", func() {
+ BeforeEach(func() {
+ version.Time = now.Add(-24 * time.Hour)
})
- Context("when the resource was triggered yesterday in the current time frame", func() {
- BeforeEach(func() {
- prev = now.Add(-24 * time.Hour)
- version.Time = prev
- })
-
- It("outputs a version containing the current time and supplied version", func() {
- Expect(response).To(HaveLen(2))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1))
- Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
+ It("outputs both previous and current versions", func() {
+ Expect(response).To(HaveLen(2))
+ Expect(response[0].Time.Unix()).To(BeNumerically("~", version.Time.Unix(), 1))
+ Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
})
})
@@ -164,55 +125,33 @@ var _ = Describe("Check", func() {
})
})
- Context("when a version is given", func() {
- var prev time.Time
-
- Context("when the interval has not elapsed", func() {
- BeforeEach(func() {
- prev = now
- version.Time = prev
- })
+ Context("when the interval has not elapsed", func() {
+ BeforeEach(func() {
+ version.Time = now
+ })
- It("outputs only the supplied version", func() {
- Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
- })
+ It("outputs only the supplied version", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix()))
})
+ })
- Context("when the interval has elapsed", func() {
- BeforeEach(func() {
- prev = now.Add(-1 * time.Minute)
- version.Time = prev
- })
-
- It("outputs a version containing the current time and supplied version", func() {
- Expect(response).To(HaveLen(2))
- Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
- Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
+ Context("when the interval has elapsed", func() {
+ BeforeEach(func() {
+ version.Time = now.Add(-2 * time.Minute)
})
- Context("with its time N intervals ago", func() {
- BeforeEach(func() {
- prev = now.Add(-5 * time.Minute)
- version.Time = prev
- })
-
- It("outputs a version containing the current time and supplied version", func() {
- Expect(response).To(HaveLen(2))
- Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
- Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
+ It("outputs both previous and current versions", func() {
+ Expect(response).To(HaveLen(2))
+ Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix()))
+ Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
})
})
})
Context("when the current day is specified", func() {
BeforeEach(func() {
- source.Days = []models.Weekday{
- models.Weekday(now.Weekday()),
- models.Weekday(now.AddDate(0, 0, 2).Weekday()),
- }
+ source.Days = []models.Weekday{models.Weekday(now.Weekday())}
})
It("outputs a version containing the current time", func() {
@@ -223,10 +162,7 @@ var _ = Describe("Check", func() {
Context("when we are out of the specified day", func() {
BeforeEach(func() {
- source.Days = []models.Weekday{
- models.Weekday(now.AddDate(0, 0, 1).Weekday()),
- models.Weekday(now.AddDate(0, 0, 2).Weekday()),
- }
+ source.Days = []models.Weekday{models.Weekday(now.AddDate(0, 0, 1).Weekday())}
})
It("does not output any versions", func() {
@@ -239,25 +175,16 @@ var _ = Describe("Check", func() {
BeforeEach(func() {
start := now.Add(6 * time.Hour)
stop := now.Add(7 * time.Hour)
-
source.Start = tod(start.Hour(), start.Minute(), 0)
source.Stop = tod(stop.Hour(), stop.Minute(), 0)
})
- Context("when no version is given", func() {
- It("does not output any versions", func() {
- Expect(response).To(BeEmpty())
- })
+ It("does not output any versions", func() {
+ Expect(response).To(BeEmpty())
})
Context("when an interval is given", func() {
BeforeEach(func() {
- start := now.Add(6 * time.Hour)
- stop := now.Add(7 * time.Hour)
-
- source.Start = tod(start.Hour(), start.Minute(), 0)
- source.Stop = tod(stop.Hour(), stop.Minute(), 0)
-
interval := models.Interval(time.Minute)
source.Interval = &interval
})
@@ -269,351 +196,639 @@ var _ = Describe("Check", func() {
})
Context("with a location configured", func() {
- var loc *time.Location
-
BeforeEach(func() {
- var err error
- loc, err = time.LoadLocation("America/Indiana/Indianapolis")
+ loc, err := time.LoadLocation("America/Indiana/Indianapolis")
Expect(err).ToNot(HaveOccurred())
-
srcLoc := models.Location(*loc)
source.Location = &srcLoc
-
now = now.In(loc)
+
+ start := now.Add(-1 * time.Hour)
+ stop := now.Add(1 * time.Hour)
+ source.Start = tod(start.Hour(), start.Minute(), 0)
+ source.Stop = tod(stop.Hour(), stop.Minute(), 0)
})
- Context("when we are in the specified time range", func() {
- BeforeEach(func() {
- start := now.Add(-1 * time.Hour)
- stop := now.Add(1 * time.Hour)
+ It("outputs a version when in range", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
+ })
+ })
+ })
- source.Start = tod(start.Hour(), start.Minute(), 0)
- source.Stop = tod(stop.Hour(), stop.Minute(), 0)
- })
+ Context("when an interval is specified", func() {
+ BeforeEach(func() {
+ interval := models.Interval(time.Minute)
+ source.Interval = &interval
+ })
- Context("when no version is given", func() {
- It("outputs a version containing the current time", func() {
- Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
- })
+ Context("when no version is given", func() {
+ It("outputs a version containing the current time", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
+ })
+ })
- Context("when a version is given", func() {
- var prev time.Time
+ Context("when the interval has not elapsed", func() {
+ BeforeEach(func() {
+ version.Time = now
+ })
- Context("when the resource has already triggered with in the current time range", func() {
- BeforeEach(func() {
- prev = now.Add(-30 * time.Minute)
- version.Time = prev
- })
+ It("outputs only the supplied version", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix()))
+ })
+ })
- It("outputs a supplied version", func() {
- Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
- })
- })
+ Context("when the interval has elapsed", func() {
+ BeforeEach(func() {
+ version.Time = now.Add(-2 * time.Minute)
+ })
- Context("when the resource was triggered yesterday near the end of the time frame", func() {
- BeforeEach(func() {
- prev = now.Add(-23 * time.Hour)
- version.Time = prev
- })
-
- It("outputs a version containing the current time and supplied version", func() {
- Expect(response).To(HaveLen(2))
- Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
- Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
- })
+ It("outputs both previous and current versions", func() {
+ Expect(response).To(HaveLen(2))
+ Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix()))
+ Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
+ })
+ })
- Context("when the resource was triggered yesterday in the current time frame", func() {
- BeforeEach(func() {
- prev = now.AddDate(0, 0, -1)
- version.Time = prev
- })
-
- It("outputs a version containing the current time and supplied version", func() {
- Expect(response).To(HaveLen(2))
- Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
- Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
- })
+ Context("with a longer interval", func() {
+ BeforeEach(func() {
+ interval := models.Interval(time.Hour)
+ source.Interval = &interval
+ })
+
+ Context("when within the interval", func() {
+ BeforeEach(func() {
+ version.Time = now.Add(-30 * time.Minute)
})
- Context("when an interval is specified", func() {
- BeforeEach(func() {
- interval := models.Interval(time.Minute)
- source.Interval = &interval
- })
+ It("outputs only the supplied version", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix()))
+ })
+ })
- Context("when no version is given", func() {
- It("outputs a version containing the current time", func() {
- Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
- })
+ Context("when beyond the interval", func() {
+ BeforeEach(func() {
+ version.Time = now.Add(-61 * time.Minute)
+ })
- Context("when a version is given", func() {
- var prev time.Time
-
- Context("with its time within the interval", func() {
- BeforeEach(func() {
- prev = now
- version.Time = prev
- })
-
- It("outputs the given version", func() {
- Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
- })
- })
-
- Context("with its time one interval ago", func() {
- BeforeEach(func() {
- prev = now.Add(-1 * time.Minute)
- version.Time = prev
- })
-
- It("outputs a version containing the current time and supplied version", func() {
- Expect(response).To(HaveLen(2))
- Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
- Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
- })
-
- Context("with its time N intervals ago", func() {
- BeforeEach(func() {
- prev = now.Add(-5 * time.Minute)
- version.Time = prev
- })
-
- It("outputs a version containing the current time and supplied version", func() {
- Expect(response).To(HaveLen(2))
- Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
- Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
- })
- })
+ It("outputs both versions", func() {
+ Expect(response).To(HaveLen(2))
+ Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix()))
+ Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
})
+ })
+ })
+ })
- Context("when the current day is specified", func() {
- BeforeEach(func() {
- source.Days = []models.Weekday{
- models.Weekday(now.Weekday()),
- models.Weekday(now.AddDate(0, 0, 2).Weekday()),
- }
- })
+ Context("when a cron expression is specified", func() {
+ Context("when no version is given", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "*/5 * * * *"}
+ source.Cron = &cronExpr
+ })
- It("outputs a version containing the current time", func() {
- Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
- })
+ It("does not output any versions", func() {
+ Expect(response).To(BeEmpty())
+ })
+ })
- Context("when we are out of the specified day", func() {
- BeforeEach(func() {
- source.Days = []models.Weekday{
- models.Weekday(now.AddDate(0, 0, 1).Weekday()),
- models.Weekday(now.AddDate(0, 0, 2).Weekday()),
- }
- })
+ Context("when no version is given and initial_version is true", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "*/5 * * * *"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
+ })
- It("does not output any versions", func() {
- Expect(response).To(BeEmpty())
- })
- })
+ It("outputs a version at the most recent cron boundary with zero seconds and nanoseconds", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Minute() % 5).To(Equal(0))
+ Expect(response[0].Time.Second()).To(Equal(0))
+ Expect(response[0].Time.Nanosecond()).To(Equal(0))
+ Expect(response[0].Time.Unix()).To(BeNumerically("<=", time.Now().Unix()))
})
+ })
- Context("when we are not within the specified time range", func() {
+ Context("when a version is given", func() {
+ Context("and next cron time has passed", func() {
BeforeEach(func() {
- start := now.Add(6 * time.Hour)
- stop := now.Add(7 * time.Hour)
+ cronExpr := models.Cron{Expression: "* * * * *"}
+ source.Cron = &cronExpr
+ version.Time = now.Add(-2 * time.Minute)
+ })
- source.Start = tod(start.Hour(), start.Minute(), 0)
- source.Stop = tod(stop.Hour(), stop.Minute(), 0)
+ It("outputs both previous version and new version at cron boundary", func() {
+ Expect(response).To(HaveLen(2))
+ Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix()))
+ Expect(response[1].Time.Second()).To(Equal(0))
+ Expect(response[1].Time.Nanosecond()).To(Equal(0))
})
+ })
- Context("when no version is given", func() {
- It("does not output any versions", func() {
- Expect(response).To(BeEmpty())
- })
+ Context("and next cron time has not passed", func() {
+ BeforeEach(func() {
+ futureMinute := (now.Minute() + 30) % 60
+ cronExpr := models.Cron{Expression: fmt.Sprintf("%d * * * *", futureMinute)}
+ source.Cron = &cronExpr
+ version.Time = now
})
- Context("when an interval is given", func() {
- BeforeEach(func() {
- start := now.Add(6 * time.Hour)
- stop := now.Add(7 * time.Hour)
+ It("outputs only the previous version", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix()))
+ })
+ })
+ })
- source.Start = tod(start.Hour(), start.Minute(), 0)
- source.Stop = tod(stop.Hour(), stop.Minute(), 0)
+ Context("with L modifier (last day of month)", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "0 9 L * *"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
+ })
- interval := models.Interval(time.Minute)
- source.Interval = &interval
- })
+ It("outputs a version on the last day of a month", func() {
+ Expect(response).To(HaveLen(1))
+ nextDay := response[0].Time.AddDate(0, 0, 1)
+ Expect(nextDay.Day()).To(Equal(1))
+ })
+ })
- It("does not output any versions", func() {
- Expect(response).To(BeEmpty())
- })
- })
+ Context("with # modifier (second Monday)", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "0 7 * * 1#2"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
+ })
+
+ It("outputs a version on the second Monday (day 8-14)", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Weekday()).To(Equal(time.Monday))
+ Expect(response[0].Time.Day()).To(BeNumerically(">=", 8))
+ Expect(response[0].Time.Day()).To(BeNumerically("<=", 14))
})
})
- })
- Context("when an interval is specified", func() {
- BeforeEach(func() {
- interval := models.Interval(time.Minute)
- source.Interval = &interval
+ Context("with # modifier (fourth Thursday)", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "0 9 * * 4#4"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
+ })
+
+ It("outputs a version on the fourth Thursday (day 22-28)", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Weekday()).To(Equal(time.Thursday))
+ Expect(response[0].Time.Day()).To(BeNumerically(">=", 22))
+ Expect(response[0].Time.Day()).To(BeNumerically("<=", 28))
+ })
})
- Context("when no version is given", func() {
- It("outputs a version containing the current time", func() {
+ Context("with W modifier (nearest weekday to 15th)", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "0 9 15W * *"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
+ })
+
+ It("outputs a version on a weekday within 2 days of the 15th", func() {
Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
+ Expect(response[0].Time.Weekday()).NotTo(Equal(time.Saturday))
+ Expect(response[0].Time.Weekday()).NotTo(Equal(time.Sunday))
+ Expect(response[0].Time.Day()).To(BeNumerically(">=", 13))
+ Expect(response[0].Time.Day()).To(BeNumerically("<=", 17))
})
})
- Context("when a version is given", func() {
- var prev time.Time
+ Context("with 1W modifier (nearest weekday to 1st)", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "0 9 1W * *"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
+ })
- Context("with its time within the interval", func() {
- BeforeEach(func() {
- prev = now
- version.Time = prev
- })
+ It("outputs a version on a weekday day 1-3 (stays in month)", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Weekday()).NotTo(Equal(time.Saturday))
+ Expect(response[0].Time.Weekday()).NotTo(Equal(time.Sunday))
+ Expect(response[0].Time.Day()).To(BeNumerically(">=", 1))
+ Expect(response[0].Time.Day()).To(BeNumerically("<=", 3))
+ })
+ })
- It("outputs a supplied version", func() {
- Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
- })
+ Context("with 5L modifier (last Friday of month)", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "0 17 * * 5L"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
})
- Context("with its time one interval ago", func() {
- BeforeEach(func() {
- prev = now.Add(-1 * time.Minute)
- version.Time = prev
- })
+ It("outputs a version on a Friday where next Friday is in a different month", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Weekday()).To(Equal(time.Friday))
+ nextFriday := response[0].Time.AddDate(0, 0, 7)
+ Expect(nextFriday.Month()).NotTo(Equal(response[0].Time.Month()))
+ })
+ })
- It("outputs a version containing the current time and supplied version", func() {
- Expect(response).To(HaveLen(2))
- Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
- Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
+ Context("with @yearly macro", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "@yearly"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
})
- Context("with its time N intervals ago", func() {
- BeforeEach(func() {
- prev = now.Add(-5 * time.Minute)
- version.Time = prev
- })
+ It("outputs a version at January 1st midnight", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Month()).To(Equal(time.January))
+ Expect(response[0].Time.Day()).To(Equal(1))
+ Expect(response[0].Time.Hour()).To(Equal(0))
+ Expect(response[0].Time.Minute()).To(Equal(0))
+ })
+ })
- It("outputs a version containing the current time and supplied version", func() {
- Expect(response).To(HaveLen(2))
- Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
- Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
+ Context("with @monthly macro", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "@monthly"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
+ })
+
+ It("outputs a version on the 1st at midnight", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Day()).To(Equal(1))
+ Expect(response[0].Time.Hour()).To(Equal(0))
+ Expect(response[0].Time.Minute()).To(Equal(0))
})
})
- })
- Context("when start_after is specified", func() {
- Context("when no version is provided", func() {
- Context("and the current time is after start_after", func() {
- BeforeEach(func() {
- startAfter := now.Add(-1 * time.Hour)
- source.StartAfter = (*models.StartAfter)(&startAfter)
- })
+ Context("with @weekly macro", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "@weekly"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
+ })
- It("outputs a version containing the current time", func() {
- Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1))
- })
+ It("outputs a version on Sunday at midnight", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Weekday()).To(Equal(time.Sunday))
+ Expect(response[0].Time.Hour()).To(Equal(0))
+ Expect(response[0].Time.Minute()).To(Equal(0))
})
+ })
- Context("and the current time is before start_after", func() {
- BeforeEach(func() {
- startAfter := now.Add(1 * time.Hour)
- source.StartAfter = (*models.StartAfter)(&startAfter)
- })
+ Context("with specific month cron expression", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "0 0 1 6 *"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
+ })
- It("does not output any versions", func() {
- Expect(response).To(BeEmpty())
- })
+ It("outputs a version in June", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Month()).To(Equal(time.June))
})
})
- Context("when a version is provided", func() {
- var previousTime time.Time
+ Context("with location configured", func() {
+ BeforeEach(func() {
+ loc, err := time.LoadLocation("America/New_York")
+ Expect(err).ToNot(HaveOccurred())
+ srcLoc := models.Location(*loc)
+ source.Location = &srcLoc
- Context("when the current time is after start_after and a previous version exists", func() {
- BeforeEach(func() {
- previousTime = now.Add(-24 * time.Hour)
- version.Time = previousTime
- startAfter := now.Add(-25 * time.Hour)
- source.StartAfter = (*models.StartAfter)(&startAfter)
- })
+ cronExpr := models.Cron{Expression: "*/5 * * * *"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
+ })
- It("outputs both the previous and current versions", func() {
- Expect(response).To(HaveLen(2))
- Expect(response[0].Time.Unix()).To(Equal(previousTime.Unix()))
- Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
- })
+ It("outputs a version at a cron boundary", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Minute() % 5).To(Equal(0))
+ Expect(response[0].Time.Second()).To(Equal(0))
})
})
- Context("when initial_version is specified", func() {
- Context("when the current time is before start_after and initial_version is true", func() {
- BeforeEach(func() {
- startAfter := now.Add(1 * time.Hour)
- source.StartAfter = (*models.StartAfter)(&startAfter)
- source.InitialVersion = true
- })
+ Context("with range in hour field", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "0 9-17 * * *"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
+ })
- It("outputs a single version containing the initial version", func() {
- Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1))
- })
+ It("outputs a version with hour between 9 and 17", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Hour()).To(BeNumerically(">=", 9))
+ Expect(response[0].Time.Hour()).To(BeNumerically("<=", 17))
})
+ })
- Context("when the current time is after start_after and initial_version is true", func() {
- BeforeEach(func() {
- startAfter := now.Add(-1 * time.Hour)
- source.StartAfter = (*models.StartAfter)(&startAfter)
- source.InitialVersion = true
- })
+ Context("with list in day-of-week field", func() {
+ BeforeEach(func() {
+ cronExpr := models.Cron{Expression: "0 9 * * 1,3,5"}
+ source.Cron = &cronExpr
+ source.InitialVersion = true
+ })
- It("outputs a single version containing the current time", func() {
- Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1))
- })
+ It("outputs a version on Monday, Wednesday, or Friday", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Weekday()).To(BeElementOf(time.Monday, time.Wednesday, time.Friday))
})
+ })
+ })
- Context("when the current time is before start_after and initial_version is false", func() {
- BeforeEach(func() {
- startAfter := now.Add(1 * time.Hour)
- source.StartAfter = (*models.StartAfter)(&startAfter)
- source.InitialVersion = false
- })
+ Context("when start_after is specified", func() {
+ Context("when current time is after start_after", func() {
+ BeforeEach(func() {
+ startAfter := now.Add(-1 * time.Hour)
+ source.StartAfter = (*models.StartAfter)(&startAfter)
+ })
- It("does not output any versions", func() {
- Expect(response).To(BeEmpty())
- })
+ It("outputs a version containing the current time", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1))
})
+ })
- Context("when the current time is after start_after and initial_version is false", func() {
- BeforeEach(func() {
- startAfter := now.Add(-1 * time.Hour)
- source.StartAfter = (*models.StartAfter)(&startAfter)
- source.InitialVersion = false
- })
+ Context("when current time is before start_after", func() {
+ BeforeEach(func() {
+ startAfter := now.Add(1 * time.Hour)
+ source.StartAfter = (*models.StartAfter)(&startAfter)
+ })
- It("outputs a single version containing the current time", func() {
- Expect(response).To(HaveLen(1))
- Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1))
- })
+ It("does not output any versions", func() {
+ Expect(response).To(BeEmpty())
+ })
+ })
+
+ Context("when current time is before start_after but initial_version is true", func() {
+ BeforeEach(func() {
+ startAfter := now.Add(1 * time.Hour)
+ source.StartAfter = (*models.StartAfter)(&startAfter)
+ source.InitialVersion = true
+ })
+
+ It("outputs a version", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1))
+ })
+ })
+ })
+
+ Context("when initial_version is true without other configuration", func() {
+ BeforeEach(func() {
+ source.InitialVersion = true
+ })
+
+ It("outputs a version containing the current time", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1))
+ })
+ })
+
+ Context("when multiple configurations are combined", func() {
+ Context("with interval and matching day", func() {
+ BeforeEach(func() {
+ interval := models.Interval(time.Minute)
+ source.Interval = &interval
+ source.Days = []models.Weekday{models.Weekday(now.Weekday())}
+ })
+
+ It("outputs a version", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1))
+ })
+ })
+
+ Context("with interval and non-matching day", func() {
+ BeforeEach(func() {
+ interval := models.Interval(time.Minute)
+ source.Interval = &interval
+ source.Days = []models.Weekday{models.Weekday(now.AddDate(0, 0, 1).Weekday())}
+ })
+
+ It("does not output any versions", func() {
+ Expect(response).To(BeEmpty())
+ })
+ })
+
+ Context("with time range, interval, and matching day", func() {
+ BeforeEach(func() {
+ start := now.Add(-1 * time.Hour)
+ stop := now.Add(1 * time.Hour)
+ source.Start = tod(start.Hour(), start.Minute(), 0)
+ source.Stop = tod(stop.Hour(), stop.Minute(), 0)
+
+ interval := models.Interval(time.Minute)
+ source.Interval = &interval
+ source.Days = []models.Weekday{models.Weekday(now.Weekday())}
+ })
+
+ It("outputs a version when all conditions match", func() {
+ Expect(response).To(HaveLen(1))
+ Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1))
})
})
})
})
+
+ Context("with validation errors", func() {
+ var source models.Source
+ var response models.CheckResponse
+ var cmdErr error
+
+ JustBeforeEach(func() {
+ command := resource.CheckCommand{}
+ response, cmdErr = command.Run(models.CheckRequest{
+ Source: source,
+ Version: models.Version{},
+ })
+ })
+
+ Context("when start is provided without stop", func() {
+ BeforeEach(func() {
+ source = models.Source{}
+ start := models.NewTimeOfDay(time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC))
+ source.Start = &start
+ })
+
+ It("returns a validation error", func() {
+ Expect(cmdErr).To(HaveOccurred())
+ Expect(response).To(BeNil())
+ })
+ })
+
+ Context("when stop is provided without start", func() {
+ BeforeEach(func() {
+ source = models.Source{}
+ stop := models.NewTimeOfDay(time.Date(2020, 1, 1, 17, 0, 0, 0, time.UTC))
+ source.Stop = &stop
+ })
+
+ It("returns a validation error", func() {
+ Expect(cmdErr).To(HaveOccurred())
+ Expect(response).To(BeNil())
+ })
+ })
+ })
+})
+
+var _ = Describe("DescribeCron", func() {
+ Context("common macros", func() {
+ DescribeTable("describes macros correctly",
+ func(expr, expected string) {
+ Expect(resource.DescribeCron(expr)).To(Equal(expected))
+ },
+ Entry("@yearly", "@yearly", "triggers once a year at midnight on January 1st"),
+ Entry("@annually", "@annually", "triggers once a year at midnight on January 1st"),
+ Entry("@monthly", "@monthly", "triggers at midnight on the 1st of every month"),
+ Entry("@weekly", "@weekly", "triggers at midnight every Sunday"),
+ Entry("@daily", "@daily", "triggers once a day at midnight"),
+ Entry("@midnight", "@midnight", "triggers once a day at midnight"),
+ Entry("@hourly", "@hourly", "triggers at the start of every hour"),
+ )
+ })
+
+ Context("time intervals", func() {
+ DescribeTable("describes intervals correctly",
+ func(expr, expected string) {
+ Expect(resource.DescribeCron(expr)).To(Equal(expected))
+ },
+ Entry("every 5 minutes", "*/5 * * * *", "triggers every 5 minutes"),
+ Entry("every 2 hours at minute 0", "0 */2 * * *", "triggers every 2 hours at minute 0"),
+ Entry("every 3 hours at minute 30", "30 */3 * * *", "triggers every 3 hours at minute 30"),
+ )
+ })
+
+ Context("specific times", func() {
+ DescribeTable("describes specific times correctly",
+ func(expr, expected string) {
+ Expect(resource.DescribeCron(expr)).To(Equal(expected))
+ },
+ Entry("09:30", "30 9 * * *", "triggers at 09:30"),
+ Entry("00:00", "0 0 * * *", "triggers at 00:00"),
+ Entry("minute 30 of every hour", "30 * * * *", "triggers at minute 30 of every hour"),
+ Entry("during hour 9", "* 9 * * *", "triggers during hour 9"),
+ )
+ })
+
+ Context("days of week", func() {
+ DescribeTable("describes days correctly",
+ func(expr, expected string) {
+ Expect(resource.DescribeCron(expr)).To(Equal(expected))
+ },
+ Entry("Monday (1)", "0 9 * * 1", "triggers on Monday, at 09:00"),
+ Entry("Sunday (0)", "0 9 * * 0", "triggers on Sunday, at 09:00"),
+ Entry("Friday (FRI)", "0 9 * * FRI", "triggers on Friday, at 09:00"),
+ )
+ })
+
+ Context("months", func() {
+ DescribeTable("describes months correctly",
+ func(expr, expected string) {
+ Expect(resource.DescribeCron(expr)).To(Equal(expected))
+ },
+ Entry("January", "0 9 * 1 *", "triggers in January, at 09:00"),
+ Entry("June", "0 9 * 6 *", "triggers in June, at 09:00"),
+ Entry("December", "0 9 * 12 *", "triggers in December, at 09:00"),
+ )
+ })
+
+ Context("day-of-month intervals", func() {
+ It("warns about back-to-back triggers when step lands on 31", func() {
+ result := resource.DescribeCron("0 0 */2 * *")
+ Expect(result).To(ContainSubstring("every 2 days from 1st of month"))
+ Expect(result).To(ContainSubstring("31st then 1st = back-to-back triggers"))
+ })
+
+ It("does not warn when step does not land on 31", func() {
+ result := resource.DescribeCron("0 0 */7 * *")
+ Expect(result).To(ContainSubstring("every 7 days from 1st of month"))
+ Expect(result).NotTo(ContainSubstring("back-to-back"))
+ })
+ })
+
+ Context("warnings", func() {
+ It("warns about day 31", func() {
+ result := resource.DescribeCron("0 0 31 * *")
+ Expect(result).To(ContainSubstring("only triggers in months with 31 days"))
+ })
+
+ It("warns about day 30 skipping February", func() {
+ result := resource.DescribeCron("0 0 30 * *")
+ Expect(result).To(ContainSubstring("skips February"))
+ })
+
+ It("warns about day 29 and leap years", func() {
+ result := resource.DescribeCron("0 0 29 * *")
+ Expect(result).To(ContainSubstring("only triggers in leap years for February"))
+ })
+
+ It("warns about DOM + DOW OR logic", func() {
+ result := resource.DescribeCron("0 0 15 * 1")
+ Expect(result).To(ContainSubstring("OR logic"))
+ })
+
+ It("warns about DST for hours 1-3", func() {
+ result := resource.DescribeCron("0 2 * * *")
+ Expect(result).To(ContainSubstring("DST"))
+ })
+
+ It("does not warn about DST for hour 0 or 4+", func() {
+ Expect(resource.DescribeCron("0 0 * * *")).NotTo(ContainSubstring("DST"))
+ Expect(resource.DescribeCron("0 4 * * *")).NotTo(ContainSubstring("DST"))
+ })
+ })
+
+ Context("modifiers", func() {
+ It("describes L (last day of month)", func() {
+ Expect(resource.DescribeCron("0 9 L * *")).To(Equal("triggers on the last day of the month, at 09:00"))
+ })
+
+ It("describes W (nearest weekday)", func() {
+ Expect(resource.DescribeCron("0 9 15W * *")).To(Equal("triggers on the nearest weekday to the 15th, at 09:00"))
+ })
+
+ It("warns about 31W in short months", func() {
+ result := resource.DescribeCron("0 9 31W * *")
+ Expect(result).To(ContainSubstring("nearest weekday to the 31st"))
+ Expect(result).To(ContainSubstring("only triggers in months with 31 days"))
+ })
+
+ It("describes 5L (last Friday)", func() {
+ Expect(resource.DescribeCron("0 17 * * 5L")).To(Equal("triggers on last Friday of the month, at 17:00"))
+ })
+
+ It("describes 1#2 (second Monday)", func() {
+ Expect(resource.DescribeCron("0 7 * * 1#2")).To(Equal("triggers on 2nd Monday of the month, at 07:00"))
+ })
+
+ It("warns about 5th occurrence", func() {
+ result := resource.DescribeCron("0 9 * * 2#5")
+ Expect(result).To(ContainSubstring("5th Tuesday of the month"))
+ Expect(result).To(ContainSubstring("5th occurrence only exists in some months"))
+ })
+ })
+
+ Context("edge cases", func() {
+ It("returns raw expression for invalid field count", func() {
+ Expect(resource.DescribeCron("* * *")).To(Equal("schedule: * * *"))
+ })
+
+ It("returns raw expression for all wildcards", func() {
+ Expect(resource.DescribeCron("* * * * *")).To(Equal("schedule: * * * * *"))
+ })
+
+ It("describes DOW ranges 0-2", func() {
+ Expect(resource.DescribeCron("0 9 * * 0-2")).To(Equal("triggers on Sunday through Tuesday, at 09:00"))
+ })
+
+ It("describes DOW ranges 1-5", func() {
+ Expect(resource.DescribeCron("0 9 * * 1-5")).To(Equal("triggers on Monday through Friday, at 09:00"))
+ })
+ })
})
func tod(hours, minutes, offset int) *models.TimeOfDay {
@@ -621,9 +836,7 @@ func tod(hours, minutes, offset int) *models.TimeOfDay {
if offset != 0 {
loc = time.FixedZone("UnitTest", 60*60*offset)
}
-
now := time.Now()
tod := models.NewTimeOfDay(time.Date(now.Year(), now.Month(), now.Day(), hours, minutes, 0, 0, loc))
-
return &tod
}
diff --git a/go.mod b/go.mod
index 7e12d4f..5f6b129 100644
--- a/go.mod
+++ b/go.mod
@@ -1,21 +1,24 @@
module github.com/concourse/time-resource
-go 1.24
+go 1.25.1
require (
- github.com/onsi/ginkgo/v2 v2.23.4
- github.com/onsi/gomega v1.38.0
+ github.com/adhocore/gronx v1.19.6
+ github.com/onsi/ginkgo/v2 v2.25.3
+ github.com/onsi/gomega v1.38.2
)
require (
+ github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
- github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect
+ github.com/google/pprof v0.0.0-20250919162441-8b542baf5bcf // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
- golang.org/x/net v0.42.0 // indirect
- golang.org/x/sys v0.34.0 // indirect
- golang.org/x/text v0.27.0 // indirect
- golang.org/x/tools v0.35.0 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/net v0.44.0 // indirect
+ golang.org/x/sys v0.36.0 // indirect
+ golang.org/x/text v0.29.0 // indirect
+ golang.org/x/tools v0.37.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 26154ba..0fd177f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,7 @@
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
+github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
@@ -8,14 +12,20 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ=
github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
+github.com/google/pprof v0.0.0-20250919162441-8b542baf5bcf h1:ekSWtXn05ARVCly6JvQLXzAqHfVR4Jq14KjVL1f+1JY=
+github.com/google/pprof v0.0.0-20250919162441-8b542baf5bcf/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
+github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw=
+github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY=
github.com/onsi/gomega v1.38.0/go.mod h1:OcXcwId0b9QsE7Y49u+BTrL4IdKOBOKnD6VQNTJEB6o=
+github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
+github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
@@ -24,14 +34,26 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
+golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
+golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
+golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
+golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
+golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
+golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
+golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/lord/time_lord.go b/lord/time_lord.go
index 087c428..38acbe0 100644
--- a/lord/time_lord.go
+++ b/lord/time_lord.go
@@ -3,6 +3,7 @@ package lord
import (
"time"
+ "github.com/adhocore/gronx"
"github.com/concourse/time-resource/models"
)
@@ -16,9 +17,51 @@ type TimeLord struct {
Interval *models.Interval
Days []models.Weekday
StartAfter *models.StartAfter
+ Cron *models.Cron
}
func (tl TimeLord) Check(now time.Time) bool {
+ if tl.Cron != nil {
+ nowInLoc := now.In(tl.loc())
+
+ // Check start_after constraint first
+ if tl.StartAfter != nil {
+ startAfter := time.Time(*tl.StartAfter)
+ startInLoc := time.Date(startAfter.Year(), startAfter.Month(), startAfter.Day(),
+ startAfter.Hour(), startAfter.Minute(), startAfter.Second(), 0, tl.loc())
+
+ if !startInLoc.Before(nowInLoc) {
+ return false
+ }
+ }
+
+ if tl.PreviousTime.IsZero() {
+ // No previous version exists.
+ // Find the most recent scheduled cron time <= now.
+ // This ensures the resource emits an initial version even if the check
+ // doesn't run at the exact cron minute.
+ //
+ // Example: @daily, check at 3pm → prevTick = today 00:00 → trigger
+ // Example: @5minutes, check at 3:07pm → prevTick = 3:05pm → trigger
+ prevTick, err := gronx.PrevTickBefore(tl.Cron.Expression, nowInLoc, true)
+ if err != nil {
+ return false
+ }
+ return !prevTick.IsZero()
+ }
+
+ // Previous version exists.
+ // Check if the next scheduled cron time after prevTime has passed.
+ // This handles late checks - if cron is :30 and check runs at :31,
+ // we still trigger because :30 has passed.
+ prevInLoc := tl.PreviousTime.In(tl.loc())
+ nextTime, err := tl.Cron.Next(prevInLoc)
+ if err != nil {
+ return false
+ }
+
+ return !nextTime.IsZero() && !nextTime.After(nowInLoc)
+ }
start, stop := tl.LatestRangeBefore(now)
@@ -31,7 +74,7 @@ func (tl TimeLord) Check(now time.Time) bool {
startInLoc := time.Date(startAfter.Year(), startAfter.Month(), startAfter.Day(),
startAfter.Hour(), startAfter.Minute(), startAfter.Second(), 0, tl.loc())
- if !startInLoc.Before(now) {
+ if !startInLoc.Before(now.In(tl.loc())) {
return false
}
}
@@ -45,21 +88,64 @@ func (tl TimeLord) Check(now time.Time) bool {
}
if tl.Interval != nil {
- if now.Sub(tl.PreviousTime) >= time.Duration(*tl.Interval) {
- return true
- }
- } else if !start.IsZero() {
- return tl.PreviousTime.Before(start)
+ return now.Sub(tl.PreviousTime) >= time.Duration(*tl.Interval)
}
- return false
+ return !start.IsZero() && tl.PreviousTime.Before(start)
}
func (tl TimeLord) Latest(reference time.Time) time.Time {
+ if tl.Cron != nil {
+ refInLoc := reference.In(tl.loc())
+
+ if tl.StartAfter != nil {
+ startAfter := time.Time(*tl.StartAfter)
+ startInLoc := time.Date(startAfter.Year(), startAfter.Month(), startAfter.Day(),
+ startAfter.Hour(), startAfter.Minute(), startAfter.Second(), 0, tl.loc())
+
+ if !startInLoc.Before(refInLoc) {
+ return time.Time{}
+ }
+ }
+
+ if tl.PreviousTime.IsZero() {
+ // Return the most recent scheduled cron time <= reference
+ prevTick, err := gronx.PrevTickBefore(tl.Cron.Expression, refInLoc, true)
+ if err != nil || prevTick.IsZero() {
+ return time.Time{}
+ }
+ return prevTick.UTC()
+ }
+
+ // Find the most recent cron time <= reference
+ // This handles cases where multiple cron times have passed since prev
+ latest, err := gronx.PrevTickBefore(tl.Cron.Expression, refInLoc, true)
+ if err != nil || latest.IsZero() {
+ return time.Time{}
+ }
+
+ // Only return if this is after the previous time (a new trigger)
+ prevInLoc := tl.PreviousTime.In(tl.loc())
+ if latest.After(prevInLoc) {
+ return latest.UTC()
+ }
+
+ return time.Time{}
+ }
+
if tl.PreviousTime.After(reference) {
return time.Time{}
}
+ if tl.StartAfter != nil {
+ startAfter := time.Time(*tl.StartAfter)
+ startInLoc := time.Date(startAfter.Year(), startAfter.Month(), startAfter.Day(),
+ startAfter.Hour(), startAfter.Minute(), startAfter.Second(), 0, tl.loc())
+ if !startInLoc.Before(reference.In(tl.loc())) {
+ return time.Time{}
+ }
+ }
+
refInLoc := reference.In(tl.loc())
for !tl.daysMatch(refInLoc) {
refInLoc = refInLoc.AddDate(0, 0, -1)
@@ -72,6 +158,10 @@ func (tl TimeLord) Latest(reference time.Time) time.Time {
}
if tl.Interval == nil {
+ // If we're past the stop time, return zero (no new version to emit)
+ if !reference.Before(stop) {
+ return time.Time{}
+ }
if tl.PreviousTime.After(start) {
return time.Time{}
}
@@ -88,10 +178,57 @@ func (tl TimeLord) Latest(reference time.Time) time.Time {
}
func (tl TimeLord) List(reference time.Time) []time.Time {
+ if tl.Cron != nil {
+ refInLoc := reference.In(tl.loc())
+
+ if tl.StartAfter != nil {
+ startAfter := time.Time(*tl.StartAfter)
+ startInLoc := time.Date(startAfter.Year(), startAfter.Month(), startAfter.Day(),
+ startAfter.Hour(), startAfter.Minute(), startAfter.Second(), 0, tl.loc())
+
+ if !startInLoc.Before(reference.In(tl.loc())) {
+ return []time.Time{}
+ }
+ }
+
+ if tl.PreviousTime.IsZero() {
+ // Return the most recent scheduled cron time <= reference
+ prevTick, err := gronx.PrevTickBefore(tl.Cron.Expression, refInLoc, true)
+ if err != nil || prevTick.IsZero() {
+ return []time.Time{}
+ }
+ return []time.Time{prevTick.UTC()}
+ }
+
+ // Get previous time in local timezone
+ start := tl.PreviousTime.In(tl.loc())
+
+ // Get all occurrences between previous and reference
+ maxOccurrences := 5
+ times := tl.Cron.NextN(start, refInLoc, maxOccurrences)
+
+ // Convert all times back to UTC
+ result := make([]time.Time, len(times))
+ for i, t := range times {
+ result[i] = t.UTC()
+ }
+
+ return result
+ }
+
start := tl.PreviousTime
+ versions := []time.Time{}
+
+ if tl.StartAfter != nil {
+ startAfter := time.Time(*tl.StartAfter)
+ startInLoc := time.Date(startAfter.Year(), startAfter.Month(), startAfter.Day(),
+ startAfter.Hour(), startAfter.Minute(), startAfter.Second(), 0, tl.loc())
+ if !startInLoc.Before(reference.In(tl.loc())) {
+ return versions
+ }
+ }
var addForRange func(time.Time, time.Time)
- versions := []time.Time{}
if tl.Interval == nil {
@@ -103,7 +240,11 @@ func (tl TimeLord) List(reference time.Time) []time.Time {
start = refRangeStart
}
- addForRange = func(dailyStart, _ time.Time) {
+ addForRange = func(dailyStart, dailyEnd time.Time) {
+ // Don't add if reference is past the stop time
+ if !reference.Before(dailyEnd) {
+ return
+ }
if !dailyStart.Before(start) && !dailyStart.After(reference) {
versions = append(versions, dailyStart)
}
@@ -173,7 +314,7 @@ func (tl TimeLord) LatestRangeBefore(reference time.Time) (time.Time, time.Time)
refInLoc := reference.In(tl.loc())
start := time.Date(refInLoc.Year(), refInLoc.Month(), refInLoc.Day(),
- tlStart.Hour(), tlStart.Minute(), 0, 0, tl.loc())
+ tlStart.Hour(), tlStart.Minute(), tlStart.Second(), 0, tl.loc())
if start.After(refInLoc) {
start = start.AddDate(0, 0, -1)
diff --git a/lord/time_lord_test.go b/lord/time_lord_test.go
index 668a4b9..75a2127 100644
--- a/lord/time_lord_test.go
+++ b/lord/time_lord_test.go
@@ -28,8 +28,11 @@ type testCase struct {
days []time.Weekday
start_after string
- prev string
- prevDay time.Weekday
+
+ cron string
+
+ prev string
+ prevDay time.Weekday
now string
extraTime time.Duration
@@ -93,6 +96,14 @@ func (tc testCase) Run() {
tl.Interval = (*models.Interval)(&interval)
}
+ if tc.cron != "" {
+ cronExpr := models.Cron{Expression: tc.cron}
+ err := cronExpr.Validate()
+ Expect(err).NotTo(HaveOccurred())
+
+ tl.Cron = &cronExpr
+ }
+
tl.Days = make([]models.Weekday, len(tc.days))
for i, d := range tc.days {
tl.Days[i] = models.Weekday(d)
@@ -105,6 +116,8 @@ func (tc testCase) Run() {
now = now.AddDate(0, 0, 1)
}
+ now = now.Add(tc.extraTime)
+
if tc.prev != "" {
tc.prev += " 2018"
prev, err := time.Parse(exampleFormatWithTZ, tc.prev)
@@ -138,7 +151,7 @@ func (tc testCase) Run() {
expected := tc.list[idx]
Expect(actual.Hour()).To(Equal(expected.hour))
Expect(actual.Minute()).To(Equal(expected.minute))
- Expect(latest.Second()).To(Equal(0))
+ Expect(actual.Second()).To(Equal(0))
Expect(actual.Weekday()).To(Equal(expected.weekday))
}
}
@@ -209,7 +222,6 @@ var _ = DescribeTable("A range without a previous time", (testCase).Run,
result: false,
latest: expectedTime{isZero: true},
}),
-
Entry("between the start and stop time but the stop time is before the start time, spanning more than a day", testCase{
start: "5:00 AM +0000",
stop: "1:00 AM +0000",
@@ -231,7 +243,6 @@ var _ = DescribeTable("A range without a previous time", (testCase).Run,
result: true,
latest: expectedTime{hour: 20, weekday: time.Saturday},
}),
-
Entry("between the start and stop time but the compare time is in a different timezone", testCase{
start: "2:00 AM -0600",
stop: "6:00 AM -0600",
@@ -239,7 +250,6 @@ var _ = DescribeTable("A range without a previous time", (testCase).Run,
result: true,
latest: expectedTime{hour: 8},
}),
-
Entry("covering almost a full day", testCase{
start: "12:01 AM -0700",
stop: "11:59 PM -0700",
@@ -273,108 +283,80 @@ var _ = DescribeTable("A range with a previous time", (testCase).Run,
stop: "5:00 AM +0000",
now: "4:00 AM +0000",
nowDay: time.Tuesday,
- prev: "9:00 AM +0000",
+ prev: "7:00 AM +0000",
prevDay: time.Monday,
result: true,
latest: expectedTime{hour: 10, weekday: time.Monday},
}),
- Entry("after now and in range on same day as now", testCase{
- start: "2:00 AM +0000",
- stop: "4:00 AM +0000",
- now: "3:00 AM +0000",
- prev: "3:30 AM +0000",
- result: false,
- latest: expectedTime{isZero: true},
- }),
- Entry("after now and out of range on same day as now", testCase{
- start: "2:00 AM +0000",
- stop: "4:00 AM +0000",
- now: "3:00 AM +0000",
- prev: "5:00 AM +0000",
- result: false,
- latest: expectedTime{isZero: true},
+ Entry("with stop before start and prev in the start day and now after the stop time", testCase{
+ start: "10:00 AM +0000",
+ stop: "5:00 AM +0000",
+ now: "6:00 AM +0000",
+ nowDay: time.Tuesday,
+ prev: "11:00 AM +0000",
+ prevDay: time.Monday,
+ result: false,
+ latest: expectedTime{isZero: true},
}),
-)
-
-var _ = DescribeTable("A range with a location and no previous time", (testCase).Run,
- Entry("between the start and stop time in a given location", testCase{
- location: "America/Indiana/Indianapolis",
- start: "1:00 PM",
- stop: "3:00 PM",
- now: "6:00 PM +0000",
- result: true,
- latest: expectedTime{hour: 13},
- }),
- Entry("between the start and stop time in a given location on a matching day", testCase{
- location: "America/Indiana/Indianapolis",
- start: "1:00 PM",
- stop: "3:00 PM",
- days: []time.Weekday{time.Wednesday},
- now: "6:00 PM +0000",
- nowDay: time.Wednesday,
- result: true,
- latest: expectedTime{hour: 13, weekday: time.Wednesday},
+ Entry("with stop before start and prev before the range and now after the start time", testCase{
+ start: "10:00 AM +0000",
+ stop: "5:00 AM +0000",
+ now: "11:00 AM +0000",
+ nowDay: time.Tuesday,
+ prev: "8:00 AM +0000",
+ prevDay: time.Tuesday,
+ result: true,
+ latest: expectedTime{hour: 10, weekday: time.Tuesday},
}),
- Entry("not between the start and stop time in a given location", testCase{
- location: "America/Indiana/Indianapolis",
- start: "1:00 PM",
- stop: "3:00 PM",
- now: "8:00 PM +0000",
- result: false,
- latest: expectedTime{isZero: true},
+ Entry("with different days where now is correct but prev is incorrect day", testCase{
+ start: "10:00 AM +0000",
+ stop: "5:00 AM +0000",
+ now: "11:00 AM +0000",
+ nowDay: time.Tuesday,
+ prev: "11:00 AM +0000",
+ prevDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 10, weekday: time.Tuesday},
}),
- Entry("between the start and stop time in a given location but not on a matching day", testCase{
- location: "America/Indiana/Indianapolis",
- start: "1:00 PM",
- stop: "3:00 PM",
- days: []time.Weekday{time.Wednesday},
- now: "6:00 PM +0000",
- nowDay: time.Thursday,
- result: false,
- latest: expectedTime{isZero: true},
+ Entry("with stop before start and prev in the stop day", testCase{
+ start: "10:00 AM +0000",
+ stop: "5:00 AM +0000",
+ now: "6:00 AM +0000",
+ nowDay: time.Tuesday,
+ prev: "4:00 AM +0000",
+ prevDay: time.Tuesday,
+ result: false,
+ latest: expectedTime{isZero: true},
}),
- Entry("between the start and stop time in a given location and on a matching day compared to UTC", testCase{
- location: "America/Indiana/Indianapolis",
- start: "9:00 PM",
- stop: "11:00 PM",
- days: []time.Weekday{time.Wednesday},
- now: "2:00 AM +0000",
- nowDay: time.Thursday,
- result: true,
- latest: expectedTime{hour: 21, weekday: time.Wednesday},
+ Entry("with stop before start and prev in the stop day and now in the start day", testCase{
+ start: "10:00 AM +0000",
+ stop: "5:00 AM +0000",
+ now: "11:00 AM +0000",
+ nowDay: time.Tuesday,
+ prev: "4:00 AM +0000",
+ prevDay: time.Tuesday,
+ result: true,
+ latest: expectedTime{hour: 10, weekday: time.Tuesday},
}),
-)
-
-var _ = DescribeTable("A range with a location and a previous time", (testCase).Run,
- Entry("between the start and stop time in a given location, on a new day", testCase{
- location: "America/Indiana/Indianapolis",
- start: "1:00 PM",
- stop: "3:00 PM",
-
- prev: "6:00 PM +0000",
- prevDay: time.Wednesday,
- now: "6:00 PM +0000",
- nowDay: time.Thursday,
-
- result: true,
- latest: expectedTime{hour: 13, weekday: time.Thursday},
- list: []expectedTime{
- {hour: 13, weekday: time.Wednesday},
- {hour: 13, weekday: time.Thursday},
- },
+ Entry("in the range", testCase{
+ start: "2:00 AM +0000",
+ stop: "4:00 AM +0000",
+ now: "3:00 AM +0000",
+ nowDay: time.Monday,
+ prev: "2:30 AM +0000",
+ prevDay: time.Monday,
+ result: false,
+ latest: expectedTime{isZero: true},
}),
- Entry("not between the start and stop time in a given location, on the same day", testCase{
- location: "America/Indiana/Indianapolis",
- start: "1:00 PM",
- stop: "3:00 PM",
-
- prev: "6:00 PM +0000",
- prevDay: time.Wednesday,
- now: "6:01 PM +0000",
- nowDay: time.Wednesday,
-
- result: false,
- latest: expectedTime{hour: 13, weekday: time.Wednesday},
+ Entry("behind the range", testCase{
+ start: "2:00 AM +0000",
+ stop: "4:00 AM +0000",
+ now: "4:30 AM +0000",
+ nowDay: time.Monday,
+ prev: "2:00 AM +0000",
+ prevDay: time.Monday,
+ result: false,
+ latest: expectedTime{isZero: true},
}),
)
@@ -408,17 +390,14 @@ var _ = DescribeTable("An interval", (testCase).Run,
var _ = DescribeTable("A range with an interval and a previous time", (testCase).Run,
Entry("between the start and stop time, on a new day", testCase{
interval: "2m",
-
- start: "1:00 PM +0000",
- stop: "3:00 PM +0000",
-
- prev: "2:58 PM +0000",
- prevDay: time.Wednesday,
- now: "1:00 PM +0000",
- nowDay: time.Thursday,
-
- result: true,
- latest: expectedTime{hour: 13, weekday: time.Thursday},
+ start: "1:00 PM +0000",
+ stop: "3:00 PM +0000",
+ prev: "2:58 PM +0000",
+ prevDay: time.Wednesday,
+ now: "1:00 PM +0000",
+ nowDay: time.Thursday,
+ result: true,
+ latest: expectedTime{hour: 13, weekday: time.Thursday},
list: []expectedTime{
{hour: 14, minute: 58, weekday: time.Wednesday},
{hour: 13, weekday: time.Thursday},
@@ -426,15 +405,12 @@ var _ = DescribeTable("A range with an interval and a previous time", (testCase)
}),
Entry("between the start and stop time, elapsed", testCase{
interval: "2m",
-
- start: "1:00 PM +0000",
- stop: "3:00 PM +0000",
-
- prev: "1:02 PM +0000",
- now: "1:04 PM +0000",
-
- result: true,
- latest: expectedTime{hour: 13, minute: 4},
+ start: "1:00 PM +0000",
+ stop: "3:00 PM +0000",
+ prev: "1:02 PM +0000",
+ now: "1:04 PM +0000",
+ result: true,
+ latest: expectedTime{hour: 13, minute: 4},
list: []expectedTime{
{hour: 13, minute: 2},
{hour: 13, minute: 4},
@@ -442,27 +418,21 @@ var _ = DescribeTable("A range with an interval and a previous time", (testCase)
}),
Entry("between the start and stop time, not elapsed", testCase{
interval: "2m",
-
- start: "1:00 PM +0000",
- stop: "3:00 PM +0000",
-
- prev: "1:02 PM +0000",
- now: "1:03 PM +0000",
-
- result: false,
- latest: expectedTime{hour: 13, minute: 2},
+ start: "1:00 PM +0000",
+ stop: "3:00 PM +0000",
+ prev: "1:02 PM +0000",
+ now: "1:03 PM +0000",
+ result: false,
+ latest: expectedTime{hour: 13, minute: 2},
}),
Entry("not between the start and stop time, elapsed", testCase{
interval: "2m",
-
- start: "1:00 PM +0000",
- stop: "3:00 PM +0000",
-
- prev: "2:58 PM +0000",
- now: "3:02 PM +0000",
-
- result: false,
- latest: expectedTime{hour: 14, minute: 58},
+ start: "1:00 PM +0000",
+ stop: "3:00 PM +0000",
+ prev: "2:58 PM +0000",
+ now: "3:02 PM +0000",
+ result: false,
+ latest: expectedTime{hour: 14, minute: 58},
}),
)
@@ -558,3 +528,594 @@ var _ = DescribeTable("Start time with a range and interval", (testCase).Run,
latest: expectedTime{isZero: true},
}),
)
+
+var _ = DescribeTable("A cron expression without a previous time", (testCase).Run,
+ Entry("exactly at the cron time (minute)", testCase{
+ cron: "30 * * * *",
+ now: "1:30 PM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 13, minute: 30, weekday: time.Monday},
+ }),
+ Entry("one minute before the cron time", testCase{
+ cron: "30 * * * *",
+ now: "1:29 PM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 12, minute: 30, weekday: time.Monday},
+ }),
+ Entry("at a specific hour and minute", testCase{
+ cron: "15 9 * * *",
+ now: "9:15 AM +0000",
+ nowDay: time.Tuesday,
+ result: true,
+ latest: expectedTime{hour: 9, minute: 15, weekday: time.Tuesday},
+ }),
+ Entry("at a specific hour and minute on specific days", testCase{
+ cron: "15 9 * * 1-5",
+ now: "9:15 AM +0000",
+ nowDay: time.Wednesday,
+ result: true,
+ latest: expectedTime{hour: 9, minute: 15, weekday: time.Wednesday},
+ }),
+ Entry("at a specific hour and minute but not on specified days", testCase{
+ cron: "15 9 * * 1-5",
+ now: "9:15 AM +0000",
+ nowDay: time.Sunday,
+ result: true,
+ latest: expectedTime{hour: 9, minute: 15, weekday: time.Friday},
+ }),
+)
+
+var _ = DescribeTable("A cron expression with a timezone", (testCase).Run,
+ Entry("at the cron time with fixed offset", testCase{
+ cron: "0 9 * * *",
+ location: "Etc/GMT+5",
+ now: "2:00 PM +0000",
+ nowDay: time.Thursday,
+ result: true,
+ latest: expectedTime{hour: 14, minute: 0, weekday: time.Thursday},
+ }),
+)
+
+var _ = DescribeTable("A cron expression with complex patterns", (testCase).Run,
+ Entry("with range of hours", testCase{
+ cron: "0 9-17 * * *",
+ now: "2:00 PM +0000",
+ nowDay: time.Thursday,
+ result: true,
+ latest: expectedTime{hour: 14, minute: 0, weekday: time.Thursday},
+ }),
+ Entry("with step values", testCase{
+ cron: "0 */2 * * *",
+ now: "2:00 PM +0000",
+ nowDay: time.Thursday,
+ result: true,
+ latest: expectedTime{hour: 14, minute: 0, weekday: time.Thursday},
+ }),
+ Entry("with step values at odd hour", testCase{
+ cron: "0 */2 * * *",
+ now: "3:00 PM +0000",
+ nowDay: time.Thursday,
+ result: true,
+ latest: expectedTime{hour: 14, minute: 0, weekday: time.Thursday},
+ }),
+)
+
+var _ = DescribeTable("A cron expression with edge cases", (testCase).Run,
+ Entry("at midnight", testCase{
+ cron: "0 0 * * *",
+ now: "12:00 AM +0000",
+ nowDay: time.Friday,
+ result: true,
+ latest: expectedTime{hour: 0, minute: 0, weekday: time.Friday},
+ }),
+ Entry("with last day of week", testCase{
+ cron: "0 12 * * 6",
+ now: "12:00 PM +0000",
+ nowDay: time.Saturday,
+ result: true,
+ latest: expectedTime{hour: 12, minute: 0, weekday: time.Saturday},
+ }),
+ Entry("with last minute of hour", testCase{
+ cron: "59 * * * *",
+ now: "1:59 PM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 13, minute: 59, weekday: time.Monday},
+ }),
+ Entry("with name of weekday", testCase{
+ cron: "0 12 * * MON",
+ now: "12:00 PM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 12, minute: 0, weekday: time.Monday},
+ }),
+)
+
+var _ = DescribeTable("A cron expression with a previous time", (testCase).Run,
+ Entry("next cron time has passed", testCase{
+ cron: "30 * * * *",
+ prev: "12:30 PM +0000",
+ prevDay: time.Monday,
+ now: "1:31 PM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 13, minute: 30, weekday: time.Monday},
+ }),
+ Entry("next cron time has not passed", testCase{
+ cron: "30 * * * *",
+ prev: "12:30 PM +0000",
+ prevDay: time.Monday,
+ now: "1:29 PM +0000",
+ nowDay: time.Monday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ }),
+ Entry("exactly at next cron time", testCase{
+ cron: "30 * * * *",
+ prev: "12:30 PM +0000",
+ prevDay: time.Monday,
+ now: "1:30 PM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 13, minute: 30, weekday: time.Monday},
+ }),
+ Entry("multiple cron times have passed", testCase{
+ cron: "30 * * * *",
+ prev: "12:30 PM +0000",
+ prevDay: time.Monday,
+ now: "3:45 PM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 15, minute: 30, weekday: time.Monday},
+ list: []expectedTime{
+ {hour: 13, minute: 30, weekday: time.Monday},
+ {hour: 14, minute: 30, weekday: time.Monday},
+ {hour: 15, minute: 30, weekday: time.Monday},
+ },
+ }),
+)
+
+var _ = DescribeTable("A cron expression with start_after", (testCase).Run,
+ Entry("start_after in future, no previous time", testCase{
+ cron: "0 9 * * *",
+ start_after: "2025-01-01T00:00:00",
+ now: "9:00 AM +0000",
+ nowDay: time.Monday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ list: []expectedTime{},
+ }),
+ Entry("start_after in past, no previous time, at cron time", testCase{
+ cron: "0 9 * * *",
+ start_after: "2017-12-31T00:00:00",
+ now: "9:00 AM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 9, minute: 0, weekday: time.Monday},
+ }),
+ Entry("start_after in past, with previous time, next cron passed", testCase{
+ cron: "0 * * * *",
+ start_after: "2017-12-31T00:00:00",
+ prev: "9:00 AM +0000",
+ prevDay: time.Monday,
+ now: "10:05 AM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 10, minute: 0, weekday: time.Monday},
+ }),
+ Entry("start_after one second before now", testCase{
+ cron: "0 9 * * *",
+ start_after: "2018-01-01T08:59:59",
+ now: "9:00 AM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 9, minute: 0, weekday: time.Monday},
+ }),
+)
+
+var _ = DescribeTable("A cron expression with timezone and previous time", (testCase).Run,
+ Entry("timezone affects next calculation", testCase{
+ cron: "0 9 * * *",
+ location: "America/New_York",
+ prev: "2:00 PM +0000",
+ prevDay: time.Monday,
+ now: "2:05 PM +0000",
+ nowDay: time.Tuesday,
+ result: true,
+ latest: expectedTime{hour: 14, minute: 0, weekday: time.Tuesday},
+ }),
+ Entry("timezone with prev, next not passed", testCase{
+ cron: "0 9 * * *",
+ location: "America/New_York",
+ prev: "2:00 PM +0000",
+ prevDay: time.Monday,
+ now: "1:00 PM +0000",
+ nowDay: time.Tuesday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ }),
+ Entry("timezone crossing midnight", testCase{
+ cron: "0 23 * * *",
+ location: "America/Los_Angeles",
+ now: "7:00 AM +0000",
+ nowDay: time.Wednesday,
+ result: true,
+ latest: expectedTime{hour: 7, minute: 0, weekday: time.Wednesday},
+ }),
+)
+
+var _ = DescribeTable("A range with days filter", (testCase).Run,
+ Entry("on allowed day", testCase{
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ days: []time.Weekday{time.Monday, time.Wednesday, time.Friday},
+ now: "10:00 AM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 9, minute: 0, weekday: time.Monday},
+ }),
+ Entry("not on allowed day", testCase{
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ days: []time.Weekday{time.Monday, time.Wednesday, time.Friday},
+ now: "10:00 AM +0000",
+ nowDay: time.Tuesday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ }),
+ Entry("empty days means all days allowed", testCase{
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ days: []time.Weekday{},
+ now: "10:00 AM +0000",
+ nowDay: time.Saturday,
+ result: true,
+ latest: expectedTime{hour: 9, minute: 0, weekday: time.Saturday},
+ }),
+ Entry("days filter with previous time", testCase{
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ days: []time.Weekday{time.Monday, time.Wednesday},
+ prev: "10:00 AM +0000",
+ prevDay: time.Monday,
+ now: "10:00 AM +0000",
+ nowDay: time.Wednesday,
+ result: true,
+ latest: expectedTime{hour: 9, minute: 0, weekday: time.Wednesday},
+ }),
+)
+
+var _ = DescribeTable("Days filter with location", (testCase).Run,
+ Entry("timezone makes it different day", testCase{
+ location: "Pacific/Auckland",
+ start: "9:00 AM",
+ stop: "5:00 PM",
+ days: []time.Weekday{time.Tuesday},
+ now: "8:00 PM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 9, minute: 0, weekday: time.Tuesday},
+ }),
+ Entry("timezone makes it wrong day", testCase{
+ location: "Pacific/Auckland",
+ start: "9:00 AM",
+ stop: "5:00 PM",
+ days: []time.Weekday{time.Monday},
+ now: "8:00 PM +0000",
+ nowDay: time.Monday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ }),
+)
+
+var _ = DescribeTable("Interval with days filter", (testCase).Run,
+ Entry("interval on allowed day", testCase{
+ interval: "1h",
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ days: []time.Weekday{time.Monday, time.Wednesday, time.Friday},
+ now: "10:00 AM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 10, minute: 0, weekday: time.Monday},
+ }),
+ Entry("interval on non-allowed day", testCase{
+ interval: "1h",
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ days: []time.Weekday{time.Monday, time.Wednesday, time.Friday},
+ now: "10:00 AM +0000",
+ nowDay: time.Tuesday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ }),
+)
+
+var _ = DescribeTable("Every minute cron expression", (testCase).Run,
+ Entry("no previous, triggers immediately", testCase{
+ cron: "* * * * *",
+ now: "3:47 PM +0000",
+ nowDay: time.Tuesday,
+ result: true,
+ latest: expectedTime{hour: 15, minute: 47, weekday: time.Tuesday},
+ }),
+ Entry("with previous 1 minute ago", testCase{
+ cron: "* * * * *",
+ prev: "3:46 PM +0000",
+ prevDay: time.Tuesday,
+ now: "3:47 PM +0000",
+ nowDay: time.Tuesday,
+ result: true,
+ latest: expectedTime{hour: 15, minute: 47, weekday: time.Tuesday},
+ }),
+ Entry("with previous same minute - no trigger", testCase{
+ cron: "* * * * *",
+ prev: "3:47 PM +0000",
+ prevDay: time.Tuesday,
+ now: "3:47 PM +0000",
+ extraTime: 30 * time.Second,
+ nowDay: time.Tuesday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ }),
+)
+
+var _ = DescribeTable("Special cron expressions", (testCase).Run,
+ Entry("@hourly at exact hour", testCase{
+ cron: "@hourly",
+ now: "3:00 PM +0000",
+ nowDay: time.Wednesday,
+ result: true,
+ latest: expectedTime{hour: 15, minute: 0, weekday: time.Wednesday},
+ }),
+ Entry("@hourly between hours", testCase{
+ cron: "@hourly",
+ now: "3:30 PM +0000",
+ nowDay: time.Wednesday,
+ result: true,
+ latest: expectedTime{hour: 15, minute: 0, weekday: time.Wednesday},
+ }),
+ Entry("@daily at midnight", testCase{
+ cron: "@daily",
+ now: "12:00 AM +0000",
+ nowDay: time.Thursday,
+ result: true,
+ latest: expectedTime{hour: 0, minute: 0, weekday: time.Thursday},
+ }),
+ Entry("@daily in afternoon finds today's midnight", testCase{
+ cron: "@daily",
+ now: "3:00 PM +0000",
+ nowDay: time.Thursday,
+ result: true,
+ latest: expectedTime{hour: 0, minute: 0, weekday: time.Thursday},
+ }),
+ Entry("@weekly on Sunday midnight", testCase{
+ cron: "@weekly",
+ now: "12:00 AM +0000",
+ nowDay: time.Sunday,
+ result: true,
+ latest: expectedTime{hour: 0, minute: 0, weekday: time.Sunday},
+ }),
+)
+
+var _ = DescribeTable("List edge cases", (testCase).Run,
+ Entry("list with no matches returns empty", testCase{
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ now: "6:00 PM +0000",
+ nowDay: time.Monday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ list: []expectedTime{},
+ }),
+)
+
+var _ = DescribeTable("Previous time edge cases", (testCase).Run,
+ Entry("previous time after now returns zero latest", testCase{
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ prev: "10:00 AM +0000",
+ prevDay: time.Tuesday,
+ now: "9:30 AM +0000",
+ nowDay: time.Monday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ }),
+)
+
+var _ = DescribeTable("Multi-day scenarios", (testCase).Run,
+ Entry("overnight range with prev in evening, now in morning", testCase{
+ start: "10:00 PM +0000",
+ stop: "6:00 AM +0000",
+ prev: "11:00 PM +0000",
+ prevDay: time.Monday,
+ now: "3:00 AM +0000",
+ nowDay: time.Tuesday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ }),
+ Entry("overnight range with prev before range, now in morning", testCase{
+ start: "10:00 PM +0000",
+ stop: "6:00 AM +0000",
+ prev: "9:00 PM +0000",
+ prevDay: time.Monday,
+ now: "3:00 AM +0000",
+ nowDay: time.Tuesday,
+ result: true,
+ latest: expectedTime{hour: 22, minute: 0, weekday: time.Monday},
+ }),
+)
+
+var _ = DescribeTable("Cron with comma-separated values", (testCase).Run,
+ Entry("multiple minutes", testCase{
+ cron: "0,15,30,45 * * * *",
+ now: "3:17 PM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 15, minute: 15, weekday: time.Monday},
+ }),
+ Entry("multiple hours", testCase{
+ cron: "0 9,12,15,18 * * *",
+ now: "1:00 PM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 12, minute: 0, weekday: time.Monday},
+ }),
+ Entry("multiple days of week", testCase{
+ cron: "0 12 * * 1,3,5",
+ now: "12:00 PM +0000",
+ nowDay: time.Wednesday,
+ result: true,
+ latest: expectedTime{hour: 12, minute: 0, weekday: time.Wednesday},
+ }),
+)
+
+var _ = DescribeTable("Only start time specified", (testCase).Run,
+ Entry("after start time, no prev", testCase{
+ start: "9:00 AM +0000",
+ now: "10:00 AM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 9, minute: 0, weekday: time.Monday},
+ }),
+ Entry("before start time, no prev", testCase{
+ start: "9:00 AM +0000",
+ now: "8:00 AM +0000",
+ nowDay: time.Monday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ }),
+)
+
+var _ = DescribeTable("Only stop time specified", (testCase).Run,
+ Entry("before stop time, no prev", testCase{
+ stop: "5:00 PM +0000",
+ now: "3:00 PM +0000",
+ nowDay: time.Monday,
+ result: true,
+ latest: expectedTime{hour: 0, minute: 0, weekday: time.Monday},
+ }),
+ Entry("after stop time, no prev", testCase{
+ stop: "5:00 PM +0000",
+ now: "6:00 PM +0000",
+ nowDay: time.Monday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ }),
+)
+
+var _ = DescribeTable("StartAfter with non-cron configurations", (testCase).Run,
+ // Bug #2 fix: Check() non-cron path with StartAfter
+ Entry("start_after with range, now before start_after", testCase{
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ start_after: "2025-01-15T12:00:00",
+ now: "10:00 AM +0000",
+ nowDay: time.Wednesday,
+ // now (2018) is before start_after (2025), should not trigger
+ result: false,
+ latest: expectedTime{isZero: true},
+ list: []expectedTime{},
+ }),
+ Entry("start_after with range, now after start_after", testCase{
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ start_after: "2017-01-01T00:00:00",
+ now: "10:00 AM +0000",
+ nowDay: time.Wednesday,
+ // now (2018) is after start_after (2017), should trigger
+ result: true,
+ latest: expectedTime{hour: 9, minute: 0, weekday: time.Wednesday},
+ }),
+ Entry("start_after with interval, now before start_after", testCase{
+ interval: "1h",
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ start_after: "2025-01-15T12:00:00",
+ now: "11:00 AM +0000",
+ nowDay: time.Wednesday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ list: []expectedTime{},
+ }),
+ Entry("start_after with interval, now after start_after", testCase{
+ interval: "1h",
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ start_after: "2017-01-01T00:00:00",
+ now: "11:00 AM +0000",
+ nowDay: time.Wednesday,
+ result: true,
+ latest: expectedTime{hour: 11, minute: 0, weekday: time.Wednesday},
+ }),
+
+ // Bug #2 fix: timezone handling in non-cron StartAfter check
+ Entry("start_after with location, boundary test", testCase{
+ location: "America/New_York",
+ start: "9:00 AM",
+ stop: "5:00 PM",
+ start_after: "2018-01-03T10:00:00", // interpreted as 10:00 AM New York
+ now: "3:00 PM +0000", // 10:00 AM New York
+ nowDay: time.Wednesday,
+ // now equals start_after, should not trigger (needs to be strictly after)
+ result: false,
+ latest: expectedTime{isZero: true},
+ list: []expectedTime{},
+ }),
+ Entry("start_after with location, one minute after", testCase{
+ location: "America/New_York",
+ start: "9:00 AM",
+ stop: "5:00 PM",
+ start_after: "2018-01-03T10:00:00",
+ now: "3:01 PM +0000",
+ nowDay: time.Wednesday,
+ result: true,
+ latest: expectedTime{hour: 9, minute: 0, weekday: time.Wednesday}, // local hour, not UTC
+ }),
+
+ // With previous time
+ Entry("start_after with range and prev, now after start_after", testCase{
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ start_after: "2017-01-01T00:00:00",
+ prev: "8:00 AM +0000",
+ prevDay: time.Tuesday,
+ now: "10:00 AM +0000",
+ nowDay: time.Wednesday,
+ result: true,
+ latest: expectedTime{hour: 9, minute: 0, weekday: time.Wednesday},
+ }),
+ Entry("start_after with range and prev, now before start_after", testCase{
+ start: "9:00 AM +0000",
+ stop: "5:00 PM +0000",
+ start_after: "2025-01-15T12:00:00",
+ prev: "8:00 AM +0000",
+ prevDay: time.Tuesday,
+ now: "10:00 AM +0000",
+ nowDay: time.Wednesday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ list: []expectedTime{},
+ }),
+)
+
+var _ = DescribeTable("StartAfter edge cases", (testCase).Run,
+ // start_after alone (no range, no interval, no cron)
+ Entry("start_after only, now before", testCase{
+ start_after: "2025-01-15T00:00:00",
+ now: "10:00 AM +0000",
+ nowDay: time.Wednesday,
+ result: false,
+ latest: expectedTime{isZero: true},
+ list: []expectedTime{},
+ }),
+ Entry("start_after only, now after", testCase{
+ start_after: "2017-01-01T00:00:00",
+ now: "10:00 AM +0000",
+ nowDay: time.Wednesday,
+ result: true,
+ latest: expectedTime{hour: 0, minute: 0, weekday: time.Wednesday},
+ }),
+)
diff --git a/models/models.go b/models/models.go
index d471177..c7e0782 100644
--- a/models/models.go
+++ b/models/models.go
@@ -6,6 +6,8 @@ import (
"fmt"
"strings"
"time"
+
+ "github.com/adhocore/gronx"
)
type Version struct {
@@ -46,9 +48,17 @@ type Source struct {
Days []Weekday `json:"days"`
Location *Location `json:"location"`
StartAfter *StartAfter `json:"start_after"`
+ Cron *Cron `json:"cron"`
}
func (source Source) Validate() error {
+ // Validate interval/start/stop/days and cron are not specified together
+ if source.Cron != nil {
+ if source.Interval != nil || source.Start != nil || source.Stop != nil || len(source.Days) > 0 {
+ return errors.New("cannot configure 'interval' or 'start'/'stop' or 'days' with 'cron'")
+ }
+ }
+
// Validate start and stop are both set or both unset
if (source.Start != nil) != (source.Stop != nil) {
if source.Start != nil {
@@ -64,6 +74,13 @@ func (source Source) Validate() error {
}
}
+ // Validate cron expression if provided
+ if source.Cron != nil {
+ if err := source.Cron.Validate(); err != nil {
+ return fmt.Errorf("invalid cron expression: %v", err)
+ }
+ }
+
return nil
}
@@ -150,7 +167,7 @@ func (tod *TimeOfDay) UnmarshalJSON(payload []byte) error {
var t time.Time
for _, format := range timeFormats {
- t, err = time.Parse(format, timeStr)
+ t, err = time.Parse(format, strings.ToUpper(timeStr))
if err == nil {
break
}
@@ -176,6 +193,10 @@ func (tod TimeOfDay) Minute() int {
return int(time.Duration(tod) % time.Hour / time.Minute)
}
+func (t TimeOfDay) Second() int {
+ return int(time.Duration(t).Seconds()) % 60
+}
+
func (tod TimeOfDay) String() string {
return fmt.Sprintf("%d:%02d", tod.Hour(), tod.Minute())
}
@@ -225,6 +246,8 @@ func (wd Weekday) MarshalJSON() ([]byte, error) {
}
var dateTimeFormats = []string{
+ time.RFC3339,
+ "2006-01-02T15:04:05-07:00",
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02T15",
@@ -262,3 +285,105 @@ func (sa StartAfter) MarshalJSON() ([]byte, error) {
StartAfterStr := time.Time(sa).Format("2006-01-02T15:04:05")
return json.Marshal(StartAfterStr)
}
+
+type Cron struct {
+ Expression string
+}
+
+func (c *Cron) UnmarshalJSON(payload []byte) error {
+ var cronStr string
+ err := json.Unmarshal(payload, &cronStr)
+ if err != nil {
+ return err
+ }
+
+ g := gronx.New()
+ if !g.IsValid(cronStr) {
+ return fmt.Errorf("invalid cron expression: %s", cronStr)
+ }
+
+ c.Expression = cronStr
+ return nil
+}
+
+func (c Cron) MarshalJSON() ([]byte, error) {
+ return json.Marshal(c.Expression)
+}
+
+func (c Cron) Validate() error {
+ g := gronx.New()
+ if !g.IsValid(c.Expression) {
+ return fmt.Errorf("invalid cron expression: %s", c.Expression)
+ }
+
+ // Check if the cron expression would run too frequently (less than 60 seconds)
+ if err := c.validateMinimumInterval(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// validateMinimumInterval ensures no specific seconds are specified (to always run on minute boundaries)
+func (c Cron) validateMinimumInterval() error {
+ if strings.HasPrefix(c.Expression, "@") {
+ if c.Expression == "@everysecond" {
+ return errors.New("@everysecond is not supported: cron expressions must not specify seconds")
+ }
+
+ // Unknown macro - let it pass if gronx accepted it, assuming it doesn't operate at the second level
+ return nil
+ }
+
+ // Check if the expression is a 6-field cron format (includes seconds)
+ fields := strings.Fields(c.Expression)
+
+ // If there are 6 fields, it's a cron expression with seconds, which we want to disallow
+ if len(fields) == 6 {
+ return errors.New("cron expressions with seconds field are not supported: use 5-field format")
+ }
+
+ return nil
+}
+
+// parsePositiveInt parses a string to an integer and ensures it's positive
+func parsePositiveInt(s string) (int, error) {
+ var value int
+ _, err := fmt.Sscanf(s, "%d", &value)
+ if err != nil {
+ return 0, err
+ }
+
+ if value <= 0 {
+ return 0, errors.New("value must be positive")
+ }
+
+ return value, nil
+}
+
+func (c *Cron) Next(t time.Time) (time.Time, error) {
+ return gronx.NextTickAfter(c.Expression, t, false)
+}
+
+// NextIncludingCurrent returns the next time including the reference time if it matches
+func (c *Cron) NextIncludingCurrent(t time.Time) (time.Time, error) {
+ return gronx.NextTickAfter(c.Expression, t, true)
+}
+
+// NextN returns all next cron times between after and before
+func (c *Cron) NextN(after time.Time, before time.Time, n int) []time.Time {
+ var times []time.Time
+ next, err := c.Next(after)
+ if err != nil {
+ return nil
+ }
+
+ for len(times) < n && !next.After(before) {
+ times = append(times, next)
+ next, err = c.Next(next)
+ if err != nil {
+ break
+ }
+ }
+ return times
+}
diff --git a/models/source_test.go b/models/source_test.go
index df25aa1..41a7b5b 100644
--- a/models/source_test.go
+++ b/models/source_test.go
@@ -73,4 +73,260 @@ var _ = Describe("Source", func() {
Expect(source.Stop.Hour()).To(Equal(source.Start.Hour() + 12))
})
})
+
+ Context("when using cron expressions", func() {
+ Context("with both interval and cron specified", func() {
+ BeforeEach(func() {
+ config = `{ "interval": "1h", "cron": "*/5 * * * *" }`
+ })
+
+ It("generates a validation error", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(Equal("cannot configure 'interval' or 'start'/'stop' or 'days' with 'cron'"))
+ })
+ })
+
+ Context("with both interval and days and cron specified", func() {
+ BeforeEach(func() {
+ config = `{ "interval": "1h", "days": ["Monday"], "cron": "*/5 * * * *" }`
+ })
+
+ It("generates a validation error", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(Equal("cannot configure 'interval' or 'start'/'stop' or 'days' with 'cron'"))
+ })
+ })
+
+ Context("with both interval and start/stop and cron specified", func() {
+ BeforeEach(func() {
+ config = `{ "interval": "1h", "start": "6AM", "stop": "7am", "cron": "*/5 * * * *" }`
+ })
+
+ It("generates a validation error", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(Equal("cannot configure 'interval' or 'start'/'stop' or 'days' with 'cron'"))
+ })
+ })
+
+ Context("with an invalid cron expression", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "invalid expression" }`
+ })
+
+ It("generates a validation error", func() {
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid cron expression"))
+ })
+ })
+
+ Context("with a cron expression that runs every second (6-field)", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "* * * * * *" }`
+ })
+
+ It("generates a validation error for having seconds field", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported"))
+ })
+ })
+
+ Context("with a cron expression that runs every 10 seconds", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "*/10 * * * * *" }`
+ })
+
+ It("generates a validation error for having seconds field", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported"))
+ })
+ })
+
+ Context("with a cron expression that runs every 30 seconds", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "*/30 * * * * *" }`
+ })
+
+ It("generates a validation error for having seconds field", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported"))
+ })
+ })
+
+ Context("with a cron expression that runs at specific seconds", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "0,15,30,45 * * * * *" }`
+ })
+
+ It("generates a validation error for having seconds field", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported"))
+ })
+ })
+
+ Context("with a cron expression using a seconds range", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "0-30 * * * * *" }`
+ })
+
+ It("generates a validation error for having seconds field", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported"))
+ })
+ })
+
+ Context("with a cron expression that runs exactly every minute (5-field)", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "* * * * *" }`
+ })
+
+ It("is valid (minimum acceptable frequency)", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ Context("with a cron expression that runs every 5 minutes", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "*/5 * * * *" }`
+ })
+
+ It("is valid", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ Context("with a cron expression that runs hourly", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "0 * * * *" }`
+ })
+
+ It("is valid", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ Context("with a cron expression that runs every 2 hours", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "0 */2 * * *" }`
+ })
+
+ It("is valid", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ Context("with a cron expression that runs daily", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "0 0 * * *" }`
+ })
+
+ It("is valid", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ Context("with a cron expression that runs weekly", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "@weekly" }`
+ })
+
+ It("is valid", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ Context("with a cron expression using seconds field but running only once per minute", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "0 * * * * *" }`
+ })
+
+ It("generates a validation error for having seconds field", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported"))
+ })
+ })
+
+ Context("with a cron expression that runs exactly every 60 seconds", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "*/60 * * * * *" }`
+ })
+
+ It("generates a validation error for having seconds field", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported"))
+ })
+ })
+
+ Context("with a cron expression that runs every 90 seconds", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "*/90 * * * * *" }`
+ })
+
+ It("generates a validation error for having seconds field", func() {
+ Expect(err).ToNot(HaveOccurred())
+
+ err = source.Validate()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported"))
+ })
+ })
+
+ Context("with a cron expression with an invalid step value in seconds", func() {
+ BeforeEach(func() {
+ config = `{ "cron": "*/abc * * * * *" }`
+ })
+
+ It("is caught during initial cron validation", func() {
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid cron expression"))
+ })
+ })
+ })
})