Skip to content

Commit 76ad53e

Browse files

File tree

13 files changed

+408
-358
lines changed

13 files changed

+408
-358
lines changed
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
/*
2+
* The MIT License
3+
*
4+
* Copyright 2023 CloudBees, Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*/
24+
25+
package org.jenkinsci.plugins.workflow.libs;
26+
27+
import edu.umd.cs.findbugs.annotations.CheckForNull;
28+
import edu.umd.cs.findbugs.annotations.NonNull;
29+
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
30+
import hudson.AbortException;
31+
import hudson.Extension;
32+
import hudson.FilePath;
33+
import hudson.Functions;
34+
import hudson.Util;
35+
import hudson.model.Computer;
36+
import hudson.model.Item;
37+
import hudson.model.Job;
38+
import hudson.model.Node;
39+
import hudson.model.Run;
40+
import hudson.model.TaskListener;
41+
import hudson.model.TopLevelItem;
42+
import hudson.model.listeners.ItemListener;
43+
import hudson.scm.SCM;
44+
import hudson.slaves.WorkspaceList;
45+
import hudson.util.FormValidation;
46+
import java.io.File;
47+
import java.io.IOException;
48+
import java.io.InterruptedIOException;
49+
import java.util.Collections;
50+
import java.util.HashMap;
51+
import java.util.List;
52+
import java.util.Map;
53+
import java.util.Set;
54+
import java.util.TreeSet;
55+
import java.util.concurrent.Callable;
56+
import java.util.logging.Level;
57+
import java.util.logging.Logger;
58+
import java.util.regex.Pattern;
59+
import java.util.stream.Collectors;
60+
import jenkins.model.Jenkins;
61+
import org.jenkinsci.plugins.workflow.steps.scm.GenericSCMStep;
62+
import org.jenkinsci.plugins.workflow.steps.scm.SCMStep;
63+
import org.kohsuke.accmod.Restricted;
64+
import org.kohsuke.accmod.restrictions.DoNotUse;
65+
import org.kohsuke.stapler.DataBoundSetter;
66+
import org.kohsuke.stapler.QueryParameter;
67+
import org.kohsuke.stapler.verb.POST;
68+
69+
/**
70+
* Functionality common to {@link SCMSourceRetriever} and {@link SCMRetriever}.
71+
*/
72+
public abstract class SCMBasedRetriever extends LibraryRetriever {
73+
74+
private static final Logger LOGGER = Logger.getLogger(SCMBasedRetriever.class.getName());
75+
76+
@SuppressFBWarnings(value = "MS_SHOULD_BE_FINAL", justification = "Non-final for write access via the Script Console")
77+
public static boolean INCLUDE_SRC_TEST_IN_LIBRARIES = Boolean.getBoolean(SCMSourceRetriever.class.getName() + ".INCLUDE_SRC_TEST_IN_LIBRARIES");
78+
79+
/**
80+
* Matches ".." in positions where it would be treated as the parent directory.
81+
*
82+
* <p>Used to prevent {@link #libraryPath} from being used for directory traversal.
83+
*/
84+
static final Pattern PROHIBITED_DOUBLE_DOT = Pattern.compile("(^|.*[\\\\/])\\.\\.($|[\\\\/].*)");
85+
86+
private boolean clone;
87+
88+
/**
89+
* The path to the library inside of the SCM.
90+
*
91+
* {@code null} is the default and means that the library is in the root of the repository. Otherwise, the value is
92+
* considered to be a relative path inside of the repository and always ends in a forward slash
93+
*
94+
* @see #setLibraryPath
95+
*/
96+
private @CheckForNull String libraryPath;
97+
98+
public boolean isClone() {
99+
return clone;
100+
}
101+
102+
@DataBoundSetter public void setClone(boolean clone) {
103+
this.clone = clone;
104+
}
105+
106+
public String getLibraryPath() {
107+
return libraryPath;
108+
}
109+
110+
@DataBoundSetter public void setLibraryPath(String libraryPath) {
111+
libraryPath = Util.fixEmptyAndTrim(libraryPath);
112+
if (libraryPath != null && !libraryPath.endsWith("/")) {
113+
libraryPath += '/';
114+
}
115+
this.libraryPath = libraryPath;
116+
}
117+
118+
protected final void doRetrieve(String name, boolean changelog, @NonNull SCM scm, FilePath target, Run<?, ?> run, TaskListener listener) throws Exception {
119+
if (libraryPath != null && PROHIBITED_DOUBLE_DOT.matcher(libraryPath).matches()) {
120+
throw new AbortException("Library path may not contain '..'");
121+
}
122+
if (clone && changelog) {
123+
listener.getLogger().println("WARNING: ignoring request to compute changelog in clone mode");
124+
changelog = false;
125+
}
126+
// Adapted from CpsScmFlowDefinition:
127+
SCMStep delegate = new GenericSCMStep(scm);
128+
delegate.setPoll(false); // TODO we have no API for determining if a given SCMHead is branch-like or tag-like; would we want to turn on polling if the former?
129+
delegate.setChangelog(changelog);
130+
Node node = Jenkins.get();
131+
if (clone) {
132+
if (libraryPath == null) {
133+
retrySCMOperation(listener, () -> {
134+
delegate.checkout(run, target, listener, Jenkins.get().createLauncher(listener));
135+
WorkspaceList.tempDir(target).deleteRecursive();
136+
return null;
137+
});
138+
} else {
139+
FilePath root = target.child("root");
140+
retrySCMOperation(listener, () -> {
141+
delegate.checkout(run, root, listener, Jenkins.get().createLauncher(listener));
142+
WorkspaceList.tempDir(root).deleteRecursive();
143+
return null;
144+
});
145+
FilePath subdir = root.child(libraryPath);
146+
if (!subdir.isDirectory()) {
147+
throw new AbortException("Did not find " + libraryPath + " in checkout");
148+
}
149+
for (String content : List.of("src", "vars", "resources")) {
150+
FilePath contentDir = subdir.child(content);
151+
if (contentDir.isDirectory()) {
152+
listener.getLogger().println("Moving " + content + " to top level");
153+
contentDir.renameTo(target.child(content));
154+
}
155+
}
156+
// root itself will be deleted below
157+
}
158+
Set<String> deleted = new TreeSet<>();
159+
if (!INCLUDE_SRC_TEST_IN_LIBRARIES) {
160+
FilePath srcTest = target.child("src/test");
161+
if (srcTest.isDirectory()) {
162+
listener.getLogger().println("Excluding src/test/ from checkout of " + scm.getKey() + " so that library test code cannot be accessed by Pipelines.");
163+
listener.getLogger().println("To remove this log message, move the test code outside of src/. To restore the previous behavior that allowed access to files in src/test/, pass -D" + SCMSourceRetriever.class.getName() + ".INCLUDE_SRC_TEST_IN_LIBRARIES=true to the java command used to start Jenkins.");
164+
srcTest.deleteRecursive();
165+
deleted.add("src/test");
166+
}
167+
}
168+
for (FilePath child : target.list()) {
169+
String subdir = child.getName();
170+
switch (subdir) {
171+
case "src":
172+
// TODO delete everything that is not *.groovy
173+
break;
174+
case "vars":
175+
// TODO delete everything that is not *.groovy or *.txt, incl. subdirs
176+
break;
177+
case "resources":
178+
// OK, leave it all
179+
break;
180+
default:
181+
deleted.add(subdir);
182+
child.deleteRecursive();
183+
}
184+
}
185+
if (!deleted.isEmpty()) {
186+
listener.getLogger().println("Deleted " + deleted.stream().collect(Collectors.joining(", ")));
187+
}
188+
} else { // !clone
189+
FilePath dir;
190+
if (run.getParent() instanceof TopLevelItem) {
191+
FilePath baseWorkspace = node.getWorkspaceFor((TopLevelItem) run.getParent());
192+
if (baseWorkspace == null) {
193+
throw new IOException(node.getDisplayName() + " may be offline");
194+
}
195+
String checkoutDirName = LibraryRecord.directoryNameFor(scm.getKey());
196+
dir = baseWorkspace.withSuffix(getFilePathSuffix() + "libs").child(checkoutDirName);
197+
} else { // should not happen, but just in case:
198+
throw new AbortException("Cannot check out in non-top-level build");
199+
}
200+
Computer computer = node.toComputer();
201+
if (computer == null) {
202+
throw new IOException(node.getDisplayName() + " may be offline");
203+
}
204+
try (WorkspaceList.Lease lease = computer.getWorkspaceList().allocate(dir)) {
205+
// Write the SCM key to a file as a debugging aid.
206+
lease.path.withSuffix("-scm-key.txt").write(scm.getKey(), "UTF-8");
207+
retrySCMOperation(listener, () -> {
208+
delegate.checkout(run, lease.path, listener, node.createLauncher(listener));
209+
return null;
210+
});
211+
if (libraryPath == null) {
212+
libraryPath = ".";
213+
}
214+
String excludes = INCLUDE_SRC_TEST_IN_LIBRARIES ? null : "src/test/";
215+
if (lease.path.child(libraryPath).child("src/test").exists()) {
216+
listener.getLogger().println("Excluding src/test/ from checkout of " + scm.getKey() + " so that library test code cannot be accessed by Pipelines.");
217+
listener.getLogger().println("To remove this log message, move the test code outside of src/. To restore the previous behavior that allowed access to files in src/test/, pass -D" + SCMSourceRetriever.class.getName() + ".INCLUDE_SRC_TEST_IN_LIBRARIES=true to the java command used to start Jenkins.");
218+
}
219+
// Cannot add WorkspaceActionImpl to private CpsFlowExecution.flowStartNodeActions; do we care?
220+
// Copy sources with relevant files from the checkout:
221+
lease.path.child(libraryPath).copyRecursiveTo("src/**/*.groovy,vars/*.groovy,vars/*.txt,resources/", excludes, target);
222+
}
223+
}
224+
}
225+
226+
protected static <T> T retrySCMOperation(TaskListener listener, Callable<T> task) throws Exception{
227+
T ret = null;
228+
for (int retryCount = Jenkins.get().getScmCheckoutRetryCount(); retryCount >= 0; retryCount--) {
229+
try {
230+
ret = task.call();
231+
break;
232+
}
233+
catch (AbortException e) {
234+
// abort exception might have a null message.
235+
// If so, just skip echoing it.
236+
if (e.getMessage() != null) {
237+
listener.error(e.getMessage());
238+
}
239+
}
240+
catch (InterruptedIOException e) {
241+
throw e;
242+
}
243+
catch (Exception e) {
244+
// checkout error not yet reported
245+
Functions.printStackTrace(e, listener.error("Checkout failed"));
246+
}
247+
248+
if (retryCount == 0) // all attempts failed
249+
throw new AbortException("Maximum checkout retry attempts reached, aborting");
250+
251+
listener.getLogger().println("Retrying after 10 seconds");
252+
Thread.sleep(10000);
253+
}
254+
return ret;
255+
}
256+
257+
// TODO there is WorkspaceList.tempDir but no API to make other variants
258+
private static String getFilePathSuffix() {
259+
return System.getProperty(WorkspaceList.class.getName(), "@");
260+
}
261+
262+
protected abstract static class SCMBasedRetrieverDescriptor extends LibraryRetrieverDescriptor {
263+
264+
@POST
265+
public FormValidation doCheckLibraryPath(@QueryParameter String libraryPath) {
266+
libraryPath = Util.fixEmptyAndTrim(libraryPath);
267+
if (libraryPath == null) {
268+
return FormValidation.ok();
269+
} else if (PROHIBITED_DOUBLE_DOT.matcher(libraryPath).matches()) {
270+
return FormValidation.error(Messages.SCMSourceRetriever_library_path_no_double_dot());
271+
}
272+
return FormValidation.ok();
273+
}
274+
275+
}
276+
277+
@Restricted(DoNotUse.class)
278+
@Extension
279+
public static class WorkspaceListener extends ItemListener {
280+
281+
@Override
282+
public void onDeleted(Item item) {
283+
deleteLibsDir(item, item.getFullName());
284+
}
285+
286+
@Override
287+
public void onLocationChanged(Item item, String oldFullName, String newFullName) {
288+
deleteLibsDir(item, oldFullName);
289+
}
290+
291+
private static void deleteLibsDir(Item item, String itemFullName) {
292+
if (item instanceof Job
293+
&& item.getClass()
294+
.getName()
295+
.equals("org.jenkinsci.plugins.workflow.job.WorkflowJob")) {
296+
synchronized (item) {
297+
String base =
298+
expandVariablesForDirectory(
299+
Jenkins.get().getRawWorkspaceDir(),
300+
itemFullName,
301+
item.getRootDir().getPath());
302+
FilePath dir =
303+
new FilePath(new File(base)).withSuffix(getFilePathSuffix() + "libs");
304+
try {
305+
if (dir.isDirectory()) {
306+
LOGGER.log(
307+
Level.INFO,
308+
() -> "Deleting obsolete library workspace " + dir);
309+
dir.deleteRecursive();
310+
}
311+
} catch (IOException | InterruptedException e) {
312+
LOGGER.log(
313+
Level.WARNING,
314+
e,
315+
() -> "Could not delete obsolete library workspace " + dir);
316+
}
317+
}
318+
}
319+
}
320+
321+
private static String expandVariablesForDirectory(
322+
String base, String itemFullName, String itemRootDir) {
323+
// If the item is moved, it is too late to look up its original workspace location by
324+
// the time we get the notification. See:
325+
// https://github.com/jenkinsci/jenkins/blob/f03183ab09ce5fb8f9f4cc9ccee42a3c3e6b2d3e/core/src/main/java/jenkins/model/Jenkins.java#L2567-L2576
326+
Map<String, String> properties = new HashMap<>();
327+
properties.put("JENKINS_HOME", Jenkins.get().getRootDir().getPath());
328+
properties.put("ITEM_ROOTDIR", itemRootDir);
329+
properties.put("ITEM_FULLNAME", itemFullName); // legacy, deprecated
330+
properties.put(
331+
"ITEM_FULL_NAME", itemFullName.replace(':', '$')); // safe, see JENKINS-12251
332+
return Util.replaceMacro(base, Collections.unmodifiableMap(properties));
333+
}
334+
}
335+
336+
}

0 commit comments

Comments
 (0)