/* * The MIT License * * Copyright (c) 2004-2009, Sun Microsystems, Inc., Kohsuke Kawaguchi, Jene Jasper, Stephen Connolly * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ package hudson.scm; import hudson.EnvVars; import hudson.FilePath; import hudson.FilePath.FileCallable; import hudson.Launcher; import hudson.Proc; import hudson.Util; import hudson.Extension; import static hudson.Util.fixEmpty; import static hudson.Util.fixNull; import static hudson.Util.fixEmptyAndTrim; import hudson.model.AbstractBuild; import hudson.model.AbstractProject; import hudson.model.BuildListener; import hudson.model.Hudson; import hudson.model.ModelObject; import hudson.model.Run; import hudson.model.TaskListener; import hudson.model.TaskThread; import hudson.model.Describable; import hudson.model.Descriptor; import hudson.org.apache.tools.ant.taskdefs.cvslib.ChangeLogTask; import hudson.remoting.Future; import hudson.remoting.RemoteOutputStream; import hudson.remoting.VirtualChannel; import hudson.security.Permission; import hudson.util.ArgumentListBuilder; import hudson.util.ForkOutputStream; import hudson.util.IOException2; import hudson.util.FormValidation; import hudson.util.AtomicFileWriter; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import org.apache.tools.ant.BuildException; import org.apache.tools.ant.taskdefs.Expand; import org.apache.tools.zip.ZipEntry; import org.apache.tools.zip.ZipOutputStream; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.QueryParameter; import org.kohsuke.stapler.export.Exported; import org.kohsuke.stapler.export.ExportedBean; import org.kohsuke.stapler.framework.io.ByteBuffer; import javax.servlet.ServletException; import javax.servlet.http.HttpServletResponse; import java.io.BufferedOutputStream; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.FileReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.PrintWriter; import java.io.Serializable; import java.io.StringWriter; import java.text.DateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.TimeZone; import java.util.TreeSet; import java.util.logging.Logger; import static java.util.logging.Level.INFO; import java.util.concurrent.ExecutionException; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import net.sf.json.JSONObject; import hudson.scm.cvs.Messages; /** * CVS. * * <p> * I couldn't call this class "CVS" because that would cause the view folder name * to collide with CVS control files. * * <p> * This object gets shipped to the remote machine to perform some of the work, * so it implements {@link Serializable}. * * @author Kohsuke Kawaguchi */ public class CVSSCM extends SCM implements Serializable { /** * CVSSCM connection string, like ":pserver:me@host:/cvs" */ private String cvsroot; /** * Module names. * * This could be a whitespace/NL-separated list of multiple modules. * Modules could be either directories or files. "\ " is used to escape * " ", which is needed for modules with whitespace in it. */ private String module; private String branch; private String cvsRsh; private boolean canUseUpdate; /** * True to avoid creating a sub-directory inside the workspace. * (Works only when there's just one module.) */ private boolean flatten; private CVSRepositoryBrowser repositoryBrowser; private boolean isTag; private String excludedRegions; @DataBoundConstructor public CVSSCM(String cvsRoot, String allModules,String branch,String cvsRsh,boolean canUseUpdate, boolean legacy, boolean isTag, String excludedRegions) { if(fixNull(branch).equals("HEAD")) branch = null; this.cvsroot = fixNull(cvsRoot).trim(); this.module = allModules.trim(); this.branch = nullify(branch); this.cvsRsh = nullify(cvsRsh); this.canUseUpdate = canUseUpdate; this.flatten = !legacy && getAllModulesNormalized().length==1; this.isTag = isTag; this.excludedRegions = excludedRegions; } @Override public CVSRepositoryBrowser getBrowser() { return repositoryBrowser; } private String compression() { if(getDescriptor().isNoCompression()) return null; // CVS 1.11.22 manual: // If the access method is omitted, then if the repository starts with // `/', then `:local:' is assumed. If it does not start with `/' then // either `:ext:' or `:server:' is assumed. boolean local = cvsroot.startsWith("/") || cvsroot.startsWith(":local:") || cvsroot.startsWith(":fork:"); // For local access, compression is senseless. For remote, use z3: // http://root.cern.ch/root/CVS.html#checkout return local ? "-z0" : "-z3"; } @Exported public String getCvsRoot() { return cvsroot; } /** * Returns true if {@link #getBranch()} represents a tag. * <p> * This causes Hudson to stop using "-D" option while check out and update. */ @Exported public boolean isTag() { return isTag; } /** * If there are multiple modules, return the module directory of the first one. * @param workspace */ public FilePath getModuleRoot(FilePath workspace) { if(flatten) return workspace; return workspace.child(getAllModulesNormalized()[0]); } @Override public FilePath[] getModuleRoots(FilePath workspace) { if (!flatten) { final String[] moduleLocations = getAllModulesNormalized(); FilePath[] moduleRoots = new FilePath[moduleLocations.length]; for (int i = 0; i < moduleLocations.length; i++) { moduleRoots[i] = workspace.child(moduleLocations[i]); } return moduleRoots; } return new FilePath[]{getModuleRoot(workspace)}; } public ChangeLogParser createChangeLogParser() { return new CVSChangeLogParser(); } @Exported public String getAllModules() { return module; } @Exported public String getExcludedRegions() { return excludedRegions; } public String[] getExcludedRegionsNormalized() { return excludedRegions == null ? null : excludedRegions.split("[\\r\\n]+"); } private Pattern[] getExcludedRegionsPatterns() { String[] excludedRegions = getExcludedRegionsNormalized(); if (excludedRegions != null) { Pattern[] patterns = new Pattern[excludedRegions.length]; int i = 0; for (String excludedRegion : excludedRegions) { patterns[i++] = Pattern.compile(excludedRegion); } return patterns; } return null; } /** * List up all modules to check out. */ public String[] getAllModulesNormalized() { // split by whitespace, except "\ " String[] r = module.split("(?<!\\\\)[ \\r\\n]+"); // now replace "\ " to " ". for (int i = 0; i < r.length; i++) r[i] = r[i].replaceAll("\\\\ "," "); return r; } /** * Branch to build. Null to indicate the trunk. */ @Exported public String getBranch() { return branch; } @Exported public String getCvsRsh() { return cvsRsh; } @Exported public boolean getCanUseUpdate() { return canUseUpdate; } @Exported public boolean isFlatten() { return flatten; } public boolean isLegacy() { return !flatten; } public boolean pollChanges(AbstractProject project, Launcher launcher, FilePath dir, TaskListener listener) throws IOException, InterruptedException { String why = isUpdatable(dir); if(why!=null) { listener.getLogger().println(Messages.CVSSCM_WorkspaceInconsistent(why)); return true; } List<String> changedFiles = update(true, launcher, dir, listener, new Date()); if (changedFiles != null && !changedFiles.isEmpty()) { Pattern[] patterns = getExcludedRegionsPatterns(); if (patterns != null) { boolean areThereChanges = false; for (String changedFile : changedFiles) { boolean patternMatched = false; for (Pattern pattern : patterns) { if (pattern.matcher(changedFile).matches()) { patternMatched = true; break; } } if (!patternMatched) { areThereChanges = true; break; } } return areThereChanges; } // no excluded patterns so just return true as // changedFiles != null && !changedFiles.isEmpty() is true return true; } return false; } private void configureDate(ArgumentListBuilder cmd, Date date) { // #192 if(isTag) return; // don't use the -D option. DateFormat df = DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.FULL, Locale.US); df.setTimeZone(TimeZone.getTimeZone("UTC")); // #209 cmd.add("-D", df.format(date)); } public boolean checkout(AbstractBuild build, Launcher launcher, FilePath ws, BuildListener listener, File changelogFile) throws IOException, InterruptedException { List<String> changedFiles = null; // files that were affected by update. null this is a check out if(canUseUpdate && isUpdatable(ws)==null) { changedFiles = update(false, launcher, ws, listener, build.getTimestamp().getTime()); if(changedFiles==null) return false; // failed } else { if(!checkout(launcher,ws,listener,build.getTimestamp().getTime())) return false; } // archive the workspace to support later tagging File archiveFile = getArchiveFile(build); final OutputStream os = new RemoteOutputStream(new FileOutputStream(archiveFile)); ws.act(new FileCallable<Void>() { public Void invoke(File ws, VirtualChannel channel) throws IOException { ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(os)); String[] modules = getAllModulesNormalized(); if(flatten) { assert modules.length==1; // becaue flatter==true only when there's one module. archive(ws, modules[0], zos,true); } else { for (String m : modules) { File mf = new File(ws, m); if(!mf.exists()) // directory doesn't exist. This happens if a directory that was checked out // didn't include any file. continue; if(!mf.isDirectory()) { // this module is just a file, say "foo/bar.txt". // to record "foo/CVS/*", we need to start by archiving "foo". int idx = m.lastIndexOf('/'); if(idx==-1) throw new Error("Kohsuke probe: m="+m); m = m.substring(0, idx); mf = mf.getParentFile(); } archive(mf,m,zos,true); } } zos.close(); return null; } }); // contribute the tag action build.getActions().add(new TagAction(build)); return calcChangeLog(build, ws, changedFiles, changelogFile, listener); } public boolean checkout(Launcher launcher, FilePath dir, TaskListener listener) throws IOException, InterruptedException { Date now = new Date(); if(canUseUpdate && isUpdatable(dir)==null) { return update(false, launcher, dir, listener, now)!=null; } else { return checkout(launcher,dir,listener, now); } } private boolean checkout(Launcher launcher, FilePath dir, TaskListener listener, Date dt) throws IOException, InterruptedException { dir.deleteContents(); ArgumentListBuilder cmd = new ArgumentListBuilder(); cmd.add(getDescriptor().getCvsExeOrDefault(), noQuiet?null:(debug ?"-t":"-Q"),compression(),"-d",cvsroot,"co","-P"); if(branch!=null) cmd.add("-r",branch); if(flatten) cmd.add("-d",dir.getName()); configureDate(cmd,dt); cmd.add(getAllModulesNormalized()); if(!run(launcher,cmd,listener, flatten ? dir.getParent() : dir)) return false; // clean up the sticky tag if(flatten) dir.act(new StickyDateCleanUpTask()); else { for (String module : getAllModulesNormalized()) { dir.child(module).act(new StickyDateCleanUpTask()); } } return true; } /** * Returns the file name used to archive the build. */ private static File getArchiveFile(AbstractBuild build) { return new File(build.getRootDir(),"workspace.zip"); } /** * Archives all the CVS-controlled files in {@code dir}. * * @param relPath * The path name in ZIP to store this directory with. */ private void archive(File dir,String relPath,ZipOutputStream zos, boolean isRoot) throws IOException { Set<String> knownFiles = new HashSet<String>(); // see http://www.monkey.org/openbsd/archive/misc/9607/msg00056.html for what Entries.Log is for parseCVSEntries(new File(dir,"CVS/Entries"),knownFiles); parseCVSEntries(new File(dir,"CVS/Entries.Log"),knownFiles); parseCVSEntries(new File(dir,"CVS/Entries.Extra"),knownFiles); boolean hasCVSdirs = !knownFiles.isEmpty(); knownFiles.add("CVS"); File[] files = dir.listFiles(); if(files==null) { if(isRoot) throw new IOException("No such directory exists. Did you specify the correct branch? Perhaps you specified a tag: "+dir); else throw new IOException("No such directory exists. Looks like someone is modifying the workspace concurrently: "+dir); } for( File f : files ) { String name = relPath+'/'+f.getName(); if(f.isDirectory()) { if(hasCVSdirs && !knownFiles.contains(f.getName())) { // not controlled in CVS. Skip. // but also make sure that we archive CVS/*, which doesn't have CVS/CVS continue; } archive(f,name,zos,false); } else { if(!dir.getName().equals("CVS")) // we only need to archive CVS control files, not the actual workspace files continue; zos.putNextEntry(new ZipEntry(name)); FileInputStream fis = new FileInputStream(f); Util.copyStream(fis,zos); fis.close(); zos.closeEntry(); } } } /** * Parses the CVS/Entries file and adds file/directory names to the list. */ private void parseCVSEntries(File entries, Set<String> knownFiles) throws IOException { if(!entries.exists()) return; BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream(entries))); try { String line; while((line=in.readLine())!=null) { String[] tokens = line.split("/+"); if(tokens==null || tokens.length<2) continue; // invalid format knownFiles.add(tokens[1]); } } finally { IOUtils.closeQuietly(in); } } /** * Updates the workspace as well as locate changes. * * @return * List of affected file names, relative to the workspace directory. * Null if the operation failed. */ private List<String> update(boolean dryRun, Launcher launcher, FilePath workspace, TaskListener listener, Date date) throws IOException, InterruptedException { List<String> changedFileNames = new ArrayList<String>(); // file names relative to the workspace ArgumentListBuilder cmd = new ArgumentListBuilder(); cmd.add(getDescriptor().getCvsExeOrDefault(),debug?"-t":"-q",compression()); if(dryRun) cmd.add("-n"); cmd.add("update","-PdC"); if (branch != null) { cmd.add("-r", branch); } configureDate(cmd, date); if(flatten) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); if(!run(launcher,cmd,listener,workspace, new ForkOutputStream(baos,listener.getLogger()))) return null; // asynchronously start cleaning up the sticky tag while we work on parsing the result Future<Void> task = workspace.actAsync(new StickyDateCleanUpTask()); parseUpdateOutput("",baos, changedFileNames); join(task); } else { @SuppressWarnings("unchecked") // StringTokenizer oddly has the wrong type final Set<String> moduleNames = new TreeSet(Arrays.asList(getAllModulesNormalized())); // Add in any existing CVS dirs, in case project checked out its own. moduleNames.addAll(workspace.act(new FileCallable<Set<String>>() { public Set<String> invoke(File ws, VirtualChannel channel) throws IOException { File[] subdirs = ws.listFiles(); if (subdirs != null) { SUBDIR: for (File s : subdirs) { if (new File(s, "CVS").isDirectory()) { String top = s.getName(); for (String mod : moduleNames) { if (mod.startsWith(top + "/")) { // #190: user asked to check out foo/bar foo/baz quux // Our top-level dirs are "foo" and "quux". // Do not add "foo" to checkout or we will check out foo/*! continue SUBDIR; } } moduleNames.add(top); } } } return moduleNames; } })); for (String moduleName : moduleNames) { // capture the output during update ByteArrayOutputStream baos = new ByteArrayOutputStream(); FilePath modulePath = new FilePath(workspace, moduleName); ArgumentListBuilder actualCmd = cmd; String baseName = moduleName; if(!modulePath.isDirectory()) { // updating just one file, like "foo/bar.txt". // run update command from "foo" directory with "bar.txt" as the command line argument actualCmd = cmd.clone(); actualCmd.add(modulePath.getName()); modulePath = modulePath.getParent(); int slash = baseName.lastIndexOf('/'); if (slash > 0) { baseName = baseName.substring(0, slash); } } if(!run(launcher,actualCmd,listener, modulePath, new ForkOutputStream(baos,listener.getLogger()))) return null; // asynchronously start cleaning up the sticky tag while we work on parsing the result Future<Void> task = modulePath.actAsync(new StickyDateCleanUpTask()); // we'll run one "cvs log" command with workspace as the base, // so use path names that are relative to moduleName. parseUpdateOutput(baseName+'/',baos, changedFileNames); join(task); } } return changedFileNames; } private void join(Future<Void> task) throws InterruptedException, IOException { try { task.get(); } catch (ExecutionException e) { throw new IOException2(e); } } // see http://www.network-theory.co.uk/docs/cvsmanual/cvs_153.html for the output format. // we don't care '?' because that's not in the repository private static final Pattern UPDATE_LINE = Pattern.compile("[UPARMC] (.+)"); private static final Pattern REMOVAL_LINE = Pattern.compile("cvs (server|update): `?(.+?)'? is no longer in the repository"); //private static final Pattern NEWDIRECTORY_LINE = Pattern.compile("cvs server: New directory `(.+)' -- ignored"); /** * Parses the output from CVS update and list up files that might have been changed. * * @param result * list of file names whose changelog should be checked. This may include files * that are no longer present. The path names are relative to the workspace, * hence "String", not {@link File}. */ private void parseUpdateOutput(String baseName, ByteArrayOutputStream output, List<String> result) throws IOException { BufferedReader in = new BufferedReader(new InputStreamReader( new ByteArrayInputStream(output.toByteArray()))); String line; while((line=in.readLine())!=null) { Matcher matcher = UPDATE_LINE.matcher(line); if(matcher.matches()) { result.add(baseName+matcher.group(1)); continue; } matcher= REMOVAL_LINE.matcher(line); if(matcher.matches()) { result.add(baseName+matcher.group(2)); continue; } // this line is added in an attempt to capture newly created directories in the repository, // but it turns out that this line always hit if the workspace is missing a directory // that the server has, even if that directory contains nothing in it //matcher= NEWDIRECTORY_LINE.matcher(line); //if(matcher.matches()) { // result.add(baseName+matcher.group(1)); //} } } /** * Returns null if we can use "cvs update" instead of "cvs checkout" * * @return * If update is impossible, return the text explaining why. */ private String isUpdatable(FilePath dir) throws IOException, InterruptedException { return dir.act(new FileCallable<String>() { public String invoke(File dir, VirtualChannel channel) throws IOException { if(flatten) { return isUpdatableModule(dir); } else { for (String m : getAllModulesNormalized()) { File module = new File(dir,m); String reason = isUpdatableModule(module); if(reason!=null) return reason; } return null; } } private String isUpdatableModule(File module) { try { if(!module.isDirectory()) // module is a file, like "foo/bar.txt". Then CVS information is "foo/CVS". module = module.getParentFile(); File cvs = new File(module,"CVS"); if(!cvs.exists()) return "No CVS dir in "+module; // check cvsroot File cvsRootFile = new File(cvs, "Root"); if(!checkContents(cvsRootFile,cvsroot)) return cvs+"/Root content mismatch: expected "+cvsroot+" but found "+FileUtils.readFileToString(cvsRootFile); if(branch!=null) { if(!checkContents(new File(cvs,"Tag"),(isTag()?'N':'T')+branch)) return cvs+" branch mismatch"; } else { File tag = new File(cvs,"Tag"); if (tag.exists()) { BufferedReader r = new BufferedReader(new FileReader(tag)); try { String s = r.readLine(); if(s != null && s.startsWith("D")) return null; // OK return "Workspace is on branch "+s; } finally { r.close(); } } } return null; } catch (IOException e) { return e.getMessage(); } } }); } /** * Returns true if the contents of the file is equal to the given string. * * @return false in all the other cases. */ private boolean checkContents(File file, String contents) { try { BufferedReader r = new BufferedReader(new FileReader(file)); try { String s = r.readLine(); if (s == null) return false; return massageForCheckContents(s).equals(massageForCheckContents(contents)); } finally { r.close(); } } catch (IOException e) { return false; } } /** * Normalize the string for comparison in {@link #checkContents(File, String)}. */ private String massageForCheckContents(String s) { s=s.trim(); // this is somewhat ugly because we only want to do this for CVS/Root but still ended up doing this // for all checks. OTOH, there shouldn'be really any false positive. Matcher m = PSERVER_CVSROOT_WITH_PASSWORD.matcher(s); if(m.matches()) s = m.group(1)+m.group(2); // cut off password return s; } /** * Looks for CVSROOT that includes password, like ":pserver:uid:pwd@server:/path". * * <p> * Some CVS client (likely CVSNT?) appears to add the password despite the fact that CVSROOT Hudson is setting * doesn't include one. So when we compare CVSROOT, we need to remove the password. * * <p> * Since the password equivalence shouldn't really affect the {@link #checkContents(File, String)}, we use * this pattern to ignore password from both {@link #cvsroot} and the string found in <tt>path/CVS/Root</tt> * and then compare. * * See http://www.nabble.com/Problem-with-polling-CVS%2C-from-version-1.181-tt15799926.html for the user report. */ private static final Pattern PSERVER_CVSROOT_WITH_PASSWORD = Pattern.compile("(:pserver:[^@:]+):[^@:]+(@.+)"); /** * Used to communicate the result of the detection in {@link CVSSCM#calcChangeLog(AbstractBuild, FilePath, List, File, BuildListener)} */ static class ChangeLogResult implements Serializable { boolean hadError; String errorOutput; public ChangeLogResult(boolean hadError, String errorOutput) { this.hadError = hadError; if(hadError) this.errorOutput = errorOutput; } private static final long serialVersionUID = 1L; } /** * Used to propagate {@link BuildException} and error log at the same time. */ static class BuildExceptionWithLog extends RuntimeException { final String errorOutput; public BuildExceptionWithLog(BuildException cause, String errorOutput) { super(cause); this.errorOutput = errorOutput; } private static final long serialVersionUID = 1L; } /** * Computes the changelog into an XML file. * * <p> * When we update the workspace, we'll compute the changelog by using its output to * make it faster. In general case, we'll fall back to the slower approach where * we check all files in the workspace. * * @param changedFiles * Files whose changelog should be checked for updates. * This is provided if the previous operation is update, otherwise null, * which means we have to fall back to the default slow computation. */ private boolean calcChangeLog(AbstractBuild build, FilePath ws, final List<String> changedFiles, File changelogFile, final BuildListener listener) throws InterruptedException { if(build.getPreviousBuild()==null || (changedFiles!=null && changedFiles.isEmpty())) { // nothing to compare against, or no changes // (note that changedFiles==null means fallback, so we have to run cvs log. listener.getLogger().println("$ no changes detected"); return createEmptyChangeLog(changelogFile,listener, "changelog"); } if(skipChangeLog) { listener.getLogger().println("Skipping changelog computation"); return createEmptyChangeLog(changelogFile,listener, "changelog"); } listener.getLogger().println("$ computing changelog"); final String cvspassFile = getDescriptor().getCvspassFile(); final String cvsExe = getDescriptor().getCvsExeOrDefault(); OutputStream o = null; try { // range of time for detecting changes final Date startTime = build.getPreviousBuild().getTimestamp().getTime(); final Date endTime = build.getTimestamp().getTime(); final OutputStream out = o = new RemoteOutputStream(new FileOutputStream(changelogFile)); ChangeLogResult result = ws.act(new FileCallable<ChangeLogResult>() { public ChangeLogResult invoke(File ws, VirtualChannel channel) throws IOException { final StringWriter errorOutput = new StringWriter(); final boolean[] hadError = new boolean[1]; ChangeLogTask task = new ChangeLogTask() { @Override public void log(String msg, int msgLevel) { if(msgLevel==org.apache.tools.ant.Project.MSG_ERR) hadError[0] = true; // send error to listener. This seems like the route in which the changelog task // sends output. // Also in ChangeLogTask.getExecuteStreamHandler, we send stderr from CVS // at WARN level. if(msgLevel<=org.apache.tools.ant.Project.MSG_WARN) { errorOutput.write(msg); errorOutput.write('\n'); return; } if(debug) { listener.getLogger().println(msg); } } }; task.setProject(new org.apache.tools.ant.Project()); task.setCvsExe(cvsExe); task.setDir(ws); if(cvspassFile.length()!=0) task.setPassfile(new File(cvspassFile)); if (canUseUpdate && cvsroot.startsWith("/")) { // cvs log of built source trees unreliable in local access method: // https://savannah.nongnu.org/bugs/index.php?15223 task.setCvsRoot(":fork:" + cvsroot); } else if (canUseUpdate && cvsroot.startsWith(":local:")) { task.setCvsRoot(":fork:" + cvsroot.substring(7)); } else { task.setCvsRoot(cvsroot); } task.setCvsRsh(cvsRsh); task.setFailOnError(true); BufferedOutputStream bufferedOutput = new BufferedOutputStream(out); task.setDeststream(bufferedOutput); // It's to enforce ChangeLogParser find a "branch". If tag was specified, branch does not matter (see documentation for 'cvs log -r:tag'). if (!isTag()){ task.setBranch(branch); } // It's to enforce ChangeLogTask use "baranch" in CVS command (cvs log -r...). task.setTag(isTag() ? ":" + branch : branch); task.setStart(startTime); task.setEnd(endTime); if(changedFiles!=null) { // we can optimize the processing if we know what files have changed. // but also try not to make the command line too long so as no to hit // the system call limit to the command line length (see issue #389) // the choice of the number is arbitrary, but normally we don't really // expect continuous builds to have too many changes, so this should be OK. if(changedFiles.size()<100 || !Hudson.isWindows()) { // if the directory doesn't exist, cvs changelog will die, so filter them out. // this means we'll lose the log of those changes for (String filePath : changedFiles) { if(new File(ws,filePath).getParentFile().exists()) task.addFile(filePath); } } } else { // fallback if(!flatten) task.setPackage(getAllModulesNormalized()); } try { task.execute(); } catch (BuildException e) { throw new BuildExceptionWithLog(e,errorOutput.toString()); } finally { bufferedOutput.close(); } return new ChangeLogResult(hadError[0],errorOutput.toString()); } }); if(result.hadError) { // non-fatal error must have occurred, such as cvs changelog parsing error.s listener.getLogger().print(result.errorOutput); } return true; } catch( BuildExceptionWithLog e ) { // capture output from the task for diagnosis listener.getLogger().print(e.errorOutput); // then report an error BuildException x = (BuildException) e.getCause(); PrintWriter w = listener.error(x.getMessage()); w.println("Working directory is "+ ws); x.printStackTrace(w); return false; } catch( RuntimeException e ) { // an user reported a NPE inside the changeLog task. // we don't want a bug in Ant to prevent a build. e.printStackTrace(listener.error(e.getMessage())); return true; // so record the message but continue } catch( IOException e ) { e.printStackTrace(listener.error("Failed to detect changlog")); return true; } finally { IOUtils.closeQuietly(o); } } @Override public DescriptorImpl getDescriptor() { return (DescriptorImpl)super.getDescriptor(); } @Override public void buildEnvVars(AbstractBuild build, Map<String, String> env) { if(cvsRsh!=null) env.put("CVS_RSH",cvsRsh); if(branch!=null) env.put("CVS_BRANCH",branch); String cvspass = getDescriptor().getCvspassFile(); if(cvspass.length()!=0) env.put("CVS_PASSFILE",cvspass); } /** * Invokes the command with the specified command line option and wait for its completion. * * @param dir * if launching locally this is a local path, otherwise a remote path. * @param out * Receives output from the executed program. */ protected final boolean run(Launcher launcher, ArgumentListBuilder cmd, TaskListener listener, FilePath dir, OutputStream out) throws IOException, InterruptedException { Map<String,String> env = createEnvVarMap(true); int r = launcher.launch().cmds(cmd).envs(env).stdout(out).pwd(dir).join(); if(r!=0) listener.fatalError(getDescriptor().getDisplayName()+" failed. exit code="+r); return r==0; } protected final boolean run(Launcher launcher, ArgumentListBuilder cmd, TaskListener listener, FilePath dir) throws IOException, InterruptedException { return run(launcher,cmd,listener,dir,listener.getLogger()); } /** * * @param overrideOnly * true to indicate that the returned map shall only contain * properties that need to be overridden. This is for use with {@link Launcher}. * false to indicate that the map should contain complete map. * This is to invoke {@link Proc} directly. */ protected final Map<String,String> createEnvVarMap(boolean overrideOnly) { Map<String,String> env = new HashMap<String,String>(); if(!overrideOnly) env.putAll(EnvVars.masterEnvVars); buildEnvVars(null/*TODO*/,env); return env; } /** * Recursively visits directories and get rid of the sticky date in <tt>CVS/Entries</tt> folder. */ private static final class StickyDateCleanUpTask implements FileCallable<Void> { public Void invoke(File f, VirtualChannel channel) throws IOException { process(f); return null; } private void process(File f) throws IOException { File entries = new File(f,"CVS/Entries"); if(!entries.exists()) return; // not a CVS-controlled directory. No point in recursing boolean modified = false; String contents; try { contents = FileUtils.readFileToString(entries); } catch (IOException e) { // reports like http://www.nabble.com/Exception-while-checking-out-from-CVS-td24256117.html // indicates that CVS/Entries may contain something more than we know of. leave them as is LOGGER.log(INFO, "Failed to parse "+entries,e); return; } StringBuilder newContents = new StringBuilder(contents.length()); String[] lines = contents.split("\n"); for (String line : lines) { int idx = line.lastIndexOf('/'); if(idx==-1) continue; // something is seriously wrong with this line. just skip. String date = line.substring(idx+1); if(STICKY_DATE.matcher(date.trim()).matches()) { // the format is like "D2008.01.21.23.30.44" line = line.substring(0,idx+1); modified = true; } newContents.append(line).append('\n'); } if(modified) { // write it back AtomicFileWriter w = new AtomicFileWriter(entries,null); try { w.write(newContents.toString()); w.commit(); } finally { w.abort(); } } // recursively process children File[] children = f.listFiles(); if(children!=null) { for (File child : children) process(child); } } private static final Pattern STICKY_DATE = Pattern.compile("D\\d\\d\\d\\d\\.\\d\\d\\.\\d\\d\\.\\d\\d\\.\\d\\d\\.\\d\\d"); } @Extension public static final class DescriptorImpl extends SCMDescriptor<CVSSCM> implements ModelObject { /** * Path to <tt>.cvspass</tt>. Null to default. */ private String cvsPassFile; /** * Path to cvs executable. Null to just use "cvs". */ private String cvsExe; /** * Disable CVS compression support. */ private boolean noCompression; // compatibility only private transient Map<String,RepositoryBrowser> browsers; // compatibility only class RepositoryBrowser { String diffURL; String browseURL; } public DescriptorImpl() { super(CVSRepositoryBrowser.class); load(); } @Override protected void convert(Map<String, Object> oldPropertyBag) { cvsPassFile = (String)oldPropertyBag.get("cvspass"); } public String getDisplayName() { return "CVS"; } @Override public SCM newInstance(StaplerRequest req, JSONObject formData) throws FormException { CVSSCM scm = req.bindJSON(CVSSCM.class,formData); scm.repositoryBrowser = RepositoryBrowsers.createInstance(CVSRepositoryBrowser.class,req,formData,"browser"); return scm; } public String getCvspassFile() { String value = cvsPassFile; if(value==null) value = ""; return value; } public String getCvsExe() { return cvsExe; } public void setCvsExe(String value) { this.cvsExe = value; save(); } public String getCvsExeOrDefault() { if(Util.fixEmpty(cvsExe)==null) return "cvs"; else return cvsExe; } public void setCvspassFile(String value) { cvsPassFile = value; save(); } public boolean isNoCompression() { return noCompression; } @Override public boolean configure( StaplerRequest req, JSONObject o ) { cvsPassFile = fixEmptyAndTrim(o.getString("cvspassFile")); cvsExe = fixEmptyAndTrim(o.getString("cvsExe")); noCompression = req.getParameter("cvs_noCompression")!=null; save(); return true; } @Override public boolean isBrowserReusable(CVSSCM x, CVSSCM y) { return x.getCvsRoot().equals(y.getCvsRoot()); } /** * Returns all {@code CVSROOT} strings used in the current Hudson installation. */ public Set<String> getAllCvsRoots() { Set<String> r = new TreeSet<String>(); for( AbstractProject p : Hudson.getInstance().getAllItems(AbstractProject.class) ) { SCM scm = p.getScm(); if (scm instanceof CVSSCM) { CVSSCM cvsscm = (CVSSCM) scm; r.add(cvsscm.getCvsRoot()); } } return r; } // // web methods // public FormValidation doCheckCvspassFile(@QueryParameter String value) { // this method can be used to check if a file exists anywhere in the file system, // so it should be protected. if(!Hudson.getInstance().hasPermission(Hudson.ADMINISTER)) return FormValidation.ok(); value = fixEmpty(value); if(value==null) // not entered return FormValidation.ok(); File cvsPassFile = new File(value); if(cvsPassFile.exists()) { if(cvsPassFile.isDirectory()) return FormValidation.error(cvsPassFile+" is a directory"); else return FormValidation.ok(); } return FormValidation.error("No such file exists"); } /** * Checks if cvs executable exists. */ public FormValidation doCheckCvsExe(@QueryParameter String value) { return FormValidation.validateExecutable(value); } /** * Displays "cvs --version" for trouble shooting. */ public void doVersion(StaplerRequest req, StaplerResponse rsp) throws IOException, ServletException, InterruptedException { ByteBuffer baos = new ByteBuffer(); try { Hudson.getInstance().createLauncher(TaskListener.NULL).launch() .cmds(getCvsExeOrDefault(), "--version").stdout(baos).join(); rsp.setContentType("text/plain"); baos.writeTo(rsp.getOutputStream()); } catch (IOException e) { req.setAttribute("error",e); rsp.forward(this,"versionCheckError",req); } } /** * Checks the correctness of the branch name. */ public FormValidation doCheckBranch(@QueryParameter String value) { String v = fixNull(value); if(v.equals("HEAD")) return FormValidation.error(Messages.CVSSCM_HeadIsNotBranch()); return FormValidation.ok(); } /** * Checks the entry to the CVSROOT field. * <p> * Also checks if .cvspass file contains the entry for this. */ public FormValidation doCheckCvsRoot(@QueryParameter String value) throws IOException { String v = fixEmpty(value); if(v==null) return FormValidation.error(Messages.CVSSCM_MissingCvsroot()); Matcher m = CVSROOT_PSERVER_PATTERN.matcher(v); // CVSROOT format isn't really that well defined. So it's hard to check this rigorously. if(v.startsWith(":pserver") || v.startsWith(":ext")) { if(!m.matches()) return FormValidation.error(Messages.CVSSCM_InvalidCvsroot()); // I can't really test if the machine name exists, either. // some cvs, such as SOCKS-enabled cvs can resolve host names that Hudson might not // be able to. If :ext is used, all bets are off anyway. } // check .cvspass file to see if it has entry. // CVS handles authentication only if it's pserver. if(v.startsWith(":pserver")) { if(m.group(2)==null) {// if password is not specified in CVSROOT String cvspass = getCvspassFile(); File passfile; if(cvspass.equals("")) { passfile = new File(new File(System.getProperty("user.home")),".cvspass"); } else { passfile = new File(cvspass); } if(passfile.exists()) { // It's possible that we failed to locate the correct .cvspass file location, // so don't report an error if we couldn't locate this file. // // if this is explicitly specified, then our system config page should have // reported an error. if(!scanCvsPassFile(passfile, v)) return FormValidation.error(Messages.CVSSCM_PasswordNotSet()); } } } return FormValidation.ok(); } /** * Validates the excludeRegions Regex */ public FormValidation doCheckExcludeRegions(@QueryParameter String value) { String v = fixNull(value).trim(); for (String region : v.split("[\\r\\n]+")) try { Pattern.compile(region); } catch (PatternSyntaxException e) { return FormValidation.error("Invalid regular expression. " + e.getMessage()); } return FormValidation.ok(); } /** * Checks if the given pserver CVSROOT value exists in the pass file. */ private boolean scanCvsPassFile(File passfile, String cvsroot) throws IOException { cvsroot += ' '; String cvsroot2 = "/1 "+cvsroot; // see http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=5006835 BufferedReader in = new BufferedReader(new FileReader(passfile)); try { String line; while((line=in.readLine())!=null) { // "/1 " version always have the port number in it, so examine a much with // default port 2401 left out int portIndex = line.indexOf(":2401/"); String line2 = ""; if(portIndex>=0) line2 = line.substring(0,portIndex+1)+line.substring(portIndex+5); // leave '/' if(line.startsWith(cvsroot) || line.startsWith(cvsroot2) || line2.startsWith(cvsroot2)) return true; } return false; } finally { in.close(); } } private static final Pattern CVSROOT_PSERVER_PATTERN = Pattern.compile(":(ext|extssh|pserver):[^@:]+(:[^@:]+)?@[^:]+:(\\d+:)?.+"); /** * Runs cvs login command. * * TODO: this apparently doesn't work. Probably related to the fact that * cvs does some tty magic to disable echo back or whatever. */ public void doPostPassword(StaplerRequest req, StaplerResponse rsp) throws IOException, InterruptedException { Hudson.getInstance().checkPermission(Hudson.ADMINISTER); String cvsroot = req.getParameter("cvsroot"); String password = req.getParameter("password"); if(cvsroot==null || password==null) { rsp.setStatus(HttpServletResponse.SC_BAD_REQUEST); return; } rsp.setContentType("text/plain"); Proc proc = Hudson.getInstance().createLauncher(TaskListener.NULL).launch() .cmds(getCvsExeOrDefault(), "-d",cvsroot,"login") .stdin(new ByteArrayInputStream((password+"\n").getBytes())) .stdout(rsp.getOutputStream()).start(); proc.join(); } } /** * Action for a build that performs the tagging. */ @ExportedBean public final class TagAction extends AbstractScmTagAction implements Describable<TagAction> { /** * If non-null, that means the build is already tagged. * If multiple tags are created, those are whitespace-separated. */ private volatile String tagName; public TagAction(AbstractBuild build) { super(build); } public String getIconFileName() { if(tagName==null && !build.getParent().getACL().hasPermission(TAG)) return null; return "save.gif"; } public String getDisplayName() { if(tagName==null) return Messages.CVSSCM_TagThisBuild(); if(tagName.indexOf(' ')>=0) return Messages.CVSSCM_DisplayName2(); else return Messages.CVSSCM_DisplayName1(); } @Exported public String[] getTagNames() { if(tagName==null) return new String[0]; return tagName.split(" "); } /** * Checks if the value is a valid CVS tag name. */ public synchronized FormValidation doCheckTag(@QueryParameter String value) { String tag = fixNull(value).trim(); if(tag.length()==0) // nothing entered yet return FormValidation.ok(); return FormValidation.error(isInvalidTag(tag)); } @Override public Permission getPermission() { return TAG; } @Override public String getTooltip() { if(tagName!=null) return "Tag: "+tagName; else return null; } @Override public boolean isTagged() { return tagName!=null; } /** * Invoked to actually tag the workspace. */ public synchronized void doSubmit(StaplerRequest req, StaplerResponse rsp) throws IOException, ServletException { build.checkPermission(getPermission()); Map<AbstractBuild,String> tagSet = new HashMap<AbstractBuild,String>(); String name = fixNull(req.getParameter("name")).trim(); String reason = isInvalidTag(name); if(reason!=null) { sendError(reason,req,rsp); return; } tagSet.put(build,name); if(req.getParameter("upstream")!=null) { // tag all upstream builds Enumeration e = req.getParameterNames(); Map<AbstractProject, Integer> upstreams = build.getTransitiveUpstreamBuilds(); // TODO: define them at AbstractBuild level while(e.hasMoreElements()) { String upName = (String) e.nextElement(); if(!upName.startsWith("upstream.")) continue; String tag = fixNull(req.getParameter(upName)).trim(); reason = isInvalidTag(tag); if(reason!=null) { sendError(Messages.CVSSCM_NoValidTagNameGivenFor(upName,reason),req,rsp); return; } upName = upName.substring(9); // trim off 'upstream.' AbstractProject p = Hudson.getInstance().getItemByFullName(upName,AbstractProject.class); if(p==null) { sendError(Messages.CVSSCM_NoSuchJobExists(upName),req,rsp); return; } Integer buildNum = upstreams.get(p); if(buildNum==null) { sendError(Messages.CVSSCM_NoUpstreamBuildFound(upName),req,rsp); return; } Run build = p.getBuildByNumber(buildNum); tagSet.put((AbstractBuild) build,tag); } } new TagWorkerThread(this,tagSet).start(); doIndex(req,rsp); } /** * Checks if the given value is a valid CVS tag. * * If it's invalid, this method gives you the reason as string. */ private String isInvalidTag(String name) { // source code from CVS rcs.c //void //RCS_check_tag (tag) // const char *tag; //{ // char *invalid = "$,.:;@"; /* invalid RCS tag characters */ // const char *cp; // // /* // * The first character must be an alphabetic letter. The remaining // * characters cannot be non-visible graphic characters, and must not be // * in the set of "invalid" RCS identifier characters. // */ // if (isalpha ((unsigned char) *tag)) // { // for (cp = tag; *cp; cp++) // { // if (!isgraph ((unsigned char) *cp)) // error (1, 0, "tag `%s' has non-visible graphic characters", // tag); // if (strchr (invalid, *cp)) // error (1, 0, "tag `%s' must not contain the characters `%s'", // tag, invalid); // } // } // else // error (1, 0, "tag `%s' must start with a letter", tag); //} if(name==null || name.length()==0) return Messages.CVSSCM_TagIsEmpty(); char ch = name.charAt(0); if(!(('A'<=ch && ch<='Z') || ('a'<=ch && ch<='z'))) return Messages.CVSSCM_TagNeedsToStartWithAlphabet(); for( char invalid : "$,.:;@".toCharArray() ) { if(name.indexOf(invalid)>=0) return Messages.CVSSCM_TagContainsIllegalChar(invalid); } return null; } /** * Performs tagging. */ public void perform(String tagName, TaskListener listener) { File destdir = null; try { destdir = Util.createTempDir(); // unzip the archive listener.getLogger().println(Messages.CVSSCM_ExpandingWorkspaceArchive(destdir)); Expand e = new Expand(); e.setProject(new org.apache.tools.ant.Project()); e.setDest(destdir); e.setSrc(getArchiveFile(build)); e.setTaskType("unzip"); e.execute(); // run cvs tag command listener.getLogger().println(Messages.CVSSCM_TaggingWorkspace()); for (String m : getAllModulesNormalized()) { FilePath path = new FilePath(destdir).child(m); boolean isDir = path.isDirectory(); ArgumentListBuilder cmd = new ArgumentListBuilder(); cmd.add(CVSSCM.this.getDescriptor().getCvsExeOrDefault(),"tag"); if(isDir) { cmd.add("-R"); } cmd.add(tagName); if(!isDir) { cmd.add(path.getName()); path = path.getParent(); } if(!CVSSCM.this.run(new Launcher.LocalLauncher(listener),cmd,listener, path)) { listener.getLogger().println(Messages.CVSSCM_TaggingFailed()); return; } } // completed successfully onTagCompleted(tagName); build.save(); } catch (Throwable e) { e.printStackTrace(listener.fatalError(e.getMessage())); } finally { try { if(destdir!=null) { listener.getLogger().println("cleaning up "+destdir); Util.deleteRecursive(destdir); } } catch (IOException e) { e.printStackTrace(listener.fatalError(e.getMessage())); } } } /** * Atomically set the tag name and then be done with {@link TagWorkerThread}. */ private synchronized void onTagCompleted(String tagName) { if(this.tagName!=null) this.tagName += ' '+tagName; else this.tagName = tagName; this.workerThread = null; } public Descriptor<TagAction> getDescriptor() { return Hudson.getInstance().getDescriptorOrDie(getClass()); } } @Extension public static final class TagActionDescriptor extends Descriptor<TagAction> { public TagActionDescriptor() { super(TagAction.class); } public String getDisplayName() { return ""; } } public static final class TagWorkerThread extends TaskThread { private final Map<AbstractBuild,String> tagSet; public TagWorkerThread(TagAction owner,Map<AbstractBuild,String> tagSet) { super(owner,ListenerAndText.forMemory()); this.tagSet = tagSet; } @Override public synchronized void start() { for (Entry<AbstractBuild, String> e : tagSet.entrySet()) { TagAction ta = e.getKey().getAction(TagAction.class); if(ta!=null) associateWith(ta); } super.start(); } protected void perform(TaskListener listener) { for (Entry<AbstractBuild, String> e : tagSet.entrySet()) { TagAction ta = e.getKey().getAction(TagAction.class); if(ta==null) { listener.error(e.getKey()+" doesn't have CVS tag associated with it. Skipping"); continue; } listener.getLogger().println(Messages.CVSSCM_TagginXasY(e.getKey(),e.getValue())); try { e.getKey().keepLog(); } catch (IOException x) { x.printStackTrace(listener.error(Messages.CVSSCM_FailedToMarkForKeep(e.getKey()))); } ta.perform(e.getValue(), listener); listener.getLogger().println(); } } } /** * Temporary hack for assisting trouble-shooting. * * <p> * Setting this property to true would cause <tt>cvs log</tt> to dump a lot of messages. */ public static boolean debug = Boolean.getBoolean(CVSSCM.class.getName()+".debug"); // probe to figure out the CVS hang problem public static boolean noQuiet = Boolean.getBoolean(CVSSCM.class.getName()+".noQuiet"); private static final long serialVersionUID = 1L; /** * True to avoid computing the changelog. Useful with ancient versions of CVS that doesn't support * the -d option in the log command. See #1346. */ public static boolean skipChangeLog = Boolean.getBoolean(CVSSCM.class.getName()+".skipChangeLog"); private static final Logger LOGGER = Logger.getLogger(CVSSCM.class.getName()); }