Skip to content

Commit

Permalink
feat(cron): add an option to disable cron (#411)
Browse files Browse the repository at this point in the history
* feat(cron): parse `@disabled` and `@nevermore`

* feat: bail out early when cron is disabled

* fix(cron): parse `@disabled` as `nil` and add tests

* docs: document the mode to disable cron completely

* test(config): change test names
  • Loading branch information
favonia committed Mar 9, 2023
1 parent f67d7c0 commit a381c5a
Show file tree
Hide file tree
Showing 7 changed files with 152 additions and 33 deletions.
20 changes: 11 additions & 9 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -262,18 +262,20 @@ In most cases, `CF_ACCOUNT_ID` is not needed.
<details>
<summary>⏳ Schedules, triggers, and timeouts</summary>

| Name | Valid Values | Meaning | Required? | Default Value |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | --------- | ----------------------------- |
| `CACHE_EXPIRATION` | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The expiration of cached Cloudflare API responses | No | `6h0m0s` (6 hours) |
| `DELETE_ON_STOP` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether managed DNS records should be deleted on exit | No | `false` |
| `DETECTION_TIMEOUT` | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The timeout of each attempt to detect IP addresses | No | `5s` (5 seconds) |
| `TZ` | Recognized timezones, such as `UTC` | The timezone used for logging and parsing `UPDATE_CRON` | No | `UTC` |
| `UPDATE_CRON` | Cron expressions. See the [documentation of cron](https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format) | The schedule to re-check IP addresses and update DNS records (if necessary) | No | `@every 5m` (every 5 minutes) |
| `UPDATE_ON_START` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether to check IP addresses on start regardless of `UPDATE_CRON` | No | `true` |
| `UPDATE_TIMEOUT` | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The timeout of each attempt to update DNS records, per domain, per record type | No | `30s` (30 seconds) |
| Name | Valid Values | Meaning | Required? | Default Value |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | --------- | ----------------------------- |
| `CACHE_EXPIRATION` | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The expiration of cached Cloudflare API responses | No | `6h0m0s` (6 hours) |
| `DELETE_ON_STOP` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether managed DNS records should be deleted on exit | No | `false` |
| `DETECTION_TIMEOUT` | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The timeout of each attempt to detect IP addresses | No | `5s` (5 seconds) |
| `TZ` | Recognized timezones, such as `UTC` | The timezone used for logging and parsing `UPDATE_CRON` | No | `UTC` |
| `UPDATE_CRON` | Cron expressions. See the [documentation of cron](https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format). See below for the experimental mode to disable cron. | The schedule to re-check IP addresses and update DNS records (if necessary) | No | `@every 5m` (every 5 minutes) |
| `UPDATE_ON_START` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether to check IP addresses on start regardless of `UPDATE_CRON` | No | `true` |
| `UPDATE_TIMEOUT` | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The timeout of each attempt to update DNS records, per domain, per record type | No | `30s` (30 seconds) |

⚠️ The update schedule _does not_ take the time to update records into consideration. For example, if the schedule is “for every 5 minutes”, and if the updating itself takes 2 minutes, then the actual interval between adjacent updates is 3 minutes, not 5 minutes.

🧪 Experimental mode to disable cron: `UPDATE_CRON` can be set to `@disabled` (or `@nevermore` as an alias); the updater will terminate immediately after updating the DNS records. This is useful when the scheduling is handled by other mechanisms (such as [CronJob](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/)).

</details>

<details>
Expand Down
10 changes: 8 additions & 2 deletions cmd/ddns/ddns.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func initConfig(ctx context.Context, ppfmt pp.PP) (*config.Config, setter.Setter
c := config.Default()

// Read the config
if !c.ReadEnv(ppfmt) || !c.NormalizeDomains(ppfmt) {
if !c.ReadEnv(ppfmt) || !c.NormalizeConfig(ppfmt) {
return c, nil, false
}

Expand Down Expand Up @@ -107,7 +107,7 @@ func realMain() int { //nolint:funlen
for {
// The next time to run the updater.
// This is called before running the updater so that the timer would not be delayed by the updating.
next := c.UpdateCron.Next()
next := cron.Next(c.UpdateCron)

// Update the IP addresses
if first && !c.UpdateOnStart {
Expand All @@ -121,6 +121,12 @@ func realMain() int { //nolint:funlen
}
first = false

// Maybe the cron was disabled?
if c.UpdateCron == nil {
ppfmt.Noticef(pp.EmojiBye, "Bye!")
return 0
}

// Maybe there's nothing scheduled in near future?
if next.IsZero() {
ppfmt.Errorf(pp.EmojiUserError, "No scheduled updates in near future")
Expand Down
30 changes: 20 additions & 10 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ func (c *Config) Print(ppfmt pp.PP) {

section("Scheduling:")
item("Timezone:", "%s", cron.DescribeLocation(time.Local))
item("Update frequency:", "%v", c.UpdateCron)
item("Update frequency:", "%v", cron.DescribeSchedule(c.UpdateCron))
item("Update on start?", "%t", c.UpdateOnStart)
item("Delete on stop?", "%t", c.DeleteOnStop)
item("Cache expiration:", "%v", c.CacheExpiration)
Expand Down Expand Up @@ -264,7 +264,7 @@ func (c *Config) Print(ppfmt pp.PP) {
}

// ReadEnv calls the relevant readers to read all relevant environment variables except TZ
// and update relevant fields. One should subsequently call [Config.NormalizeDomains] to maintain
// and update relevant fields. One should subsequently call [Config.NormalizeConfig] to maintain
// invariants across different fields.
func (c *Config) ReadEnv(ppfmt pp.PP) bool {
if ppfmt.IsEnabledFor(pp.Info) {
Expand All @@ -290,21 +290,30 @@ func (c *Config) ReadEnv(ppfmt pp.PP) bool {
return true
}

// NormalizeDomains updates and normalizes the fields [Config.Provider] and [Config.Proxied].
// When errors are reported, the original configuration remain unchanged.
// NormalizeConfig checks and normalizes the fields [Config.Provider], [Config.Proxied], and [Config.DeleteOnStop].
// When any error is reported, the original configuration remain unchanged.
//
//nolint:funlen
func (c *Config) NormalizeDomains(ppfmt pp.PP) bool {
// New maps
providerMap := map[ipnet.Type]provider.Provider{}
proxiedMap := map[domain.Domain]bool{}
activeDomainSet := map[domain.Domain]bool{}

func (c *Config) NormalizeConfig(ppfmt pp.PP) bool {
if ppfmt.IsEnabledFor(pp.Info) {
ppfmt.Infof(pp.EmojiEnvVars, "Checking settings . . .")
ppfmt = ppfmt.IncIndent()
}

// Part 1: check DELETE_ON_STOP
if c.UpdateCron == nil && c.DeleteOnStop {
ppfmt.Errorf(
pp.EmojiUserError,
"DELETE_ON_STOP=true will immediately delete all DNS records when UPDATE_CRON=@disabled")
return false
}

// Part 2: normalize domain maps
// New domain maps
providerMap := map[ipnet.Type]provider.Provider{}
proxiedMap := map[domain.Domain]bool{}
activeDomainSet := map[domain.Domain]bool{}

if len(c.Domains[ipnet.IP4]) == 0 && len(c.Domains[ipnet.IP6]) == 0 {
ppfmt.Errorf(pp.EmojiUserError, "No domains were specified in DOMAINS, IP4_DOMAINS, or IP6_DOMAINS")
return false
Expand Down Expand Up @@ -362,6 +371,7 @@ func (c *Config) NormalizeDomains(ppfmt pp.PP) bool {
proxiedMap[dom] = proxiedPred(dom)
}

// Part 3: override the old values
c.Provider = providerMap
c.Proxied = proxiedMap

Expand Down
56 changes: 53 additions & 3 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ func TestPrintEmpty(t *testing.T) {
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv6 provider:", "none"),
mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Timezone:", Some("UTC (UTC+00 now)", "Local (UTC+00 now)")), //nolint:lll
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update frequency:", "<nil>"),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update frequency:", "@disabled"),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update on start?", "false"),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Delete on stop?", "false"),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Cache expiration:", "0s"),
Expand Down Expand Up @@ -529,7 +529,7 @@ func TestReadEnvEmpty(t *testing.T) {
}

//nolint:funlen
func TestNormalize(t *testing.T) {
func TestNormalizeConfig(t *testing.T) {
t.Parallel()

var empty config.Config
Expand Down Expand Up @@ -767,6 +767,56 @@ func TestNormalize(t *testing.T) {
)
},
},
"delete-on-stop/without-cron": {
input: &config.Config{ //nolint:exhaustruct
DeleteOnStop: true,
},
ok: false,
expected: nil,
prepareMockPP: func(m *mocks.MockPP) {
gomock.InOrder(
m.EXPECT().IsEnabledFor(pp.Info).Return(true),
m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
m.EXPECT().IncIndent().Return(m),
m.EXPECT().Errorf(pp.EmojiUserError, "DELETE_ON_STOP=true will immediately delete all DNS records when UPDATE_CRON=@disabled"), //nolint:lll
)
},
},
"delete-on-stop/with-cron": {
input: &config.Config{ //nolint:exhaustruct
DeleteOnStop: true,
UpdateCron: cron.MustNew("@every 5m"),
Provider: map[ipnet.Type]provider.Provider{
ipnet.IP6: provider.NewCloudflareTrace(),
},
Domains: map[ipnet.Type][]domain.Domain{
ipnet.IP6: {domain.FQDN("a.b.c")},
},
ProxiedTemplate: "false",
},
ok: true,
expected: &config.Config{ //nolint:exhaustruct
DeleteOnStop: true,
UpdateCron: cron.MustNew("@every 5m"),
Provider: map[ipnet.Type]provider.Provider{
ipnet.IP6: provider.NewCloudflareTrace(),
},
Domains: map[ipnet.Type][]domain.Domain{
ipnet.IP6: {domain.FQDN("a.b.c")},
},
ProxiedTemplate: "false",
Proxied: map[domain.Domain]bool{
domain.FQDN("a.b.c"): false,
},
},
prepareMockPP: func(m *mocks.MockPP) {
gomock.InOrder(
m.EXPECT().IsEnabledFor(pp.Info).Return(true),
m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
m.EXPECT().IncIndent().Return(m),
)
},
},
} {
tc := tc
t.Run(name, func(t *testing.T) {
Expand All @@ -778,7 +828,7 @@ func TestNormalize(t *testing.T) {
if tc.prepareMockPP != nil {
tc.prepareMockPP(mockPP)
}
ok := cfg.NormalizeDomains(mockPP)
ok := cfg.NormalizeConfig(mockPP)
require.Equal(t, tc.ok, ok)
if tc.ok {
require.Equal(t, tc.expected, cfg)
Expand Down
20 changes: 19 additions & 1 deletion internal/cron/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,23 @@ import "time"
// Schedule tells the next time a scheduled event should happen.
type Schedule = interface {
Next() time.Time
String() string
Describe() string
}

// Next gets the next scheduled time. It returns the zero value for nil.
func Next(s Schedule) time.Time {
if s == nil {
return time.Time{}
}

return s.Next()
}

// String gives back the original cron string.
func DescribeSchedule(s Schedule) string {
if s == nil {
return "@disabled"
}

return s.Describe()
}
12 changes: 8 additions & 4 deletions internal/cron/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@ type cronSchedule struct {

// New creates a new Schedule.
func New(spec string) (Schedule, error) {
if spec == "@disabled" || spec == "@nevermore" {
return (Schedule)(nil), nil
}

sche, err := cron.ParseStandard(spec)
if err != nil {
return nil, fmt.Errorf("parsing %q: %w", spec, err)
}

return &cronSchedule{
return cronSchedule{
spec: spec,
schedule: sche,
}, nil
Expand All @@ -38,11 +42,11 @@ func MustNew(spec string) Schedule {
}

// Next tells the next scheduled time.
func (s *cronSchedule) Next() time.Time {
func (s cronSchedule) Next() time.Time {
return s.schedule.Next(time.Now())
}

// String gives back the original cron string.
func (s *cronSchedule) String() string {
// Describe gives back the original cron string.
func (s cronSchedule) Describe() string {
return s.spec
}
37 changes: 33 additions & 4 deletions internal/cron/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,21 @@ func TestMustNewSuccessful(t *testing.T) {
tc := tc // capture range variable
t.Run(tc, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc, cron.MustNew(tc).String())
require.Equal(t, tc, cron.DescribeSchedule(cron.MustNew(tc)))
})
}
}

func TestMustNewSuccessfulNil(t *testing.T) {
t.Parallel()
for _, tc := range [...]string{
"@disabled",
"@nevermore",
} {
tc := tc // capture range variable
t.Run(tc, func(t *testing.T) {
t.Parallel()
require.Nil(t, cron.MustNew(tc))
})
}
}
Expand All @@ -41,18 +55,33 @@ func TestMustNewPanicking(t *testing.T) {

func TestNext(t *testing.T) {
t.Parallel()
const delta = time.Second * 5
const delta = time.Second
for _, tc := range [...]struct {
spec string
interval time.Duration
}{
{"@every 1h", time.Hour},
{"@every 1h1m", time.Hour + time.Minute},
{"@every 4h", time.Hour * 4},
} {
tc := tc // capture range variable
t.Run(tc.spec, func(t *testing.T) {
t.Parallel()
require.WithinDuration(t, time.Now().Add(tc.interval), cron.MustNew(tc.spec).Next(), delta)
require.WithinDuration(t, time.Now().Add(tc.interval), cron.Next(cron.MustNew(tc.spec)), delta)
})
}
}

func TestNextNever(t *testing.T) {
t.Parallel()
for _, tc := range [...]string{
"* * 30 2 *",
"@disabled",
"@nevermore",
} {
tc := tc // capture range variable
t.Run(tc, func(t *testing.T) {
t.Parallel()
require.True(t, cron.Next(cron.MustNew(tc)).IsZero())
})
}
}

0 comments on commit a381c5a

Please sign in to comment.