11package org .jenkinsci .plugins .github_branch_source ;
22
33import com .github .tomakehurst .wiremock .core .WireMockConfiguration ;
4+ import com .github .tomakehurst .wiremock .extension .responsetemplating .ResponseTemplateTransformer ;
45import com .github .tomakehurst .wiremock .http .RequestMethod ;
56import com .github .tomakehurst .wiremock .junit .WireMockRule ;
67import com .github .tomakehurst .wiremock .matching .RequestPatternBuilder ;
78import jenkins .scm .api .SCMHeadOrigin ;
89import jenkins .scm .api .mixin .ChangeRequestCheckoutStrategy ;
10+ import org .apache .commons .io .FileUtils ;
911import org .junit .Before ;
1012import org .junit .Rule ;
1113import org .junit .Test ;
1416import org .kohsuke .github .GHRepository ;
1517import org .kohsuke .github .GitHub ;
1618
17- import java .io .FileNotFoundException ;
19+ import java .io .File ;
20+ import java .io .IOException ;
1821
1922import static org .junit .Assert .*;
2023import static com .github .tomakehurst .wiremock .client .WireMock .*;
@@ -25,78 +28,136 @@ public class GitHubSCMProbeTest {
2528 public JenkinsRule j = new JenkinsRule ();
2629 public static WireMockRuleFactory factory = new WireMockRuleFactory ();
2730 @ Rule
28- public WireMockRule githubApi = factory .getRule (WireMockConfiguration .options ().dynamicPort ().usingFilesUnderClasspath ("api" ));
31+ public WireMockRule githubApi = factory .getRule (WireMockConfiguration .options ()
32+ .dynamicPort ().usingFilesUnderClasspath ("cache_failure" )
33+ .extensions (ResponseTemplateTransformer .builder ().global (true ).maxCacheEntries (0L ).build ()));
2934 private GitHubSCMProbe probe ;
3035
3136 @ Before
3237 public void setUp () throws Exception {
33- GitHubSCMProbe .JENKINS_54126_WORKAROUND = true ;
34- GitHubSCMProbe .STAT_RETHROW_API_FNF = true ;
35- final GitHub github = Connector .connect ("http://localhost:" + githubApi .port (), null );
38+ // Clear all caches before each test
39+ File cacheBaseDir = new File (j .jenkins .getRootDir (),
40+ GitHubSCMProbe .class .getName () + ".cache" );
41+ if (cacheBaseDir .exists ()) {
42+ FileUtils .cleanDirectory (cacheBaseDir );
43+ }
44+
3645 githubApi .stubFor (
37- get (urlEqualTo ("/repos/cloudbeers/yolo" ))
38- .willReturn (aResponse ()
39- .withStatus (200 )
40- .withHeader ("Content-Type" , "application/json" )
41- .withBodyFile ("body-cloudbeers-yolo-PucD6.json" ))
46+ get (urlEqualTo ("/repos/cloudbeers/yolo" ))
47+ .willReturn (aResponse ()
48+ .withStatus (200 )
49+ .withHeader ("Content-Type" , "application/json" )
50+ .withBodyFile ("body-cloudbeers-yolo-PucD6.json" ))
4251 );
52+ }
53+
54+ void createProbeForPR (int number ) throws IOException {
55+ final GitHub github = Connector .connect ("http://localhost:" + githubApi .port (), null );
56+
4357 final GHRepository repo = github .getRepository ("cloudbeers/yolo" );
44- final PullRequestSCMHead head = new PullRequestSCMHead ("PR-1" , "cloudbeers" , "yolo" , "b" , 1 , new BranchSCMHead ("master" ), new SCMHeadOrigin .Fork ("rsandell" ), ChangeRequestCheckoutStrategy .MERGE );
58+ final PullRequestSCMHead head = new PullRequestSCMHead ("PR-" + number , "cloudbeers" , "yolo" , "b" , number , new BranchSCMHead ("master" ), new SCMHeadOrigin .Fork ("rsandell" ), ChangeRequestCheckoutStrategy .MERGE );
4559 probe = new GitHubSCMProbe (github , repo ,
46- head ,
47- new PullRequestSCMRevision (head , "a" , "b" ));
60+ head ,
61+ new PullRequestSCMRevision (head , "a" , "b" ));
4862 }
4963
5064 @ Issue ("JENKINS-54126" )
51- @ Test ( expected = FileNotFoundException . class )
65+ @ Test
5266 public void statWhenRootIs404 () throws Exception {
53- githubApi .stubFor (get (urlPathEqualTo ("/repos/cloudbeers/yolo/contents/" )).willReturn (aResponse ().withStatus (404 )));
54- probe .stat ("Jenkinsfile" ).exists ();
55- }
67+ githubApi .stubFor (get (urlEqualTo ("/repos/cloudbeers/yolo/contents/?ref=refs%2Fpull%2F1%2Fmerge" )).willReturn (aResponse ().withStatus (404 )).atPriority (0 ));
5668
57- @ Issue ("JENKINS-54126" )
58- @ Test
59- public void statWhenRootIs404WorkaroundOff () throws Exception {
60- GitHubSCMProbe .JENKINS_54126_WORKAROUND = false ;
61- GitHubSCMProbe .STAT_RETHROW_API_FNF = false ;
62- githubApi .stubFor (get (urlPathEqualTo ("/repos/cloudbeers/yolo/contents/" )).willReturn (aResponse ().withStatus (404 )));
63- assertFalse (probe .stat ("README.md" ).exists ());
64- }
69+ createProbeForPR (1 );
6570
66- @ Issue ("JENKINS-54126" )
67- @ Test (expected = FileNotFoundException .class )
68- public void statWhenDirAndRootIs404 () throws Exception {
69- githubApi .stubFor (get (urlPathEqualTo ("/repos/cloudbeers/yolo/contents/" )).willReturn (aResponse ().withStatus (200 )));
70- githubApi .stubFor (get (urlPathEqualTo ("/repos/cloudbeers/yolo/contents/subdir" )).willReturn (aResponse ().withStatus (404 )));
71- probe .stat ("subdir/Jenkinsfile" ).exists ();
71+ assertFalse (probe .stat ("Jenkinsfile" ).exists ());
7272 }
7373
7474 @ Issue ("JENKINS-54126" )
7575 @ Test
7676 public void statWhenDirIs404 () throws Exception {
77- githubApi .stubFor (get (urlPathEqualTo ("/repos/cloudbeers/yolo/contents/subdir" )).willReturn (aResponse ().withStatus (404 )));
78- githubApi . stubFor ( get ( urlPathEqualTo ( "/repos/cloudbeers/yolo/contents/" ))
79- . willReturn ( aResponse ()
80- . withStatus ( 200 )
81- . withHeader ( "Content-Type" , "application/json" )
82- . withBodyFile ( "body-yolo-contents-8rd37.json" ) ));
77+ githubApi .stubFor (get (urlEqualTo ("/repos/cloudbeers/yolo/contents/subdir?ref=refs%2Fpull%2F1%2Fmerge " )).willReturn (aResponse ().withStatus (404 )). atPriority ( 0 ));
78+
79+ createProbeForPR ( 1 );
80+
81+ assertTrue ( probe . stat ( "README.md" ). exists ());
82+ assertFalse ( probe . stat ( "subdir" ). exists ( ));
8383 assertFalse (probe .stat ("subdir/Jenkinsfile" ).exists ());
8484 }
8585
8686 @ Issue ("JENKINS-54126" )
8787 @ Test
88- public void statWhenRootIs404AndCacheOnThenOff () throws Exception {
88+ public void statWhenRoot404andThenIncorrectCached () throws Exception {
8989 GitHubSCMSource .setCacheSize (10 );
90- githubApi .stubFor (get (urlPathEqualTo ("/repos/cloudbeers/yolo/contents/" )).withHeader ("Cache-Control" , containing ("max-age" )).willReturn (aResponse ().withStatus (404 )));
91- githubApi .stubFor (get (urlPathEqualTo ("/repos/cloudbeers/yolo/contents/" )).withHeader ("Cache-Control" , absent ())
92- .willReturn (aResponse ()
93- .withStatus (200 )
94- .withHeader ("Content-Type" , "application/json" )
95- .withBodyFile ("body-yolo-contents-8rd37.json" )));
9690
91+ createProbeForPR (9 );
92+
93+ // JENKINS-54126 happens when:
94+ // 1. client asks for a resource "Z" that doesn't exist
95+ // ---> client receives a 404 response from github and caches it.
96+ // ---> Important: GitHub does not send ETag header for 404 responses.
97+ // 2. Resource "Z" gets created on GitHub but some means.
98+ // 3. client (eventually) asks for the resource "Z" again.
99+ // ---> Since the the client has a cached response without ETag, it sends "If-Modified-Since" header
100+ // ---> Resource has changed (it was created).
101+ //
102+ // ---> EXPECTED: GitHub should respond with 200 and data.
103+ // ---> ACTUAL: GitHub server lies, responds with incorrect 304 response, telling client that the cached data is still valid.
104+ // ---> THE BAD: Client cache believes GitHub - uses the previously cached 404 (and even adds the ETag).
105+ // ---> THE UGLY: Client is now stuck with a bad cached 404, and can't get rid of it until the resource is _updated_ again or the cache is cleared manually.
106+ //
107+ // This is the cause of JENKINS-54126. This is a pervasive GitHub server problem.
108+ // We see it mostly in this one scenario, but it will happen anywhere the server returns a 404.
109+ // It cannot be reliably detected or mitigated at the level of this plugin.
110+ //
111+ // WORKAROUND (implemented in the github-api library):
112+ // 4. the github-api library recognizes any 404 with ETag as invalid. Does not return it to the client.
113+ // ---> The github-api library automatically retries the request with "no-cache" to force refresh with valid data.
114+
115+ // 1.
116+ assertFalse (probe .stat ("README.md" ).exists ());
117+
118+ // 3.
119+ // Without 4. this would return false and would stay false.
120+ assertTrue (probe .stat ("README.md" ).exists ());
121+
122+ // 5. Verify caching is working
97123 assertTrue (probe .stat ("README.md" ).exists ());
98- githubApi .verify (RequestPatternBuilder .newRequestPattern (RequestMethod .GET , urlPathEqualTo ("/repos/cloudbeers/yolo/contents/" )).withHeader ("Cache-Control" , containing ("max-age" )));
99- githubApi .verify (RequestPatternBuilder .newRequestPattern (RequestMethod .GET , urlPathEqualTo ("/repos/cloudbeers/yolo/contents/" )).withHeader ("Cache-Control" , absent ()));
124+
125+ // Verify the expected requests were made
126+ if (hudson .Functions .isWindows ()) {
127+ // On windows caching is disabled by default, so the work around doesn't happen
128+ githubApi .verify (3 , RequestPatternBuilder .newRequestPattern (RequestMethod .GET , urlPathEqualTo ("/repos/cloudbeers/yolo/contents/" ))
129+ .withHeader ("Cache-Control" , equalTo ("max-age=0" ))
130+ .withHeader ("If-Modified-Since" , absent ())
131+ .withHeader ("If-None-Match" , absent ())
132+ );
133+ } else {
134+ // 1.
135+ githubApi .verify (RequestPatternBuilder .newRequestPattern (RequestMethod .GET , urlPathEqualTo ("/repos/cloudbeers/yolo/contents/" ))
136+ .withHeader ("Cache-Control" , equalTo ("max-age=0" ))
137+ .withHeader ("If-None-Match" , absent ())
138+ .withHeader ("If-Modified-Since" , absent ())
139+ );
140+
141+ // 3.
142+ githubApi .verify (RequestPatternBuilder .newRequestPattern (RequestMethod .GET , urlPathEqualTo ("/repos/cloudbeers/yolo/contents/" ))
143+ .withHeader ("Cache-Control" , containing ("max-age" ))
144+ .withHeader ("If-None-Match" , absent ())
145+ .withHeader ("If-Modified-Since" , containing ("GMT" ))
146+ );
147+
148+ // 4.
149+ githubApi .verify (RequestPatternBuilder .newRequestPattern (RequestMethod .GET , urlPathEqualTo ("/repos/cloudbeers/yolo/contents/" ))
150+ .withHeader ("Cache-Control" , equalTo ("no-cache" ))
151+ .withHeader ("If-Modified-Since" , absent ())
152+ .withHeader ("If-None-Match" , absent ())
153+ );
154+
155+ // 5.
156+ githubApi .verify (RequestPatternBuilder .newRequestPattern (RequestMethod .GET , urlPathEqualTo ("/repos/cloudbeers/yolo/contents/" ))
157+ .withHeader ("Cache-Control" , equalTo ("max-age=0" ))
158+ .withHeader ("If-None-Match" , equalTo ("\" d3be5b35b8d84ef7ac03c0cc9c94ed81\" " ))
159+ );
160+ }
100161 }
101162
102163}
0 commit comments