@@ -176,3 +176,120 @@ func TestWaitUnassigned(t *testing.T) {
176176 }
177177 })
178178}
179+
180+ // windowed serves resmgr's post-deauth behaviour: the per-host endpoint 404s while the
181+ // list keeps reporting whatever `list` says.
182+ func windowed (t * testing.T , perHostStatus int , perHost , list string ) * gophercloud.ServiceClient {
183+ t .Helper ()
184+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
185+ w .Header ().Set ("Content-Type" , "application/json" )
186+ if strings .TrimSuffix (r .URL .Path , "/" ) == "/hosts" {
187+ _ , _ = w .Write ([]byte (list ))
188+ return
189+ }
190+ w .WriteHeader (perHostStatus )
191+ _ , _ = w .Write ([]byte (perHost ))
192+ }))
193+ t .Cleanup (srv .Close )
194+ return & gophercloud.ServiceClient {ProviderClient : & gophercloud.ProviderClient {}, Endpoint : srv .URL + "/" }
195+ }
196+
197+ // A host being deauthorised 404s on its per-host endpoint for minutes while the list still
198+ // reports it. Believing that 404 drops a live assignment or role out of state, and the next
199+ // apply then fails against a reality it never left.
200+ func TestHostRecordDoesNotBelieveThePostDeauthWindow (t * testing.T ) {
201+ const listed = `[{"id":"host-a","roles":["hypervisor"],"hostconfig_id":"hc-1"}]`
202+
203+ t .Run ("404 while the list still has it is not gone" , func (t * testing.T ) {
204+ host , known , err := hostRecord (context .Background (),
205+ windowed (t , 404 , `{"message":"HostNotFound"}` , listed ), "host-a" )
206+ if err != nil {
207+ t .Fatalf ("unexpected error: %v" , err )
208+ }
209+ if ! known {
210+ t .Fatal ("reported the host as gone; a Read would drop a live resource from state" )
211+ }
212+ if host .HostConfigID != "hc-1" || len (host .Roles ) != 1 {
213+ t .Fatalf ("the list record did not come back: %+v" , host )
214+ }
215+ })
216+
217+ t .Run ("404 and absent from the list is gone" , func (t * testing.T ) {
218+ _ , known , err := hostRecord (context .Background (),
219+ windowed (t , 404 , `{"message":"HostNotFound"}` , `[{"id":"host-b"}]` ), "host-a" )
220+ if err != nil {
221+ t .Fatalf ("unexpected error: %v" , err )
222+ }
223+ if known {
224+ t .Fatal ("a host resmgr does not list anywhere is gone and must leave state" )
225+ }
226+ })
227+
228+ t .Run ("an unreadable list fails closed" , func (t * testing.T ) {
229+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
230+ if strings .TrimSuffix (r .URL .Path , "/" ) == "/hosts" {
231+ w .WriteHeader (http .StatusInternalServerError )
232+ return
233+ }
234+ w .WriteHeader (http .StatusNotFound )
235+ }))
236+ defer srv .Close ()
237+ client := & gophercloud.ServiceClient {ProviderClient : & gophercloud.ProviderClient {}, Endpoint : srv .URL + "/" }
238+ if _ , known , err := hostRecord (context .Background (), client , "host-a" ); err == nil || known {
239+ t .Fatal ("an unverified 404 must surface as an error, not as an absence" )
240+ }
241+ })
242+
243+ // A per-host failure that is not a 404 says nothing about whether the host exists, so it
244+ // has to reach the caller as an error. All three Read paths check err before known, which
245+ // makes this guard the thing standing between a transient 500 or an expired token and a
246+ // live assignment or role being dropped from state — the outcome this whole fix exists to
247+ // prevent. Without this case the guard can be deleted and the suite stays green.
248+ t .Run ("a per-host failure that is not a 404 is not an absence" , func (t * testing.T ) {
249+ for _ , status := range []int {
250+ http .StatusInternalServerError ,
251+ http .StatusUnauthorized ,
252+ http .StatusForbidden ,
253+ http .StatusServiceUnavailable ,
254+ } {
255+ _ , known , err := hostRecord (context .Background (),
256+ windowed (t , status , `{"message":"boom"}` , listed ), "host-a" )
257+ if err == nil {
258+ t .Errorf ("status %d: got no error; a failed read would be reported as a deleted host" , status )
259+ }
260+ if known {
261+ t .Errorf ("status %d: reported the host as known off a read that never answered" , status )
262+ }
263+ }
264+ })
265+
266+ t .Run ("the ordinary path is one request" , func (t * testing.T ) {
267+ var n int
268+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , _ * http.Request ) {
269+ n ++
270+ w .Header ().Set ("Content-Type" , "application/json" )
271+ _ , _ = w .Write ([]byte (`{"id":"host-a","roles":["hypervisor"],"hostconfig_id":"hc-1"}` ))
272+ }))
273+ defer srv .Close ()
274+ client := & gophercloud.ServiceClient {ProviderClient : & gophercloud.ProviderClient {}, Endpoint : srv .URL + "/" }
275+ if _ , known , err := hostRecord (context .Background (), client , "host-a" ); err != nil || ! known {
276+ t .Fatalf ("known=%v err=%v" , known , err )
277+ }
278+ if n != 1 {
279+ t .Fatalf ("a host resmgr describes cost %d requests, want 1" , n )
280+ }
281+ })
282+ }
283+
284+ func TestHostHasRoleThroughTheWindow (t * testing.T ) {
285+ const listed = `[{"id":"host-a","roles":["hypervisor","image-library"]}]`
286+ client := windowed (t , 404 , `{"message":"HostNotFound"}` , listed )
287+
288+ has , known , err := hostHasRole (context .Background (), client , "host-a" , "hypervisor" )
289+ if err != nil || ! known || ! has {
290+ t .Fatalf ("has=%v known=%v err=%v; the role is still on the host" , has , known , err )
291+ }
292+ if has , known , _ = hostHasRole (context .Background (), client , "host-a" , "persistent-storage" ); has || ! known {
293+ t .Fatalf ("has=%v known=%v; the host is known, the role is not on it" , has , known )
294+ }
295+ }
0 commit comments