/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.apache.jmeter.gui.action;
import java.awt.event.ActionEvent;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.swing.JFileChooser;
import javax.swing.JOptionPane;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.filefilter.FileFilterUtils;
import org.apache.commons.io.filefilter.IOFileFilter;
import org.apache.jmeter.control.gui.TestFragmentControllerGui;
import org.apache.jmeter.engine.TreeCloner;
import org.apache.jmeter.exceptions.IllegalUserActionException;
import org.apache.jmeter.gui.GuiPackage;
import org.apache.jmeter.gui.tree.JMeterTreeNode;
import org.apache.jmeter.gui.util.FileDialoger;
import org.apache.jmeter.save.SaveService;
import org.apache.jmeter.testelement.TestElement;
import org.apache.jmeter.testelement.TestPlan;
import org.apache.jmeter.testelement.WorkBench;
import org.apache.jmeter.threads.ThreadGroup;
import org.apache.jmeter.util.JMeterUtils;
import org.apache.jorphan.collections.HashTree;
import org.apache.jorphan.collections.ListedHashTree;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Save the current test plan; implements:
* Save
* Save TestPlan As
* Save (Selection) As
*/
public class Save extends AbstractAction {
private static final Logger log = LoggerFactory.getLogger(Save.class);
private static final List<File> EMPTY_FILE_LIST = Collections.emptyList();
private static final String JMX_BACKUP_ON_SAVE = "jmeter.gui.action.save.backup_on_save"; // $NON-NLS-1$
private static final String JMX_BACKUP_DIRECTORY = "jmeter.gui.action.save.backup_directory"; // $NON-NLS-1$
private static final String JMX_BACKUP_MAX_HOURS = "jmeter.gui.action.save.keep_backup_max_hours"; // $NON-NLS-1$
private static final String JMX_BACKUP_MAX_COUNT = "jmeter.gui.action.save.keep_backup_max_count"; // $NON-NLS-1$
public static final String JMX_FILE_EXTENSION = ".jmx"; // $NON-NLS-1$
private static final String DEFAULT_BACKUP_DIRECTORY = JMeterUtils.getJMeterHome() + "/backups"; //$NON-NLS-1$
// Whether we should keep backups for save JMX files. Default is to enable backup
private static final boolean BACKUP_ENABLED = JMeterUtils.getPropDefault(JMX_BACKUP_ON_SAVE, true);
// Path to the backup directory
private static final String BACKUP_DIRECTORY = JMeterUtils.getPropDefault(JMX_BACKUP_DIRECTORY, DEFAULT_BACKUP_DIRECTORY);
// Backup files expiration in hours. Default is to never expire (zero value).
private static final int BACKUP_MAX_HOURS = JMeterUtils.getPropDefault(JMX_BACKUP_MAX_HOURS, 0);
// Max number of backup files. Default is to limit to 10 backups max.
private static final int BACKUP_MAX_COUNT = JMeterUtils.getPropDefault(JMX_BACKUP_MAX_COUNT, 10);
// NumberFormat to format version number in backup file names
private static final DecimalFormat BACKUP_VERSION_FORMATER = new DecimalFormat("000000"); //$NON-NLS-1$
private static final Set<String> commands = new HashSet<>();
static {
commands.add(ActionNames.SAVE_AS); // Save (Selection) As
commands.add(ActionNames.SAVE_AS_TEST_FRAGMENT); // Save as Test Fragment
commands.add(ActionNames.SAVE_ALL_AS); // Save TestPlan As
commands.add(ActionNames.SAVE); // Save
}
/**
* Constructor for the Save object.
*/
public Save() {}
/**
* Gets the ActionNames attribute of the Save object.
*
* @return the ActionNames value
*/
@Override
public Set<String> getActionNames() {
return commands;
}
@Override
public void doAction(ActionEvent e) throws IllegalUserActionException {
HashTree subTree;
boolean fullSave = false; // are we saving the whole tree?
if (!commands.contains(e.getActionCommand())) {
throw new IllegalUserActionException("Invalid user command:" + e.getActionCommand());
}
if (e.getActionCommand().equals(ActionNames.SAVE_AS)) {
JMeterTreeNode[] nodes = GuiPackage.getInstance().getTreeListener().getSelectedNodes();
if (nodes.length > 1){
JMeterUtils.reportErrorToUser(
JMeterUtils.getResString("save_as_error"), // $NON-NLS-1$
JMeterUtils.getResString("save_as")); // $NON-NLS-1$
return;
}
subTree = GuiPackage.getInstance().getCurrentSubTree();
}
else if (e.getActionCommand().equals(ActionNames.SAVE_AS_TEST_FRAGMENT)) {
JMeterTreeNode[] nodes = GuiPackage.getInstance().getTreeListener().getSelectedNodes();
if(checkAcceptableForTestFragment(nodes)) {
subTree = GuiPackage.getInstance().getCurrentSubTree();
// Create Test Fragment node
TestElement element = GuiPackage.getInstance().createTestElement(TestFragmentControllerGui.class.getName());
HashTree hashTree = new ListedHashTree();
HashTree tfTree = hashTree.add(new JMeterTreeNode(element, null));
for (JMeterTreeNode node : nodes) {
// Clone deeply current node
TreeCloner cloner = new TreeCloner(false);
GuiPackage.getInstance().getTreeModel().getCurrentSubTree(node).traverse(cloner);
// Add clone to tfTree
tfTree.add(cloner.getClonedTree());
}
subTree = hashTree;
} else {
JMeterUtils.reportErrorToUser(
JMeterUtils.getResString("save_as_test_fragment_error"), // $NON-NLS-1$
JMeterUtils.getResString("save_as_test_fragment")); // $NON-NLS-1$
return;
}
} else {
fullSave = true;
HashTree testPlan = GuiPackage.getInstance().getTreeModel().getTestPlan();
// If saveWorkBench
if (isWorkbenchSaveable()) {
HashTree workbench = GuiPackage.getInstance().getTreeModel().getWorkBench();
testPlan.add(workbench);
}
subTree = testPlan;
}
String updateFile = GuiPackage.getInstance().getTestPlanFile();
if (!ActionNames.SAVE.equals(e.getActionCommand()) || updateFile == null) {
JFileChooser chooser = FileDialoger.promptToSaveFile(updateFile == null ? GuiPackage.getInstance().getTreeListener()
.getCurrentNode().getName()
+ JMX_FILE_EXTENSION : updateFile);
if (chooser == null) {
return;
}
updateFile = chooser.getSelectedFile().getAbsolutePath();
// Make sure the file ends with proper extension
if(FilenameUtils.getExtension(updateFile).isEmpty()) {
updateFile = updateFile + JMX_FILE_EXTENSION;
}
// Check if the user is trying to save to an existing file
File f = new File(updateFile);
if(f.exists()) {
int response = JOptionPane.showConfirmDialog(GuiPackage.getInstance().getMainFrame(),
JMeterUtils.getResString("save_overwrite_existing_file"), // $NON-NLS-1$
JMeterUtils.getResString("save?"), // $NON-NLS-1$
JOptionPane.YES_NO_OPTION,
JOptionPane.QUESTION_MESSAGE);
if (response == JOptionPane.CLOSED_OPTION || response == JOptionPane.NO_OPTION) {
return ; // Do not save, user does not want to overwrite
}
}
if (!e.getActionCommand().equals(ActionNames.SAVE_AS)) {
GuiPackage.getInstance().setTestPlanFile(updateFile);
}
}
// backup existing file according to jmeter/user.properties settings
List<File> expiredBackupFiles = EMPTY_FILE_LIST;
File fileToBackup = new File(updateFile);
try {
expiredBackupFiles = createBackupFile(fileToBackup);
} catch (Exception ex) {
log.error("Failed to create a backup for {}", fileToBackup, ex); //$NON-NLS-1$
}
try {
convertSubTree(subTree);
} catch (Exception err) {
if (log.isWarnEnabled()) {
log.warn("Error converting subtree. {}", err.toString());
}
}
try (FileOutputStream ostream = new FileOutputStream(updateFile)){
SaveService.saveTree(subTree, ostream);
if (fullSave) { // Only update the stored copy of the tree for a full save
subTree = GuiPackage.getInstance().getTreeModel().getTestPlan(); // refetch, because convertSubTree affects it
if (isWorkbenchSaveable()) {
HashTree workbench = GuiPackage.getInstance().getTreeModel().getWorkBench();
subTree.add(workbench);
}
ActionRouter.getInstance().doActionNow(new ActionEvent(subTree, e.getID(), ActionNames.SUB_TREE_SAVED));
}
// delete expired backups : here everything went right so we can
// proceed to deletion
for (File expiredBackupFile : expiredBackupFiles) {
try {
FileUtils.deleteQuietly(expiredBackupFile);
} catch (Exception ex) {
log.warn("Failed to delete backup file, {}", expiredBackupFile); //$NON-NLS-1$
}
}
} catch(RuntimeException ex) {
throw ex;
}
catch (Exception ex) {
log.error("Error saving tree.", ex);
throw new IllegalUserActionException("Couldn't save test plan to file: " + updateFile, ex);
}
GuiPackage.getInstance().updateCurrentGui();
}
/**
* <p>
* Create a backup copy of the specified file whose name will be
* <code>{baseName}-{version}.jmx</code><br>
* Where :<br>
* <code>{baseName}</code> is the name of the file to backup without its
* <code>.jmx</code> extension. For a file named <code>testplan.jmx</code>
* it would then be <code>testplan</code><br>
* <code>{version}</code> is the version number automatically incremented
* after the higher version number of pre-existing backup files. <br>
* <br>
* Example: <code>testplan-000028.jmx</code> <br>
* <br>
* If <code>jmeter.gui.action.save.backup_directory</code> is <b>not</b>
* set, then backup files will be created in
* <code>${JMETER_HOME}/backups</code>
* </p>
* <p>
* Backup process is controlled by the following jmeter/user properties :<br>
* <table border=1>
* <tr>
* <th align=left>Property</th>
* <th align=left>Type/Value</th>
* <th align=left>Description</th>
* </tr>
* <tr>
* <td><code>jmeter.gui.action.save.backup_on_save</code></td>
* <td><code>true|false</code></td>
* <td>Enables / Disables backup</td>
* </tr>
* <tr>
* <td><code>jmeter.gui.action.save.backup_directory</code></td>
* <td><code>/path/to/backup/directory</code></td>
* <td>Set the directory path where backups will be stored upon save. If not
* set then backups will be created in <code>${JMETER_HOME}/backups</code><br>
* If that directory does not exist, it will be created</td>
* </tr>
* <tr>
* <td><code>jmeter.gui.action.save.keep_backup_max_hours</code></td>
* <td><code>integer</code></td>
* <td>Maximum number of hours to preserve backup files. Backup files whose
* age exceeds that limit should be deleted and will be added to this method
* returned list</td>
* </tr>
* <tr>
* <td><code>jmeter.gui.action.save.keep_backup_max_count</code></td>
* <td><code>integer</code></td>
* <td>Max number of backup files to be preserved. Exceeding backup files
* should be deleted and will be added to this method returned list. Only
* the most recent files will be preserved.</td>
* </tr>
* </table>
* </p>
*
* @param fileToBackup
* The file to create a backup from
* @return A list of expired backup files selected according to the above
* properties and that should be deleted after the save operation
* has performed successfully
*/
private List<File> createBackupFile(File fileToBackup) {
if (!BACKUP_ENABLED || !fileToBackup.exists()) {
return EMPTY_FILE_LIST;
}
char versionSeparator = '-'; //$NON-NLS-1$
String baseName = fileToBackup.getName();
// remove .jmx extension if any
baseName = baseName.endsWith(JMX_FILE_EXTENSION) ? baseName.substring(0, baseName.length() - JMX_FILE_EXTENSION.length()) : baseName;
// get a file to the backup directory
File backupDir = new File(BACKUP_DIRECTORY);
backupDir.mkdirs();
if (!backupDir.isDirectory()) {
log.error(
"Could not backup file ! Backup directory does not exist, is not a directory or could not be created ! <{}>", //$NON-NLS-1$
backupDir.getAbsolutePath()); //$NON-NLS-2$
}
/**
* select files matching
* {baseName}{versionSeparator}{version}{jmxExtension}
* where {version} is a 6 digits number
*/
String backupPatternRegex = Pattern.quote(baseName + versionSeparator) + "([\\d]{6})" + Pattern.quote(JMX_FILE_EXTENSION); //$NON-NLS-1$
Pattern backupPattern = Pattern.compile(backupPatternRegex);
// create a file filter that select files matching a given regex pattern
IOFileFilter patternFileFilter = new PrivatePatternFileFilter(backupPattern);
// get all backup files in the backup directory
List<File> backupFiles = new ArrayList<>(FileUtils.listFiles(backupDir, patternFileFilter, null));
// find the highest version number among existing backup files (this
// should be the more recent backup)
int lastVersionNumber = 0;
for (File backupFile : backupFiles) {
Matcher matcher = backupPattern.matcher(backupFile.getName());
if (matcher.find() && matcher.groupCount() > 0) {
// parse version number from the backup file name
// should never fail as it matches the regex
int version = Integer.parseInt(matcher.group(1));
lastVersionNumber = Math.max(lastVersionNumber, version);
}
}
// find expired backup files
List<File> expiredFiles = new ArrayList<>();
if (BACKUP_MAX_HOURS > 0) {
Calendar cal = Calendar.getInstance();
cal.add(Calendar.HOUR_OF_DAY, -BACKUP_MAX_HOURS);
long expiryDate = cal.getTime().getTime();
// select expired files that should be deleted
IOFileFilter expiredFileFilter = FileFilterUtils.ageFileFilter(expiryDate, true);
expiredFiles.addAll(FileFilterUtils.filterList(expiredFileFilter, backupFiles));
}
// sort backups from by their last modified time
Collections.sort(backupFiles, (o1, o2) -> {
long diff = o1.lastModified() - o2.lastModified();
// convert the long to an int in order to comply with the method
// contract
return diff < 0 ? -1 : diff > 0 ? 1 : 0;
});
/**
* backup name is of the form
* {baseName}{versionSeparator}{version}{jmxExtension}
*/
String backupName = baseName + versionSeparator + BACKUP_VERSION_FORMATER.format(lastVersionNumber + 1L) + JMX_FILE_EXTENSION;
File backupFile = new File(backupDir, backupName);
// create file backup
try {
FileUtils.copyFile(fileToBackup, backupFile);
} catch (IOException e) {
log.error("Failed to backup file : {}", fileToBackup.getAbsolutePath(), e); //$NON-NLS-1$
return EMPTY_FILE_LIST;
}
// add the fresh new backup file (list is still sorted here)
backupFiles.add(backupFile);
// unless max backups is not set, ensure that we don't keep more backups
// than required
if (BACKUP_MAX_COUNT > 0 && backupFiles.size() > BACKUP_MAX_COUNT) {
// keep the most recent files in the limit of the specified max
// count
expiredFiles.addAll(backupFiles.subList(0, backupFiles.size() - BACKUP_MAX_COUNT));
}
return expiredFiles;
}
/**
* check if the workbench should be saved
*/
private boolean isWorkbenchSaveable() {
JMeterTreeNode workbenchNode = (JMeterTreeNode) ((JMeterTreeNode) GuiPackage.getInstance().getTreeModel().getRoot()).getChildAt(1);
return ((WorkBench) workbenchNode.getUserObject()).getSaveWorkBench();
}
/**
* Check nodes does not contain a node of type TestPlan or ThreadGroup
* @param nodes
*/
private static boolean checkAcceptableForTestFragment(JMeterTreeNode[] nodes) {
for (JMeterTreeNode node : nodes) {
Object userObject = node.getUserObject();
if (userObject instanceof ThreadGroup ||
userObject instanceof TestPlan) {
return false;
}
}
return true;
}
// package protected to allow access from test code
void convertSubTree(HashTree tree) {
for (Object o : new LinkedList<>(tree.list())) {
JMeterTreeNode item = (JMeterTreeNode) o;
convertSubTree(tree.getTree(item));
TestElement testElement = item.getTestElement(); // requires JMeterTreeNode
tree.replaceKey(item, testElement);
}
}
private static class PrivatePatternFileFilter implements IOFileFilter {
private Pattern pattern;
public PrivatePatternFileFilter(Pattern pattern) {
if(pattern == null) {
throw new IllegalArgumentException("pattern cannot be null !"); //$NON-NLS-1$
}
this.pattern = pattern;
}
@Override
public boolean accept(File dir, String fileName) {
return pattern.matcher(fileName).matches();
}
@Override
public boolean accept(File file) {
return accept(file.getParentFile(), file.getName());
}
}
}