Make ACME cursor persistence best effort
- commit
- 86e5b3bfb73199a4039eee3940bab3dbd960852e
- author
- brice · · 2026-05-01 10:54 UTC
- parent
- 90ab417
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +79 -15
M docs/config/server.md +1 -1
| @@ -69,7 +69,7 @@ With TLS enabled: | |||
| 69 | 69 | - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/` | |
| 70 | 70 | - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory | |
| 71 | 71 | - The intermediate CA auto-renews before expiry | |
| 72 | - | - An onboarding page at `https://<gordon-host>:<tls_port>/ca` lets clients download the root CA certificate | |
| 72 | + | - An onboarding page at `https://<gordon-host>:<tls_port>/.well-known/gordon/` lets clients download the root CA certificate | |
| 73 | 73 | - The root CA is stable across restarts (generated once, persisted to disk) | |
| 74 | 74 | ||
| 75 | 75 | #### Custom certificates | |
| @@ -69,7 +69,7 @@ With TLS enabled: | |||||
| 69 | - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/` | 69 | - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/` | ||
| 70 | - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory | 70 | - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory | ||
| 71 | - The intermediate CA auto-renews before expiry | 71 | - The intermediate CA auto-renews before expiry | ||
| 72 | - | - An onboarding page at `https://<gordon-host>:<tls_port>/ca` lets clients download the root CA certificate | 72 | + | - An onboarding page at `https://<gordon-host>:<tls_port>/.well-known/gordon/` lets clients download the root CA certificate |
| 73 | - The root CA is stable across restarts (generated once, persisted to disk) | 73 | - The root CA is stable across restarts (generated once, persisted to disk) | ||
| 74 | 74 | ||||
| 75 | #### Custom certificates | 75 | #### Custom certificates | ||
M internal/adapters/in/http/onboarding/handler_test.go +3 -1
| @@ -31,6 +31,8 @@ func newTestServer(t *testing.T) (*httptest.Server, []byte, []byte) { | |||
| 31 | 31 | h, rootPEM, mobileconfig := newTestHandler() | |
| 32 | 32 | ||
| 33 | 33 | mux := http.NewServeMux() | |
| 34 | + | mux.HandleFunc("GET /.well-known/gordon/{$}", h.ServeOnboardingPage) | |
| 35 | + | mux.HandleFunc("HEAD /.well-known/gordon/{$}", h.ServeOnboardingPage) | |
| 34 | 36 | mux.HandleFunc("GET /.well-known/gordon/ca", h.ServeOnboardingPage) | |
| 35 | 37 | mux.HandleFunc("HEAD /.well-known/gordon/ca", h.ServeOnboardingPage) | |
| 36 | 38 | mux.HandleFunc("GET /.well-known/gordon/ca.crt", h.ServeCACert) | |
| @@ -93,7 +95,7 @@ func TestHandler_Mobileconfig(t *testing.T) { | |||
| 93 | 95 | func TestHandler_OnboardingPage(t *testing.T) { | |
| 94 | 96 | srv, _, _ := newTestServer(t) | |
| 95 | 97 | ||
| 96 | - | resp, err := http.Get(srv.URL + "/.well-known/gordon/ca") | |
| 98 | + | resp, err := http.Get(srv.URL + "/.well-known/gordon/") | |
| 97 | 99 | require.NoError(t, err) | |
| 98 | 100 | defer resp.Body.Close() | |
| 99 | 101 | ||
| @@ -31,6 +31,8 @@ func newTestServer(t *testing.T) (*httptest.Server, []byte, []byte) { | |||||
| 31 | h, rootPEM, mobileconfig := newTestHandler() | 31 | h, rootPEM, mobileconfig := newTestHandler() | ||
| 32 | 32 | ||||
| 33 | mux := http.NewServeMux() | 33 | mux := http.NewServeMux() | ||
| 34 | + | mux.HandleFunc("GET /.well-known/gordon/{$}", h.ServeOnboardingPage) | |||
| 35 | + | mux.HandleFunc("HEAD /.well-known/gordon/{$}", h.ServeOnboardingPage) | |||
| 34 | mux.HandleFunc("GET /.well-known/gordon/ca", h.ServeOnboardingPage) | 36 | mux.HandleFunc("GET /.well-known/gordon/ca", h.ServeOnboardingPage) | ||
| 35 | mux.HandleFunc("HEAD /.well-known/gordon/ca", h.ServeOnboardingPage) | 37 | mux.HandleFunc("HEAD /.well-known/gordon/ca", h.ServeOnboardingPage) | ||
| 36 | mux.HandleFunc("GET /.well-known/gordon/ca.crt", h.ServeCACert) | 38 | mux.HandleFunc("GET /.well-known/gordon/ca.crt", h.ServeCACert) | ||
| @@ -93,7 +95,7 @@ func TestHandler_Mobileconfig(t *testing.T) { | |||||
| 93 | func TestHandler_OnboardingPage(t *testing.T) { | 95 | func TestHandler_OnboardingPage(t *testing.T) { | ||
| 94 | srv, _, _ := newTestServer(t) | 96 | srv, _, _ := newTestServer(t) | ||
| 95 | 97 | ||||
| 96 | - | resp, err := http.Get(srv.URL + "/.well-known/gordon/ca") | 98 | + | resp, err := http.Get(srv.URL + "/.well-known/gordon/") |
| 97 | require.NoError(t, err) | 99 | require.NoError(t, err) | ||
| 98 | defer resp.Body.Close() | 100 | defer resp.Body.Close() | ||
| 99 | 101 | ||||
M internal/usecase/publictls/service.go +3 -2
| @@ -87,7 +87,8 @@ func (s *Service) Load(ctx context.Context) error { | |||
| 87 | 87 | } | |
| 88 | 88 | state, err := s.deps.Store.LoadState(ctx) | |
| 89 | 89 | if err != nil { | |
| 90 | - | return fmt.Errorf("load certificate store state: %w", err) | |
| 90 | + | s.log.Warn().Err(err).Msg("failed to load certificate store state; using in-memory ACME obtain cursor") | |
| 91 | + | state = out.CertificateStoreState{} | |
| 91 | 92 | } | |
| 92 | 93 | if state.ObtainCursor < 0 { | |
| 93 | 94 | state.ObtainCursor = 0 | |
| @@ -204,7 +205,7 @@ func (s *Service) Reconcile(ctx context.Context) error { | |||
| 204 | 205 | ||
| 205 | 206 | if cursorChanged { | |
| 206 | 207 | if err := s.persistObtainCursor(ctx, nextCursor); err != nil { | |
| 207 | - | return err | |
| 208 | + | log.Warn().Err(err).Int("obtain_cursor", nextCursor).Msg("failed to persist ACME obtain cursor; continuing with in-memory cursor") | |
| 208 | 209 | } | |
| 209 | 210 | s.mu.Lock() | |
| 210 | 211 | if s.obtainCursor == previousCursor { | |
| @@ -87,7 +87,8 @@ func (s *Service) Load(ctx context.Context) error { | |||||
| 87 | } | 87 | } | ||
| 88 | state, err := s.deps.Store.LoadState(ctx) | 88 | state, err := s.deps.Store.LoadState(ctx) | ||
| 89 | if err != nil { | 89 | if err != nil { | ||
| 90 | - | return fmt.Errorf("load certificate store state: %w", err) | 90 | + | s.log.Warn().Err(err).Msg("failed to load certificate store state; using in-memory ACME obtain cursor") |
| 91 | + | state = out.CertificateStoreState{} | |||
| 91 | } | 92 | } | ||
| 92 | if state.ObtainCursor < 0 { | 93 | if state.ObtainCursor < 0 { | ||
| 93 | state.ObtainCursor = 0 | 94 | state.ObtainCursor = 0 | ||
| @@ -204,7 +205,7 @@ func (s *Service) Reconcile(ctx context.Context) error { | |||||
| 204 | 205 | ||||
| 205 | if cursorChanged { | 206 | if cursorChanged { | ||
| 206 | if err := s.persistObtainCursor(ctx, nextCursor); err != nil { | 207 | if err := s.persistObtainCursor(ctx, nextCursor); err != nil { | ||
| 207 | - | return err | 208 | + | log.Warn().Err(err).Int("obtain_cursor", nextCursor).Msg("failed to persist ACME obtain cursor; continuing with in-memory cursor") |
| 208 | } | 209 | } | ||
| 209 | s.mu.Lock() | 210 | s.mu.Lock() | ||
| 210 | if s.obtainCursor == previousCursor { | 211 | if s.obtainCursor == previousCursor { | ||
M internal/usecase/publictls/service_test.go +72 -11
| @@ -134,6 +134,7 @@ type mockCertificateStoreState struct { | |||
| 134 | 134 | account *out.ACMEAccount | |
| 135 | 135 | certs []out.StoredCertificate | |
| 136 | 136 | state out.CertificateStoreState | |
| 137 | + | loadStateErr error | |
| 137 | 138 | saveStateErr error | |
| 138 | 139 | } | |
| 139 | 140 | ||
| @@ -157,6 +158,12 @@ func (s *mockCertificateStoreState) SetState(state out.CertificateStoreState) { | |||
| 157 | 158 | s.state = state | |
| 158 | 159 | } | |
| 159 | 160 | ||
| 161 | + | func (s *mockCertificateStoreState) SetLoadStateError(err error) { | |
| 162 | + | s.mu.Lock() | |
| 163 | + | defer s.mu.Unlock() | |
| 164 | + | s.loadStateErr = err | |
| 165 | + | } | |
| 166 | + | ||
| 160 | 167 | func (s *mockCertificateStoreState) SetSaveStateError(err error) { | |
| 161 | 168 | s.mu.Lock() | |
| 162 | 169 | defer s.mu.Unlock() | |
| @@ -194,7 +201,12 @@ func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*o | |||
| 194 | 201 | loadAllCall.Maybe() | |
| 195 | 202 | ||
| 196 | 203 | loadStateCall := store.EXPECT().LoadState(mock.Anything).RunAndReturn(func(context.Context) (out.CertificateStoreState, error) { | |
| 197 | - | return state.State(), nil | |
| 204 | + | state.mu.Lock() | |
| 205 | + | defer state.mu.Unlock() | |
| 206 | + | if state.loadStateErr != nil { | |
| 207 | + | return out.CertificateStoreState{}, state.loadStateErr | |
| 208 | + | } | |
| 209 | + | return state.state, nil | |
| 198 | 210 | }) | |
| 199 | 211 | loadStateCall.Maybe() | |
| 200 | 212 | ||
| @@ -390,7 +402,7 @@ func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) { | |||
| 390 | 402 | assert.Equal(t, "http01-b.example.com", stored[0].ID) | |
| 391 | 403 | } | |
| 392 | 404 | ||
| 393 | - | func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing.T) { | |
| 405 | + | func TestServiceLoadUsesZeroCursorWhenLoadStateFails(t *testing.T) { | |
| 394 | 406 | ctx := context.Background() | |
| 395 | 407 | ||
| 396 | 408 | routes := &fakeRoutes{ | |
| @@ -401,6 +413,8 @@ func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing | |||
| 401 | 413 | } | |
| 402 | 414 | issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil) | |
| 403 | 415 | store, storeState := newMockCertificateStore(t) | |
| 416 | + | storeState.SetState(out.CertificateStoreState{ObtainCursor: 1}) | |
| 417 | + | storeState.SetLoadStateError(fmt.Errorf("state unreadable")) | |
| 404 | 418 | ||
| 405 | 419 | cfg := Config{ | |
| 406 | 420 | Enabled: true, | |
| @@ -424,20 +438,67 @@ func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing | |||
| 424 | 438 | }, | |
| 425 | 439 | }) | |
| 426 | 440 | ||
| 427 | - | storeState.SetSaveStateError(fmt.Errorf("disk full")) | |
| 428 | 441 | require.NoError(t, svc.Load(ctx)) | |
| 429 | - | err := svc.Reconcile(ctx) | |
| 430 | - | require.Error(t, err) | |
| 431 | - | assert.Contains(t, err.Error(), "save certificate store state") | |
| 432 | - | assert.Empty(t, recorder.Orders(), "obtain should not run when cursor persistence fails") | |
| 433 | - | assert.Equal(t, out.CertificateStoreState{}, storeState.State()) | |
| 434 | - | ||
| 435 | - | storeState.SetSaveStateError(nil) | |
| 436 | 442 | require.NoError(t, svc.Reconcile(ctx)) | |
| 437 | 443 | ||
| 438 | 444 | orders := recorder.Orders() | |
| 439 | 445 | require.Len(t, orders, 1) | |
| 440 | - | assert.Equal(t, "http01-a.example.com", orders[0].ID, "failed cursor persistence should not skip the first target in memory") | |
| 446 | + | assert.Equal(t, "http01-a.example.com", orders[0].ID, "LoadState failure should fall back to the zero cursor") | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func TestServiceReconcileUsesInMemoryCursorWhenPersistFails(t *testing.T) { | |
| 450 | + | ctx := context.Background() | |
| 451 | + | ||
| 452 | + | routes := &fakeRoutes{ | |
| 453 | + | routes: []domain.Route{ | |
| 454 | + | {Domain: "b.example.com"}, | |
| 455 | + | {Domain: "a.example.com"}, | |
| 456 | + | }, | |
| 457 | + | } | |
| 458 | + | issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |
| 459 | + | if order.ID == "http01-a.example.com" { | |
| 460 | + | return nil, fmt.Errorf("acme validation failed") | |
| 461 | + | } | |
| 462 | + | return defaultStoredCert(order) | |
| 463 | + | }, nil) | |
| 464 | + | store, storeState := newMockCertificateStore(t) | |
| 465 | + | ||
| 466 | + | cfg := Config{ | |
| 467 | + | Enabled: true, | |
| 468 | + | Email: "admin@example.com", | |
| 469 | + | Challenge: "http-01", | |
| 470 | + | HTTPPort: 8088, | |
| 471 | + | TLSPort: 8443, | |
| 472 | + | ObtainBatchSize: 1, | |
| 473 | + | } | |
| 474 | + | svc := NewService(cfg, ServiceDeps{ | |
| 475 | + | Config: cfg, | |
| 476 | + | Routes: routes, | |
| 477 | + | Issuer: issuer, | |
| 478 | + | Store: store, | |
| 479 | + | Challenges: NewHTTP01Challenges(), | |
| 480 | + | Effective: EffectiveChallenge{ | |
| 481 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 482 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 483 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 484 | + | Reason: "http-01 challenge selected", | |
| 485 | + | }, | |
| 486 | + | }) | |
| 487 | + | ||
| 488 | + | storeState.SetSaveStateError(fmt.Errorf("disk full")) | |
| 489 | + | require.NoError(t, svc.Load(ctx)) | |
| 490 | + | require.NoError(t, svc.Reconcile(ctx)) | |
| 491 | + | require.NoError(t, svc.Reconcile(ctx)) | |
| 492 | + | assert.Equal(t, out.CertificateStoreState{}, storeState.State(), "failed SaveState should leave persisted state unchanged") | |
| 493 | + | ||
| 494 | + | orders := recorder.Orders() | |
| 495 | + | require.Len(t, orders, 2) | |
| 496 | + | assert.Equal(t, "http01-a.example.com", orders[0].ID) | |
| 497 | + | assert.Equal(t, "http01-b.example.com", orders[1].ID, "SaveState failure should still advance the in-memory cursor") | |
| 498 | + | ||
| 499 | + | stored := storeState.All() | |
| 500 | + | require.Len(t, stored, 1) | |
| 501 | + | assert.Equal(t, "http01-b.example.com", stored[0].ID) | |
| 441 | 502 | } | |
| 442 | 503 | ||
| 443 | 504 | func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) { | |
| @@ -134,6 +134,7 @@ type mockCertificateStoreState struct { | |||||
| 134 | account *out.ACMEAccount | 134 | account *out.ACMEAccount | ||
| 135 | certs []out.StoredCertificate | 135 | certs []out.StoredCertificate | ||
| 136 | state out.CertificateStoreState | 136 | state out.CertificateStoreState | ||
| 137 | + | loadStateErr error | |||
| 137 | saveStateErr error | 138 | saveStateErr error | ||
| 138 | } | 139 | } | ||
| 139 | 140 | ||||
| @@ -157,6 +158,12 @@ func (s *mockCertificateStoreState) SetState(state out.CertificateStoreState) { | |||||
| 157 | s.state = state | 158 | s.state = state | ||
| 158 | } | 159 | } | ||
| 159 | 160 | ||||
| 161 | + | func (s *mockCertificateStoreState) SetLoadStateError(err error) { | |||
| 162 | + | s.mu.Lock() | |||
| 163 | + | defer s.mu.Unlock() | |||
| 164 | + | s.loadStateErr = err | |||
| 165 | + | } | |||
| 166 | + | ||||
| 160 | func (s *mockCertificateStoreState) SetSaveStateError(err error) { | 167 | func (s *mockCertificateStoreState) SetSaveStateError(err error) { | ||
| 161 | s.mu.Lock() | 168 | s.mu.Lock() | ||
| 162 | defer s.mu.Unlock() | 169 | defer s.mu.Unlock() | ||
| @@ -194,7 +201,12 @@ func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*o | |||||
| 194 | loadAllCall.Maybe() | 201 | loadAllCall.Maybe() | ||
| 195 | 202 | ||||
| 196 | loadStateCall := store.EXPECT().LoadState(mock.Anything).RunAndReturn(func(context.Context) (out.CertificateStoreState, error) { | 203 | loadStateCall := store.EXPECT().LoadState(mock.Anything).RunAndReturn(func(context.Context) (out.CertificateStoreState, error) { | ||
| 197 | - | return state.State(), nil | 204 | + | state.mu.Lock() |
| 205 | + | defer state.mu.Unlock() | |||
| 206 | + | if state.loadStateErr != nil { | |||
| 207 | + | return out.CertificateStoreState{}, state.loadStateErr | |||
| 208 | + | } | |||
| 209 | + | return state.state, nil | |||
| 198 | }) | 210 | }) | ||
| 199 | loadStateCall.Maybe() | 211 | loadStateCall.Maybe() | ||
| 200 | 212 | ||||
| @@ -390,7 +402,7 @@ func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) { | |||||
| 390 | assert.Equal(t, "http01-b.example.com", stored[0].ID) | 402 | assert.Equal(t, "http01-b.example.com", stored[0].ID) | ||
| 391 | } | 403 | } | ||
| 392 | 404 | ||||
| 393 | - | func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing.T) { | 405 | + | func TestServiceLoadUsesZeroCursorWhenLoadStateFails(t *testing.T) { |
| 394 | ctx := context.Background() | 406 | ctx := context.Background() | ||
| 395 | 407 | ||||
| 396 | routes := &fakeRoutes{ | 408 | routes := &fakeRoutes{ | ||
| @@ -401,6 +413,8 @@ func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing | |||||
| 401 | } | 413 | } | ||
| 402 | issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil) | 414 | issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil) | ||
| 403 | store, storeState := newMockCertificateStore(t) | 415 | store, storeState := newMockCertificateStore(t) | ||
| 416 | + | storeState.SetState(out.CertificateStoreState{ObtainCursor: 1}) | |||
| 417 | + | storeState.SetLoadStateError(fmt.Errorf("state unreadable")) | |||
| 404 | 418 | ||||
| 405 | cfg := Config{ | 419 | cfg := Config{ | ||
| 406 | Enabled: true, | 420 | Enabled: true, | ||
| @@ -424,20 +438,67 @@ func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing | |||||
| 424 | }, | 438 | }, | ||
| 425 | }) | 439 | }) | ||
| 426 | 440 | ||||
| 427 | - | storeState.SetSaveStateError(fmt.Errorf("disk full")) | |||
| 428 | require.NoError(t, svc.Load(ctx)) | 441 | require.NoError(t, svc.Load(ctx)) | ||
| 429 | - | err := svc.Reconcile(ctx) | |||
| 430 | - | require.Error(t, err) | |||
| 431 | - | assert.Contains(t, err.Error(), "save certificate store state") | |||
| 432 | - | assert.Empty(t, recorder.Orders(), "obtain should not run when cursor persistence fails") | |||
| 433 | - | assert.Equal(t, out.CertificateStoreState{}, storeState.State()) | |||
| 434 | - | ||||
| 435 | - | storeState.SetSaveStateError(nil) | |||
| 436 | require.NoError(t, svc.Reconcile(ctx)) | 442 | require.NoError(t, svc.Reconcile(ctx)) | ||
| 437 | 443 | ||||
| 438 | orders := recorder.Orders() | 444 | orders := recorder.Orders() | ||
| 439 | require.Len(t, orders, 1) | 445 | require.Len(t, orders, 1) | ||
| 440 | - | assert.Equal(t, "http01-a.example.com", orders[0].ID, "failed cursor persistence should not skip the first target in memory") | 446 | + | assert.Equal(t, "http01-a.example.com", orders[0].ID, "LoadState failure should fall back to the zero cursor") |
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func TestServiceReconcileUsesInMemoryCursorWhenPersistFails(t *testing.T) { | |||
| 450 | + | ctx := context.Background() | |||
| 451 | + | ||||
| 452 | + | routes := &fakeRoutes{ | |||
| 453 | + | routes: []domain.Route{ | |||
| 454 | + | {Domain: "b.example.com"}, | |||
| 455 | + | {Domain: "a.example.com"}, | |||
| 456 | + | }, | |||
| 457 | + | } | |||
| 458 | + | issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |||
| 459 | + | if order.ID == "http01-a.example.com" { | |||
| 460 | + | return nil, fmt.Errorf("acme validation failed") | |||
| 461 | + | } | |||
| 462 | + | return defaultStoredCert(order) | |||
| 463 | + | }, nil) | |||
| 464 | + | store, storeState := newMockCertificateStore(t) | |||
| 465 | + | ||||
| 466 | + | cfg := Config{ | |||
| 467 | + | Enabled: true, | |||
| 468 | + | Email: "admin@example.com", | |||
| 469 | + | Challenge: "http-01", | |||
| 470 | + | HTTPPort: 8088, | |||
| 471 | + | TLSPort: 8443, | |||
| 472 | + | ObtainBatchSize: 1, | |||
| 473 | + | } | |||
| 474 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 475 | + | Config: cfg, | |||
| 476 | + | Routes: routes, | |||
| 477 | + | Issuer: issuer, | |||
| 478 | + | Store: store, | |||
| 479 | + | Challenges: NewHTTP01Challenges(), | |||
| 480 | + | Effective: EffectiveChallenge{ | |||
| 481 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 482 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 483 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 484 | + | Reason: "http-01 challenge selected", | |||
| 485 | + | }, | |||
| 486 | + | }) | |||
| 487 | + | ||||
| 488 | + | storeState.SetSaveStateError(fmt.Errorf("disk full")) | |||
| 489 | + | require.NoError(t, svc.Load(ctx)) | |||
| 490 | + | require.NoError(t, svc.Reconcile(ctx)) | |||
| 491 | + | require.NoError(t, svc.Reconcile(ctx)) | |||
| 492 | + | assert.Equal(t, out.CertificateStoreState{}, storeState.State(), "failed SaveState should leave persisted state unchanged") | |||
| 493 | + | ||||
| 494 | + | orders := recorder.Orders() | |||
| 495 | + | require.Len(t, orders, 2) | |||
| 496 | + | assert.Equal(t, "http01-a.example.com", orders[0].ID) | |||
| 497 | + | assert.Equal(t, "http01-b.example.com", orders[1].ID, "SaveState failure should still advance the in-memory cursor") | |||
| 498 | + | ||||
| 499 | + | stored := storeState.All() | |||
| 500 | + | require.Len(t, stored, 1) | |||
| 501 | + | assert.Equal(t, "http01-b.example.com", stored[0].ID) | |||
| 441 | } | 502 | } | ||
| 442 | 503 | ||||
| 443 | func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) { | 504 | func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) { | ||