diff --git a/README.markdown b/README.markdown index c54a6bfb..03072de6 100644 --- a/README.markdown +++ b/README.markdown @@ -255,20 +255,18 @@ _(Click to expand the following items.)_
⏳ Schedules, triggers, and timeouts -| 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/)). -
diff --git a/internal/config/config_print_test.go b/internal/config/config_print_test.go index 8e0ddbea..f93d5146 100644 --- a/internal/config/config_print_test.go +++ b/internal/config/config_print_test.go @@ -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"), diff --git a/internal/config/config_read.go b/internal/config/config_read.go index b01be065..61439330 100644 --- a/internal/config/config_read.go +++ b/internal/config/config_read.go @@ -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 diff --git a/internal/config/config_read_test.go b/internal/config/config_read_test.go index 52892c78..bcfe6b78 100644 --- a/internal/config/config_read_test.go +++ b/internal/config/config_read_test.go @@ -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)), @@ -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: {}, @@ -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(), @@ -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(), }, @@ -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(), }, @@ -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(), }, @@ -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(), }, @@ -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(), }, @@ -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(), }, @@ -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(), }, @@ -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(), }, @@ -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(), }, @@ -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, @@ -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 ) }, }, diff --git a/internal/config/env_base.go b/internal/config/env_base.go index 12e6a499..b3c3a563 100644 --- a/internal/config/env_base.go +++ b/internal/config/env_base.go @@ -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 == "" { @@ -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 + } } diff --git a/internal/config/env_base_test.go b/internal/config/env_base_test.go index 06ab10ca..ac60a6d2 100644 --- a/internal/config/env_base_test.go +++ b/internal/config/env_base_test.go @@ -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" @@ -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) { diff --git a/internal/cron/base.go b/internal/cron/base.go index edc9a473..4c394c58 100644 --- a/internal/cron/base.go +++ b/internal/cron/base.go @@ -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() diff --git a/internal/cron/schedule.go b/internal/cron/schedule.go index fb4977e0..c314013a 100644 --- a/internal/cron/schedule.go +++ b/internal/cron/schedule.go @@ -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) diff --git a/internal/cron/schedule_test.go b/internal/cron/schedule_test.go index 6a8210f0..2cc691f0 100644 --- a/internal/cron/schedule_test.go +++ b/internal/cron/schedule_test.go @@ -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{ @@ -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) { @@ -85,3 +69,8 @@ func TestNextNever(t *testing.T) { }) } } + +func TestNextNil(t *testing.T) { + t.Parallel() + require.True(t, cron.Next(nil).IsZero()) +}