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