diff --git a/internal/config/config_print.go b/internal/config/config_print.go index f0805da5..7a8012d1 100644 --- a/internal/config/config_print.go +++ b/internal/config/config_print.go @@ -80,7 +80,7 @@ func (c *Config) Print(ppfmt pp.PP) { section("Scheduling:") item("Timezone:", "%s", cron.DescribeLocation(time.Local)) - item("Update frequency:", "%s", cron.DescribeSchedule(c.UpdateCron)) + item("Update schedule:", "%s", cron.DescribeSchedule(c.UpdateCron)) item("Update on start?", "%t", c.UpdateOnStart) item("Delete on stop?", "%t", c.DeleteOnStop) item("Cache expiration:", "%v", c.CacheExpiration) diff --git a/internal/config/config_print_test.go b/internal/config/config_print_test.go index c3cef99f..4aac2876 100644 --- a/internal/config/config_print_test.go +++ b/internal/config/config_print_test.go @@ -38,7 +38,7 @@ func TestPrintDefault(t *testing.T) { printItem(innerMockPP, "IPv6 provider:", "cloudflare.trace"), mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"), printItem(innerMockPP, "Timezone:", gomock.AnyOf("UTC (currently UTC+00)", "Local (currently UTC+00)")), - printItem(innerMockPP, "Update frequency:", "@every 5m"), + printItem(innerMockPP, "Update schedule:", "@every 5m"), printItem(innerMockPP, "Update on start?", "true"), printItem(innerMockPP, "Delete on stop?", "false"), printItem(innerMockPP, "Cache expiration:", "6h0m0s"), @@ -74,7 +74,7 @@ func TestPrintValues(t *testing.T) { printItem(innerMockPP, "IPv6 provider:", "cloudflare.trace"), mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"), printItem(innerMockPP, "Timezone:", gomock.AnyOf("UTC (currently UTC+00)", "Local (currently UTC+00)")), - printItem(innerMockPP, "Update frequency:", "@every 5m"), + printItem(innerMockPP, "Update schedule:", "@every 5m"), printItem(innerMockPP, "Update on start?", "true"), printItem(innerMockPP, "Delete on stop?", "false"), printItem(innerMockPP, "Cache expiration:", "6h0m0s"), @@ -140,7 +140,7 @@ func TestPrintEmpty(t *testing.T) { mockPP.EXPECT().Infof(pp.EmojiConfig, "Domains and IP providers:"), mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"), printItem(innerMockPP, "Timezone:", gomock.AnyOf("UTC (currently UTC+00)", "Local (currently UTC+00)")), - printItem(innerMockPP, "Update frequency:", "@once"), + printItem(innerMockPP, "Update schedule:", "@once"), printItem(innerMockPP, "Update on start?", "false"), printItem(innerMockPP, "Delete on stop?", "false"), printItem(innerMockPP, "Cache expiration:", "0s"), diff --git a/internal/notifier/shoutrrr.go b/internal/notifier/shoutrrr.go index 4c8a7ef4..01d8136f 100644 --- a/internal/notifier/shoutrrr.go +++ b/internal/notifier/shoutrrr.go @@ -61,7 +61,7 @@ func (s *Shoutrrr) Send(_ context.Context, ppfmt pp.PP, msg string) bool { } } if allOk { - ppfmt.Infof(pp.EmojiNotification, "Sent shoutrrr message") + ppfmt.Infof(pp.EmojiMessage, "Sent shoutrrr message") } return allOk } diff --git a/internal/notifier/shoutrrr_test.go b/internal/notifier/shoutrrr_test.go index 278e51f5..c4b773e4 100644 --- a/internal/notifier/shoutrrr_test.go +++ b/internal/notifier/shoutrrr_test.go @@ -45,7 +45,7 @@ func TestShoutrrrSend(t *testing.T) { "hello", true, true, func(m *mocks.MockPP) { - m.EXPECT().Infof(pp.EmojiNotification, "Sent shoutrrr message") + m.EXPECT().Infof(pp.EmojiMessage, "Sent shoutrrr message") }, }, "ill-formed url": { diff --git a/internal/pp/emoji.go b/internal/pp/emoji.go index 22564f76..022dc83f 100644 --- a/internal/pp/emoji.go +++ b/internal/pp/emoji.go @@ -19,9 +19,10 @@ const ( EmojiDeleteRecord Emoji = "๐Ÿ’€" // deleting DNS records EmojiUpdateRecord Emoji = "๐Ÿ“ก" // updating DNS records EmojiClearRecord Emoji = "๐Ÿงน" // clearing DNS records when exiting + EmojiBailingOut Emoji = "๐Ÿ’จ" // bailing out - EmojiPing Emoji = "๐Ÿ””" // pinging and health checks - EmojiNotification Emoji = "๐Ÿ“จ" // notifications + EmojiPing Emoji = "๐Ÿ””" // pinging and health checks + EmojiMessage Emoji = "๐Ÿ“จ" // notifications EmojiSignal Emoji = "๐Ÿšจ" // catching signals EmojiAlreadyDone Emoji = "๐Ÿคท" // DNS records were already up to date diff --git a/internal/setter/setter.go b/internal/setter/setter.go index 35ea1ab2..82fa1e56 100644 --- a/internal/setter/setter.go +++ b/internal/setter/setter.go @@ -104,33 +104,37 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP, // Let's go through all stale records for i, id := range unprocessedUnmatched { // Let's try to update it first. - if ok := s.Handle.UpdateRecord(ctx, ppfmt, domain, ipnet, id, ip); !ok { - // If the updating fails, we will delete it. - if ok := s.Handle.DeleteRecord(ctx, ppfmt, domain, ipnet, id); ok { - ppfmt.Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", - recordType, domainDescription, id) + if s.Handle.UpdateRecord(ctx, ppfmt, domain, ipnet, id, ip) { + // If the updating succeeds, we can move on to the next stage! + // + // Note that there can still be stale records at this point. + ppfmt.Noticef(pp.EmojiUpdateRecord, + "Updated a stale %s record of %q (ID: %q)", recordType, domainDescription, id) + + // Now it's up to date! Note that unprocessedMatched must be empty + // otherwise foundMatched would have been true. + foundMatched = true + numUndeletedUnmatched-- + newUnprocessedUnmatched = unprocessedUnmatched[i+1:] + + break + } + if ctx.Err() != nil { + goto timeout + } - // Only when the deletion succeeds, we decrease the counter of remaining stale records. - numUndeletedUnmatched-- - } + // If the updating fails, we will delete it. + if s.Handle.DeleteRecord(ctx, ppfmt, domain, ipnet, id) { + ppfmt.Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", + recordType, domainDescription, id) - // No matter whether the deletion succeeds, move on. + // Only when the deletion succeeds, we decrease the counter of remaining stale records. + numUndeletedUnmatched-- continue } - - // If the updating succeeds, we can move on to the next stage! - // - // Note that there can still be stale records at this point. - ppfmt.Noticef(pp.EmojiUpdateRecord, - "Updated a stale %s record of %q (ID: %q)", recordType, domainDescription, id) - - // Now it's up to date! Note that unprocessedMatched must be empty - // otherwise foundMatched would have been true. - foundMatched = true - numUndeletedUnmatched-- - newUnprocessedUnmatched = unprocessedUnmatched[i+1:] - - break + if ctx.Err() != nil { + goto timeout + } } unprocessedUnmatched = newUnprocessedUnmatched @@ -145,6 +149,8 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP, // Now it's up to date! unprocessedMatched and unprocessedUnmatched must both be empty at this point foundMatched = true + } else if ctx.Err() != nil { + goto timeout } } @@ -153,6 +159,8 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP, if s.Handle.DeleteRecord(ctx, ppfmt, domain, ipnet, id) { ppfmt.Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", recordType, domainDescription, id) numUndeletedUnmatched-- + } else if ctx.Err() != nil { + goto timeout } } @@ -162,6 +170,8 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP, if s.Handle.DeleteRecord(ctx, ppfmt, domain, ipnet, id) { ppfmt.Noticef(pp.EmojiDeleteRecord, "Deleted a duplicate %s record of %q (ID: %q)", recordType, domainDescription, id) + } else if ctx.Err() != nil { + goto timeout } } @@ -174,6 +184,10 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP, } return ResponseUpdated + +timeout: + ppfmt.Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", ctx.Err()) + return ResponseFailed } // Delete deletes all managed DNS records. @@ -205,8 +219,13 @@ func (s *setter) Delete(ctx context.Context, ppfmt pp.PP, domain domain.Domain, allOk := true for _, id := range unmatchedIDs { - if ok := s.Handle.DeleteRecord(ctx, ppfmt, domain, ipnet, id); !ok { + if !s.Handle.DeleteRecord(ctx, ppfmt, domain, ipnet, id) { allOk = false + + if ctx.Err() != nil { + ppfmt.Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", ctx.Err()) + return ResponseFailed + } continue } diff --git a/internal/setter/setter_test.go b/internal/setter/setter_test.go index e94bac32..b7d439fe 100644 --- a/internal/setter/setter_test.go +++ b/internal/setter/setter_test.go @@ -16,6 +16,27 @@ import ( "github.com/favonia/cloudflare-ddns/internal/setter" ) +func wrapCancelAsCreate(cancel func()) func(context.Context, pp.PP, domain.Domain, ipnet.Type, netip.Addr, api.TTL, bool, string) (string, bool) { //nolint:lll + return func(context.Context, pp.PP, domain.Domain, ipnet.Type, netip.Addr, api.TTL, bool, string) (string, bool) { + cancel() + return "", false + } +} + +func wrapCancelAsUpdate(cancel func()) func(context.Context, pp.PP, domain.Domain, ipnet.Type, string, netip.Addr) bool { //nolint:lll + return func(context.Context, pp.PP, domain.Domain, ipnet.Type, string, netip.Addr) bool { + cancel() + return false + } +} + +func wrapCancelAsDelete(cancel func()) func(context.Context, pp.PP, domain.Domain, ipnet.Type, string) bool { + return func(context.Context, pp.PP, domain.Domain, ipnet.Type, string) bool { + cancel() + return false + } +} + //nolint:funlen func TestSet(t *testing.T) { t.Parallel() @@ -36,7 +57,7 @@ func TestSet(t *testing.T) { ip netip.Addr resp setter.ResponseCode prepareMockPP func(m *mocks.MockPP) - prepareMockHandle func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) + prepareMockHandle func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) }{ "0": { ip1, @@ -44,7 +65,7 @@ func TestSet(t *testing.T) { func(m *mocks.MockPP) { m.EXPECT().Noticef(pp.EmojiCreateRecord, "Added a new %s record of %q (ID: %q)", "AAAA", "sub.test.org", record1) }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { gomock.InOrder( m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{}, true, true), m.EXPECT().CreateRecord(ctx, ppfmt, domain, ipNetwork, ip1, api.TTLAuto, false, "hello").Return(record1, true), @@ -62,14 +83,14 @@ func TestSet(t *testing.T) { record1, ) }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { gomock.InOrder( m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2}, true, true), m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(true), ) }, }, - "1unmatched-updatefail": { + "1unmatched/update-fail": { ip1, setter.ResponseUpdated, func(m *mocks.MockPP) { @@ -78,7 +99,7 @@ func TestSet(t *testing.T) { m.EXPECT().Noticef(pp.EmojiCreateRecord, "Added a new %s record of %q (ID: %q)", "AAAA", "sub.test.org", record2), ) }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { gomock.InOrder( m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2}, true, true), m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false), @@ -87,6 +108,38 @@ func TestSet(t *testing.T) { ) }, }, + "1unmatched/update-timeout": { + ip1, + setter.ResponseFailed, + func(m *mocks.MockPP) { + m.EXPECT().Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", gomock.Any()) + }, + func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) { + gomock.InOrder( + m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork). + Return(map[string]netip.Addr{record1: ip2}, true, true), + m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1). + Do(wrapCancelAsUpdate(cancel)).Return(false), + ) + }, + }, + "1unmatched/delete-timeout": { + ip1, + setter.ResponseFailed, + func(m *mocks.MockPP) { + m.EXPECT().Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", gomock.Any()) + }, + func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) { + gomock.InOrder( + m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork). + Return(map[string]netip.Addr{record1: ip2}, true, true), + m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1). + Return(false), + m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1). + Do(wrapCancelAsDelete(cancel)).Return(false), + ) + }, + }, "1matched": { ip1, setter.ResponseNoop, @@ -94,7 +147,7 @@ func TestSet(t *testing.T) { m.EXPECT().Infof(pp.EmojiAlreadyDone, "The %s records of %q are already up to date (cached)", "AAAA", "sub.test.org") }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1}, true, true) }, }, @@ -104,7 +157,7 @@ func TestSet(t *testing.T) { func(m *mocks.MockPP) { m.EXPECT().Infof(pp.EmojiAlreadyDone, "The %s records of %q are already up to date", "AAAA", "sub.test.org") }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1}, false, true) }, }, @@ -120,24 +173,41 @@ func TestSet(t *testing.T) { record2, ) }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { gomock.InOrder( - m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1, record2: ip1}, true, true), //nolint:lll + m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork). + Return(map[string]netip.Addr{record1: ip1, record2: ip1}, true, true), m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(true), ) }, }, - "2matched-deletefail": { + "2matched/delete-fail": { ip1, setter.ResponseUpdated, nil, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { gomock.InOrder( - m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1, record2: ip1}, true, true), //nolint:lll + m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork). + Return(map[string]netip.Addr{record1: ip1, record2: ip1}, true, true), m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(false), ) }, }, + "2matched/delete-timeout": { + ip1, + setter.ResponseFailed, + func(m *mocks.MockPP) { + m.EXPECT().Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", gomock.Any()) + }, + func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) { + gomock.InOrder( + m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork). + Return(map[string]netip.Addr{record1: ip1, record2: ip1}, true, true), + m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2). + Do(wrapCancelAsDelete(cancel)).Return(false), + ) + }, + }, "2unmatched": { ip1, setter.ResponseUpdated, @@ -157,20 +227,51 @@ func TestSet(t *testing.T) { record2), ) }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { gomock.InOrder( - m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), //nolint:lll + m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork). + Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(true), m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(true), ) }, }, - "2unmatched-updatefail": { + "2unmatched/delete-timeout": { + ip1, + setter.ResponseFailed, + func(m *mocks.MockPP) { + gomock.InOrder( + m.EXPECT().Noticef( + pp.EmojiUpdateRecord, + "Updated a stale %s record of %q (ID: %q)", + "AAAA", + "sub.test.org", + record1, + ), + m.EXPECT().Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", gomock.Any()), + ) + }, + func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) { + gomock.InOrder( + m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork). + Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), + m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(true), + m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2). + Do(wrapCancelAsDelete(cancel)).Return(false), + ) + }, + }, + "2unmatched/update-fail": { ip1, setter.ResponseUpdated, func(m *mocks.MockPP) { gomock.InOrder( - m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record1), //nolint:lll + m.EXPECT().Noticef( + pp.EmojiDeleteRecord, + "Deleted a stale %s record of %q (ID: %q)", + "AAAA", + "sub.test.org", + record1), m.EXPECT().Noticef( pp.EmojiUpdateRecord, "Updated a stale %s record of %q (ID: %q)", @@ -180,16 +281,17 @@ func TestSet(t *testing.T) { ), ) }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { gomock.InOrder( - m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), //nolint:lll + m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork). + Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false), m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true), m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record2, ip1).Return(true), ) }, }, - "2unmatched-updatefailtwice": { + "2unmatched/update-fail/update-fail": { ip1, setter.ResponseUpdated, func(m *mocks.MockPP) { @@ -199,9 +301,10 @@ func TestSet(t *testing.T) { m.EXPECT().Noticef(pp.EmojiCreateRecord, "Added a new %s record of %q (ID: %q)", "AAAA", "sub.test.org", record3), ) }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { gomock.InOrder( - m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), //nolint:lll + m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork). + Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false), m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true), m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record2, ip1).Return(false), @@ -211,7 +314,7 @@ func TestSet(t *testing.T) { }, }, //nolint:dupl - "2unmatched-updatefail-deletefail-updatefail": { + "2unmatched/update-fail/delete-fail/update-fail": { ip1, setter.ResponseFailed, func(m *mocks.MockPP) { @@ -221,7 +324,7 @@ func TestSet(t *testing.T) { m.EXPECT().Errorf(pp.EmojiError, "Failed to finish updating %s records of %q; records might be inconsistent", "AAAA", "sub.test.org"), //nolint:lll ) }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { gomock.InOrder( m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), //nolint:lll m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false), @@ -233,7 +336,7 @@ func TestSet(t *testing.T) { }, }, //nolint:dupl - "2unmatched-updatefailtwice-createfail": { + "2unmatched/update-fail/update-fail/create-fail": { ip1, setter.ResponseFailed, func(m *mocks.MockPP) { @@ -243,7 +346,7 @@ func TestSet(t *testing.T) { m.EXPECT().Errorf(pp.EmojiError, "Failed to finish updating %s records of %q; records might be inconsistent", "AAAA", "sub.test.org"), //nolint:lll ) }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { gomock.InOrder( m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), //nolint:lll m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false), @@ -254,13 +357,36 @@ func TestSet(t *testing.T) { ) }, }, + "2unmatched/update-fail/update-fail/create-timeout": { + ip1, + setter.ResponseFailed, + func(m *mocks.MockPP) { + gomock.InOrder( + m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record1), //nolint:lll + m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record2), //nolint:lll + m.EXPECT().Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", gomock.Any()), + ) + }, + func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) { + gomock.InOrder( + m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork). + Return(map[string]netip.Addr{record1: ip2, record2: ip2}, true, true), + m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false), + m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true), + m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record2, ip1).Return(false), + m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(true), + m.EXPECT().CreateRecord(ctx, ppfmt, domain, ipNetwork, ip1, api.TTLAuto, false, "hello"). + Do(wrapCancelAsCreate(cancel)).Return(record3, false), + ) + }, + }, "listfail": { ip1, setter.ResponseFailed, func(m *mocks.MockPP) { m.EXPECT().Errorf(pp.EmojiError, "Failed to retrieve the current %s records of %q", "AAAA", "sub.test.org") }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(nil, false, false) }, }, @@ -269,7 +395,8 @@ func TestSet(t *testing.T) { t.Parallel() mockCtrl := gomock.NewController(t) - ctx := context.Background() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() mockPP := mocks.NewMockPP(mockCtrl) if tc.prepareMockPP != nil { @@ -277,7 +404,7 @@ func TestSet(t *testing.T) { } mockHandle := mocks.NewMockHandle(mockCtrl) if tc.prepareMockHandle != nil { - tc.prepareMockHandle(ctx, mockPP, mockHandle) + tc.prepareMockHandle(ctx, cancel, mockPP, mockHandle) } s, ok := setter.New(mockPP, mockHandle) @@ -308,14 +435,14 @@ func TestDelete(t *testing.T) { for name, tc := range map[string]struct { resp setter.ResponseCode prepareMockPP func(m *mocks.MockPP) - prepareMockHandle func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) + prepareMockHandle func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) }{ "0": { setter.ResponseNoop, func(m *mocks.MockPP) { m.EXPECT().Infof(pp.EmojiAlreadyDone, "The %s records of %q were already deleted (cached)", "AAAA", "sub.test.org") }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{}, true, true) }, }, @@ -324,7 +451,7 @@ func TestDelete(t *testing.T) { func(m *mocks.MockPP) { m.EXPECT().Infof(pp.EmojiAlreadyDone, "The %s records of %q were already deleted", "AAAA", "sub.test.org") }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{}, false, true) }, }, @@ -333,25 +460,39 @@ func TestDelete(t *testing.T) { func(m *mocks.MockPP) { m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record1) //nolint:lll }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { gomock.InOrder( m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1}, true, true), m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true), ) }, }, - "1unmatched/fail": { + "1unmatched/delete-fail": { setter.ResponseFailed, func(m *mocks.MockPP) { m.EXPECT().Errorf(pp.EmojiError, "Failed to finish deleting %s records of %q; records might be inconsistent", "AAAA", "sub.test.org") //nolint:lll }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { gomock.InOrder( m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1}, true, true), m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(false), ) }, }, + "1unmatched/delete-timeout": { + setter.ResponseFailed, + func(m *mocks.MockPP) { + m.EXPECT().Infof(pp.EmojiBailingOut, "Operation aborted (%v); bailing out . . .", gomock.Any()) + }, + func(ctx context.Context, cancel func(), ppfmt pp.PP, m *mocks.MockHandle) { + gomock.InOrder( + m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork). + Return(map[string]netip.Addr{record1: ip1}, true, true), + m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1). + Do(wrapCancelAsDelete(cancel)).Return(false), + ) + }, + }, "impossible-records": { setter.ResponseUpdated, func(m *mocks.MockPP) { @@ -360,7 +501,7 @@ func TestDelete(t *testing.T) { m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record2), //nolint:lll ) }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { gomock.InOrder( m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1, record2: invalidIP}, true, true), //nolint:lll m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true), @@ -373,7 +514,7 @@ func TestDelete(t *testing.T) { func(m *mocks.MockPP) { m.EXPECT().Errorf(pp.EmojiError, "Failed to retrieve the current %s records of %q", "AAAA", "sub.test.org") }, - func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) { + func(ctx context.Context, _ func(), ppfmt pp.PP, m *mocks.MockHandle) { m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(nil, false, false) }, }, @@ -382,7 +523,8 @@ func TestDelete(t *testing.T) { t.Parallel() mockCtrl := gomock.NewController(t) - ctx := context.Background() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() mockPP := mocks.NewMockPP(mockCtrl) if tc.prepareMockPP != nil { @@ -390,7 +532,7 @@ func TestDelete(t *testing.T) { } mockHandle := mocks.NewMockHandle(mockCtrl) if tc.prepareMockHandle != nil { - tc.prepareMockHandle(ctx, mockPP, mockHandle) + tc.prepareMockHandle(ctx, cancel, mockPP, mockHandle) } s, ok := setter.New(mockPP, mockHandle) diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 2313ab7a..ad8475ff 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -24,6 +24,7 @@ import ( var ShouldDisplayHints = map[string]bool{ "detect-ip4-fail": true, "detect-ip6-fail": true, + "detect-timeout": true, "update-timeout": true, } @@ -46,7 +47,7 @@ func getProxied(ppfmt pp.PP, c *config.Config, domain domain.Domain) bool { return false } -var errSettingTimeout = errors.New("setting timeout") +var errTimeout = errors.New("timeout") // setIP extracts relevant settings from the configuration and calls [setter.Setter.Set] with timeout. // ip must be non-zero. @@ -56,15 +57,16 @@ func setIP(ctx context.Context, ppfmt pp.PP, resps := SetterResponses{} for _, domain := range c.Domains[ipNet] { - ctx, cancel := context.WithTimeoutCause(ctx, c.UpdateTimeout, errSettingTimeout) + ctx, cancel := context.WithTimeoutCause(ctx, c.UpdateTimeout, errTimeout) defer cancel() resp := s.Set(ctx, ppfmt, domain, ipNet, ip, c.TTL, getProxied(ppfmt, c, domain), c.RecordComment) resps.Register(resp, domain) if resp == setter.ResponseFailed { - if ShouldDisplayHints["update-timeout"] && errors.Is(context.Cause(ctx), errSettingTimeout) { + if ShouldDisplayHints["update-timeout"] && errors.Is(context.Cause(ctx), errTimeout) { ppfmt.Infof(pp.EmojiHint, - "If your network is experiencing high latency, consider increasing the value of UPDATE_TIMEOUT", + "If your network is experiencing high latency, consider increasing UPDATE_TIMEOUT=%v", + c.UpdateTimeout, ) ShouldDisplayHints["update-timeout"] = false } @@ -81,11 +83,20 @@ func deleteIP( resps := SetterResponses{} for _, domain := range c.Domains[ipNet] { - ctx, cancel := context.WithTimeout(ctx, c.UpdateTimeout) + ctx, cancel := context.WithTimeoutCause(ctx, c.UpdateTimeout, errTimeout) defer cancel() resp := s.Delete(ctx, ppfmt, domain, ipNet) resps.Register(resp, domain) + if resp == setter.ResponseFailed { + if ShouldDisplayHints["update-timeout"] && errors.Is(context.Cause(ctx), errTimeout) { + ppfmt.Infof(pp.EmojiHint, + "If your network is experiencing high latency, consider increasing UPDATE_TIMEOUT=%v", + c.UpdateTimeout, + ) + ShouldDisplayHints["update-timeout"] = false + } + } } return GenerateDeleteMessage(ipNet, resps) @@ -94,7 +105,7 @@ func deleteIP( func detectIP(ctx context.Context, ppfmt pp.PP, c *config.Config, ipNet ipnet.Type, use1001 bool, ) (netip.Addr, message.Message) { - ctx, cancel := context.WithTimeout(ctx, c.DetectionTimeout) + ctx, cancel := context.WithTimeoutCause(ctx, c.DetectionTimeout, errTimeout) defer cancel() ip, ok := c.Provider[ipNet].GetIP(ctx, ppfmt, ipNet, use1001) @@ -102,7 +113,14 @@ func detectIP(ctx context.Context, ppfmt pp.PP, ppfmt.Infof(pp.EmojiInternet, "Detected the %s address: %v", ipNet.Describe(), ip) } else { ppfmt.Errorf(pp.EmojiError, "Failed to detect the %s address", ipNet.Describe()) - if ShouldDisplayHints[getHintIDForDetection(ipNet)] { + + if ShouldDisplayHints["detect-timeout"] && errors.Is(context.Cause(ctx), errTimeout) { + ppfmt.Infof(pp.EmojiHint, + "If your network is experiencing high latency, consider increasing DETECTION_TIMEOUT=%v", + c.DetectionTimeout, + ) + ShouldDisplayHints["detect-timeout"] = false + } else if ShouldDisplayHints[getHintIDForDetection(ipNet)] { switch ipNet { case ipnet.IP6: ppfmt.Infof(pp.EmojiHint, "If you are using Docker or Kubernetes, IPv6 often requires additional setups") //nolint:lll diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 9df2cd80..f680feb3 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -22,7 +22,7 @@ import ( const RecordComment string = "hello" -//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable +//nolint:funlen,paralleltest // updater.ShouldDisplayHints is a global variable func TestUpdateIPsMultiple(t *testing.T) { domain4_1 := domain.FQDN("ip4.hello1") domain4_2 := domain.FQDN("ip4.hello2") @@ -104,6 +104,7 @@ func TestUpdateIPsMultiple(t *testing.T) { } conf.RecordComment = RecordComment conf.Use1001 = true + conf.DetectionTimeout = time.Second conf.UpdateTimeout = time.Second mockPP := mocks.NewMockPP(mockCtrl) if tc.prepareMockPP != nil { @@ -135,7 +136,7 @@ func TestUpdateIPsMultiple(t *testing.T) { } } -//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable +//nolint:funlen,paralleltest // updater.ShouldDisplayHints is a global variable func TestDeleteIPsMultiple(t *testing.T) { domain4_1 := domain.FQDN("ip4.hello1") domain4_2 := domain.FQDN("ip4.hello2") @@ -203,6 +204,7 @@ func TestDeleteIPsMultiple(t *testing.T) { } conf.RecordComment = RecordComment conf.Use1001 = true + conf.DetectionTimeout = time.Second conf.UpdateTimeout = time.Second mockPP := mocks.NewMockPP(mockCtrl) if tc.prepareMockPP != nil { @@ -228,8 +230,8 @@ func TestDeleteIPsMultiple(t *testing.T) { } } -//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable -func TestUpdateIPsUninitializedProbied(t *testing.T) { +//nolint:funlen,paralleltest // updater.ShouldDisplayHints is a global variable +func TestUpdateIPsUninitializedProxied(t *testing.T) { domain4 := domain.FQDN("ip4.hello") domains := map[ipnet.Type][]domain.Domain{ ipnet.IP4: {domain4}, @@ -279,6 +281,7 @@ func TestUpdateIPsUninitializedProbied(t *testing.T) { conf.Proxied = map[domain.Domain]bool{} conf.RecordComment = RecordComment conf.Use1001 = true + conf.DetectionTimeout = time.Second conf.UpdateTimeout = time.Second mockPP := mocks.NewMockPP(mockCtrl) if tc.prepareMockPP != nil { @@ -310,7 +313,7 @@ func TestUpdateIPsUninitializedProbied(t *testing.T) { } } -//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable +//nolint:funlen,paralleltest // updater.ShouldDisplayHints is a global variable func TestUpdateIPsHints(t *testing.T) { domain4 := domain.FQDN("ip4.hello") domain6 := domain.FQDN("ip6.hello") @@ -367,6 +370,7 @@ func TestUpdateIPsHints(t *testing.T) { conf.Proxied = map[domain.Domain]bool{domain4: false, domain6: false} conf.RecordComment = RecordComment conf.Use1001 = true + conf.DetectionTimeout = time.Second conf.UpdateTimeout = time.Second mockPP := mocks.NewMockPP(mockCtrl) if tc.prepareMockPP != nil { @@ -398,7 +402,7 @@ func TestUpdateIPsHints(t *testing.T) { } } -//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable +//nolint:funlen,paralleltest // updater.ShouldDisplayHints is a global variable func TestUpdateIPs(t *testing.T) { domain4 := domain.FQDN("ip4.hello") domain6 := domain.FQDN("ip6.hello") @@ -597,14 +601,37 @@ func TestUpdateIPs(t *testing.T) { }, nil, }, - "slow-setting": { + "detect-timeout": { + false, + []string{"Failed to detect IPv4 address"}, + []string{"Failed to detect the IPv4 address."}, + func(m *mocks.MockPP) { + gomock.InOrder( + m.EXPECT().Errorf(pp.EmojiError, "Failed to detect the %s address", "IPv4"), + m.EXPECT().Infof(pp.EmojiHint, "If your network is experiencing high latency, consider increasing DETECTION_TIMEOUT=%v", time.Second), //nolint:lll + ) + }, + mockproviders{ + ipnet.IP4: func(ppfmt pp.PP, m *mocks.MockProvider) { + m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP4, true). + DoAndReturn( + func(context.Context, pp.PP, ipnet.Type, bool) (netip.Addr, bool) { + time.Sleep(2 * time.Second) + return netip.Addr{}, false + }, + ) + }, + }, + nil, + }, + "set-timeout": { false, []string{"Failed to set A (127.0.0.1): ip4.hello"}, []string{"Failed to finish updating A records of ip4.hello with 127.0.0.1."}, func(m *mocks.MockPP) { gomock.InOrder( m.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address: %v", "IPv4", ip4), - m.EXPECT().Infof(pp.EmojiHint, "If your network is experiencing high latency, consider increasing the value of UPDATE_TIMEOUT"), //nolint:lll + m.EXPECT().Infof(pp.EmojiHint, "If your network is experiencing high latency, consider increasing UPDATE_TIMEOUT=%v", time.Second), //nolint:lll ) }, mockproviders{ @@ -615,7 +642,7 @@ func TestUpdateIPs(t *testing.T) { func(ppfmt pp.PP, m *mocks.MockSetter) { m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, false, RecordComment). DoAndReturn( - func(_ context.Context, _ pp.PP, _ domain.Domain, _ ipnet.Type, _ netip.Addr, _ api.TTL, _ bool, _ string) setter.ResponseCode { //nolint:lll + func(context.Context, pp.PP, domain.Domain, ipnet.Type, netip.Addr, api.TTL, bool, string) setter.ResponseCode { //nolint:lll time.Sleep(2 * time.Second) return setter.ResponseFailed }) @@ -631,6 +658,7 @@ func TestUpdateIPs(t *testing.T) { conf.Proxied = map[domain.Domain]bool{domain4: false, domain6: false} conf.RecordComment = RecordComment conf.Use1001 = true + conf.DetectionTimeout = time.Second conf.UpdateTimeout = time.Second mockPP := mocks.NewMockPP(mockCtrl) if tc.prepareMockPP != nil { @@ -662,7 +690,7 @@ func TestUpdateIPs(t *testing.T) { } } -//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable +//nolint:funlen,paralleltest // updater.ShouldDisplayHints is a global variable func TestDeleteIPs(t *testing.T) { domain4 := domain.FQDN("ip4.hello") domain6 := domain.FQDN("ip6.hello") @@ -769,6 +797,23 @@ func TestDeleteIPs(t *testing.T) { ) }, }, + "timeout": { + false, + []string{"Failed to delete A: ip4.hello"}, + []string{"Failed to finish deleting A records of ip4.hello."}, + func(m *mocks.MockPP) { + m.EXPECT().Infof(pp.EmojiHint, "If your network is experiencing high latency, consider increasing UPDATE_TIMEOUT=%v", time.Second) //nolint:lll + }, + mockproviders{ipnet.IP4: true}, + func(ppfmt pp.PP, m *mocks.MockSetter) { + m.EXPECT().Delete(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4). + DoAndReturn( + func(context.Context, pp.PP, domain.Domain, ipnet.Type) setter.ResponseCode { + time.Sleep(2 * time.Second) + return setter.ResponseFailed + }) + }, + }, } { t.Run(name, func(t *testing.T) { mockCtrl := gomock.NewController(t) @@ -778,6 +823,9 @@ func TestDeleteIPs(t *testing.T) { conf.TTL = api.TTLAuto conf.Proxied = map[domain.Domain]bool{domain4: false, domain6: false} conf.RecordComment = RecordComment + conf.Use1001 = true + conf.DetectionTimeout = time.Second + conf.UpdateTimeout = time.Second mockPP := mocks.NewMockPP(mockCtrl) if tc.prepareMockPP != nil { tc.prepareMockPP(mockPP) @@ -798,7 +846,6 @@ func TestDeleteIPs(t *testing.T) { tc.prepareMockSetter(mockPP, mockSetter) } resp := updater.DeleteIPs(ctx, mockPP, conf, mockSetter) - require.Equal(t, message.Message{ Ok: tc.ok, NotifierMessages: tc.notifierMessages,