Skip to content

Commit

Permalink
feat: introduce UPDATE_CRON=@once (#607)
Browse files Browse the repository at this point in the history
  • Loading branch information
favonia committed Oct 15, 2023
1 parent e65531a commit aa57602
Show file tree
Hide file tree
Showing 9 changed files with 101 additions and 58 deletions.
20 changes: 9 additions & 11 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -255,20 +255,18 @@ _(Click to expand the following items.)_
<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). 🧪 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) |
| 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 _and_ the special value `@once`. See the [documentation of cron](https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format) for cron expressions. | The schedule to re-check IP addresses and update DNS records (if necessary). If the special value `@once` is used, cron is disabled and the updater will terminate immediately after updating the DNS records. | 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 you want to use other mechanisms to schedule the updating (_e.g._, [CronJob](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/)).
</details>

<details>
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config_print_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ func TestPrintEmpty(t *testing.T) {
mockPP.EXPECT().Infof(pp.EmojiConfig, "Domains and IP providers:"),
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:", "@disabled"),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update frequency:", "@once"),
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
20 changes: 14 additions & 6 deletions internal/config/config_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,20 @@ func (c *Config) NormalizeConfig(ppfmt pp.PP) bool {
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 1: check DELETE_ON_STOP and UpdateOnStart
if c.UpdateCron == nil {
if !c.UpdateOnStart {
ppfmt.Errorf(
pp.EmojiUserError,
"UPDATE_ON_START=false is incompatible with UPDATE_CRON=@once")
return false
}
if c.DeleteOnStop {
ppfmt.Errorf(
pp.EmojiUserError,
"DELETE_ON_STOP=true will immediately delete all updated DNS records when UPDATE_CRON=@once")
return false
}
}

// Part 2: normalize domain maps
Expand Down
40 changes: 35 additions & 5 deletions internal/config/config_read_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func TestReadEnvWithOnlyToken(t *testing.T) {
mockPP.EXPECT().IncIndent().Return(innerMockPP),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP4_PROVIDER", "none"),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP6_PROVIDER", "none"),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "UPDATE_CRON", "@disabled"),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "UPDATE_CRON", "@once"),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%t", "UPDATE_ON_START", false),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%t", "DELETE_ON_STOP", false),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%v", "CACHE_EXPIRATION", time.Duration(0)),
Expand Down Expand Up @@ -97,12 +97,31 @@ func TestNormalizeConfig(t *testing.T) {
m.EXPECT().IsEnabledFor(pp.Info).Return(true),
m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
m.EXPECT().IncIndent().Return(m),
m.EXPECT().Errorf(pp.EmojiUserError, "No domains were specified in DOMAINS, IP4_DOMAINS, or IP6_DOMAINS"),
m.EXPECT().Errorf(pp.EmojiUserError, "UPDATE_ON_START=false is incompatible with UPDATE_CRON=@once"),
)
},
},
"empty/1": {
input: &config.Config{ //nolint:exhaustruct
Domains: map[ipnet.Type][]domain.Domain{
ipnet.IP4: {},
ipnet.IP6: {},
},
},
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, "UPDATE_ON_START=false is incompatible with UPDATE_CRON=@once"),
)
},
},
"empty": {
"empty/2": {
input: &config.Config{ //nolint:exhaustruct
UpdateOnStart: true,
Domains: map[ipnet.Type][]domain.Domain{
ipnet.IP4: {},
ipnet.IP6: {},
Expand All @@ -121,6 +140,7 @@ func TestNormalizeConfig(t *testing.T) {
},
"empty-ip6": {
input: &config.Config{ //nolint:exhaustruct
UpdateOnStart: true,
Provider: map[ipnet.Type]provider.Provider{
ipnet.IP4: provider.NewCloudflareTrace(),
ipnet.IP6: provider.NewCloudflareTrace(),
Expand All @@ -133,6 +153,7 @@ func TestNormalizeConfig(t *testing.T) {
},
ok: true,
expected: &config.Config{ //nolint:exhaustruct
UpdateOnStart: true,
Provider: map[ipnet.Type]provider.Provider{
ipnet.IP4: provider.NewCloudflareTrace(),
},
Expand All @@ -158,6 +179,7 @@ func TestNormalizeConfig(t *testing.T) {
},
"empty-ip6-none-ip4": {
input: &config.Config{ //nolint:exhaustruct
UpdateOnStart: true,
Provider: map[ipnet.Type]provider.Provider{
ipnet.IP6: provider.NewCloudflareTrace(),
},
Expand All @@ -184,6 +206,7 @@ func TestNormalizeConfig(t *testing.T) {
},
"ignored-ip4-domains": {
input: &config.Config{ //nolint:exhaustruct
UpdateOnStart: true,
Provider: map[ipnet.Type]provider.Provider{
ipnet.IP6: provider.NewCloudflareTrace(),
},
Expand All @@ -195,6 +218,7 @@ func TestNormalizeConfig(t *testing.T) {
},
ok: true,
expected: &config.Config{ //nolint:exhaustruct
UpdateOnStart: true,
Provider: map[ipnet.Type]provider.Provider{
ipnet.IP6: provider.NewCloudflareTrace(),
},
Expand All @@ -221,6 +245,7 @@ func TestNormalizeConfig(t *testing.T) {
},
"template": {
input: &config.Config{ //nolint:exhaustruct
UpdateOnStart: true,
Provider: map[ipnet.Type]provider.Provider{
ipnet.IP6: provider.NewCloudflareTrace(),
},
Expand All @@ -231,6 +256,7 @@ func TestNormalizeConfig(t *testing.T) {
},
ok: true,
expected: &config.Config{ //nolint:exhaustruct
UpdateOnStart: true,
Provider: map[ipnet.Type]provider.Provider{
ipnet.IP6: provider.NewCloudflareTrace(),
},
Expand All @@ -254,6 +280,7 @@ func TestNormalizeConfig(t *testing.T) {
},
"template/invalid/proxied": {
input: &config.Config{ //nolint:exhaustruct
UpdateOnStart: true,
Provider: map[ipnet.Type]provider.Provider{
ipnet.IP6: provider.NewCloudflareTrace(),
},
Expand All @@ -275,6 +302,7 @@ func TestNormalizeConfig(t *testing.T) {
},
"template/error/proxied": {
input: &config.Config{ //nolint:exhaustruct
UpdateOnStart: true,
Provider: map[ipnet.Type]provider.Provider{
ipnet.IP6: provider.NewCloudflareTrace(),
},
Expand All @@ -296,6 +324,7 @@ func TestNormalizeConfig(t *testing.T) {
},
"template/error/proxied/ill-formed": {
input: &config.Config{ //nolint:exhaustruct
UpdateOnStart: true,
Provider: map[ipnet.Type]provider.Provider{
ipnet.IP6: provider.NewCloudflareTrace(),
},
Expand All @@ -317,7 +346,8 @@ func TestNormalizeConfig(t *testing.T) {
},
"delete-on-stop/without-cron": {
input: &config.Config{ //nolint:exhaustruct
DeleteOnStop: true,
DeleteOnStop: true,
UpdateOnStart: true,
},
ok: false,
expected: nil,
Expand All @@ -326,7 +356,7 @@ func TestNormalizeConfig(t *testing.T) {
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
m.EXPECT().Errorf(pp.EmojiUserError, "DELETE_ON_STOP=true will immediately delete all updated DNS records when UPDATE_CRON=@once"), //nolint:lll
)
},
},
Expand Down
31 changes: 20 additions & 11 deletions internal/config/env_base.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func Getenv(key string) string {
return strings.TrimSpace(os.Getenv(key))
}

// ReadEmoji reads an environment variable as a plain string.
// ReadString reads an environment variable as a plain string.
func ReadString(ppfmt pp.PP, key string, field *string) bool {
val := Getenv(key)
if val == "" {
Expand Down Expand Up @@ -194,18 +194,27 @@ func ReadNonnegDuration(ppfmt pp.PP, key string, field *time.Duration) bool {

// ReadCron reads an environment variable and parses it as a Cron expression.
func ReadCron(ppfmt pp.PP, key string, field *cron.Schedule) bool {
val := Getenv(key)
if val == "" {
switch val := Getenv(key); val {
case "":
ppfmt.Infof(pp.EmojiBullet, "Use default %s=%s", key, cron.DescribeSchedule(*field))
return true
}

c, err := cron.New(val)
if err != nil {
ppfmt.Errorf(pp.EmojiUserError, "%s (%q) is not a cron expression: %v", key, val, err)
return false
}
case "@once":
*field = nil
return true

*field = c
return true
case "@disabled", "@nevermore":
ppfmt.Warningf(pp.EmojiUserWarning, "%s=%s is deprecated; use %s=@once", key, val, key)
*field = nil
return true

default:
c, err := cron.New(val)
if err != nil {
ppfmt.Errorf(pp.EmojiUserError, "%s (%q) is not a cron expression: %v", key, val, err)
return false
}
*field = c
return true
}
}
17 changes: 15 additions & 2 deletions internal/config/env_base_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ func TestReadNonnegDuration(t *testing.T) {
}
}

//nolint:paralleltest // environment vars are global
//nolint:paralleltest,funlen // environment vars are global
func TestReadCron(t *testing.T) {
key := keyPrefix + "CRON"

Expand Down Expand Up @@ -547,7 +547,20 @@ func TestReadCron(t *testing.T) {
)
},
},
"@": {true, " @daily ", cron.MustNew("@yearly"), cron.MustNew("@daily"), true, nil},
"@daily": {true, " @daily ", cron.MustNew("@yearly"), cron.MustNew("@daily"), true, nil},
"@disabled": {
true, " @disabled ", cron.MustNew("@yearly"), nil, true,
func(m *mocks.MockPP) {
m.EXPECT().Warningf(pp.EmojiUserWarning, "%s=%s is deprecated; use %s=@once", key, "@disabled", gomock.Any())
},
},
"@nevermore": {
true, " @nevermore\t", cron.MustNew("@yearly"), nil, true,
func(m *mocks.MockPP) {
m.EXPECT().Warningf(pp.EmojiUserWarning, "%s=%s is deprecated; use %s=@once", key, "@nevermore", gomock.Any())
},
},
"@once": {true, "\t\t@once", cron.MustNew("@yearly"), nil, true, nil},
"illformed": {
true, " @ddddd ", cron.MustNew("*/4 * * * *"), cron.MustNew("*/4 * * * *"), false,
func(m *mocks.MockPP) {
Expand Down
4 changes: 2 additions & 2 deletions internal/cron/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ func Next(s Schedule) time.Time {
return s.Next()
}

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

return s.Describe()
Expand Down
4 changes: 0 additions & 4 deletions internal/cron/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ 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)
Expand Down
21 changes: 5 additions & 16 deletions internal/cron/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,6 @@ func TestMustNewSuccessful(t *testing.T) {
}
}

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))
})
}
}

func TestMustNewPanicking(t *testing.T) {
t.Parallel()
for _, tc := range [...]string{
Expand Down Expand Up @@ -75,8 +61,6 @@ 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) {
Expand All @@ -85,3 +69,8 @@ func TestNextNever(t *testing.T) {
})
}
}

func TestNextNil(t *testing.T) {
t.Parallel()
require.True(t, cron.Next(nil).IsZero())
}

0 comments on commit aa57602

Please sign in to comment.