Repository: favonia/cloudflare-ddns (go)

Issue:
[Feature request] Single update execution mode / disable cron
Thanks for developing cloudflare-ddns. I have it happily deployed in a Kubernetes cluster.
I would like to migrate the scheduling from your internal Go based cron to running it with Kubernetes' native [CronJob](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/).

How difficult would it be to implement a single update mode? In other words, the binary would not run continiously, but execute a single time and just terminate successfully. This would free up node resources until the next scheduled run.

Pull request description:
feat(cron): add an option to disable cron
Closes #409

- ~~Optimization: disable caching altogether~~ (Caching can still be helpful for multiple domains.)
- [ ] UI: Make the quiet mode really quiet.
- [x] Warning about DELETE_ON_STOP
- [x] Documentation
- [x] More testing!

Patch:
diff --git a/README.markdown b/README.markdown
index 3dd9b4b..0d85c30 100644
--- a/README.markdown
+++ b/README.markdown
@@ -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>
diff --git a/cmd/ddns/ddns.go b/cmd/ddns/ddns.go
index 0343fb7..630f674 100644
--- a/cmd/ddns/ddns.go
+++ b/cmd/ddns/ddns.go
@@ -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
 	}
 
@@ -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 {
@@ -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")
diff --git a/internal/config/config.go b/internal/config/config.go
index 7be49c0..d319174 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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)
@@ -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) {
@@ -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
@@ -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
 
diff --git a/internal/cron/base.go b/internal/cron/base.go
index a727a36..edc9a47 100644
--- a/internal/cron/base.go
+++ b/internal/cron/base.go
@@ -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()
 }
diff --git a/internal/cron/schedule.go b/internal/cron/schedule.go
index 5447eb6..fb4977e 100644
--- a/internal/cron/schedule.go
+++ b/internal/cron/schedule.go
@@ -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
@@ -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
 }


Test patch:
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index fa1718f..0d8bc55 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -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"),
@@ -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
@@ -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) {
@@ -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)
diff --git a/internal/cron/schedule_test.go b/internal/cron/schedule_test.go
index 1b04a11..6a8210f 100644
--- a/internal/cron/schedule_test.go
+++ b/internal/cron/schedule_test.go
@@ -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))
 		})
 	}
 }
@@ -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())
 		})
 	}
 }