diff options
author | Håkon Hallingstad <hakon@oath.com> | 2018-08-27 17:18:08 +0200 |
---|---|---|
committer | Håkon Hallingstad <hakon@oath.com> | 2018-08-27 17:18:08 +0200 |
commit | 1599bbee277b3065ef327bf98acd4cf71a2d6a97 (patch) | |
tree | de1ced5cea7b23e1646cf44e2e44a0b56afbae36 /node-admin | |
parent | 73ed7ad7c3cbd41d5ca44c4f2f7ae547fe4c5abe (diff) |
Add version locking to YUM commands
Diffstat (limited to 'node-admin')
-rw-r--r-- | node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/yum/Yum.java | 195 | ||||
-rw-r--r-- | node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/yum/YumTest.java | 63 |
2 files changed, 253 insertions, 5 deletions
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/yum/Yum.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/yum/Yum.java index 5d60823d1c5..22fba731b02 100644 --- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/yum/Yum.java +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/yum/Yum.java @@ -6,8 +6,10 @@ import com.yahoo.vespa.hosted.node.admin.task.util.process.CommandLine; import com.yahoo.vespa.hosted.node.admin.task.util.process.Terminal; import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -63,7 +65,9 @@ public class Yum { private final String yumCommand; private final List<String> packages; private final Pattern commandOutputNoopPattern; + private Optional<String> enabledRepo = Optional.empty(); + private boolean lockVersion = false; private GenericYumCommand(Terminal terminal, String yumCommand, @@ -85,8 +89,58 @@ public class Yum { return this; } - public boolean converge(TaskContext taskContext) { - CommandLine commandLine = terminal.newCommandLine(taskContext); + /** + * Ensure the version of the installs are locked. + * + * <p>WARNING: In order to simplify the user interface of {@link #lockVersion()}, + * the package name specified in the command, e.g. {@link #install(String...)}, MUST be of + * a simple format, see {@link PackageName#fromString(String)}. + */ + public GenericYumCommand lockVersion() { + packages.forEach(PackageName::fromString); // to throw any parse error here instead of later + lockVersion = true; + return this; + } + + public boolean converge(TaskContext context) { + Set<String> packageNamesToLock = new HashSet<>(); + Set<String> fullPackageNamesToLock = new HashSet<>(); + + if (lockVersion) { + // Remove all locks for other version + + packages.stream() + .map(PackageName::fromString) + .map(PackageName.Builder::new) + .map(builder -> builder.setArchitecture("*").build()) + .forEach(packageName -> { + packageNamesToLock.add(packageName.getName()); + fullPackageNamesToLock.add(packageName.toFullName()); + }); + + terminal.newCommandLine(context) + .add("yum", "--quiet", "versionlock", "list") + .executeSilently() + .getOutputLinesStream() + .map(PackageName::parseString) + .filter(Optional::isPresent) + .map(Optional::get) + .forEach(packageName -> { + // Ignore lines for other packages + if (packageNamesToLock.contains(packageName.getName())) { + // If existing lock doesn't exactly match the full package name, + // it means it's locked to another version and we must remove that lock. + String fullName = packageName.toFullName(); + if (!fullPackageNamesToLock.remove(fullName)) { + terminal.newCommandLine(context) + .add("yum", "versionlock", "delete", fullName) + .execute(); + } + } + }); + } + + CommandLine commandLine = terminal.newCommandLine(context); commandLine.add("yum", yumCommand, "--assumeyes"); enabledRepo.ifPresent(repo -> commandLine.add("--enablerepo=" + repo)); commandLine.add(packages); @@ -101,10 +155,16 @@ public class Yum { commandLine.recordSilentExecutionAsSystemModification(); } + fullPackageNamesToLock.forEach(fullPackageName -> + terminal.newCommandLine(context) + .add("yum", "versionlock", "add", fullPackageName) + .execute()); + modifiedSystem |= !fullPackageNamesToLock.isEmpty(); + return modifiedSystem; } - public boolean mapOutput(String output) { + private boolean mapOutput(String output) { Matcher unknownPackageMatcher = UNKNOWN_PACKAGE_PATTERN.matcher(output); if (unknownPackageMatcher.find()) { throw new IllegalArgumentException("Unknown package: " + unknownPackageMatcher.group(1)); @@ -113,4 +173,133 @@ public class Yum { return !commandOutputNoopPattern.matcher(output).find(); } } + + /** YUM package name. */ + private static class PackageName { + private static final Pattern ARCHITECTURE_PATTERN = Pattern.compile("\\.(noarch|x86_64|i686|i386|\\*)$"); + private static final Pattern NAME_VER_REL_PATTERN = Pattern.compile("^(.+)-([^-]+)-([^-]+)$"); + + public final Optional<String> epoch; + public final String name; + public final Optional<String> version; + public final Optional<String> release; + public final Optional<String> architecture; + + private PackageName(Optional<String> epoch, + String name, + Optional<String> version, + Optional<String> release, + Optional<String> architecture) { + this.epoch = epoch; + this.name = name; + this.version = version; + this.release = release; + this.architecture = architecture; + } + + public static PackageName fromComponents(String name, String version, String release) { + if (name.isEmpty()) throw new IllegalArgumentException("Name is empty"); + if (version.isEmpty()) throw new IllegalArgumentException("Version is empty"); + if (release.isEmpty()) throw new IllegalArgumentException("Release is empty"); + return new PackageName(Optional.empty(), name, Optional.of(version), Optional.of(release), Optional.empty()); + } + + /** + * Parse the string specification of a YUM package. + * + * <p>According to yum(8) a package can be specified using a variety of different + * and ambiguous formats. We'll use a subset: + * + * <ul> + * <li>spec MUST be of the form name-ver-rel, name-ver-rel.arch, or epoch:name-ver-rel.arch. + * <li>If specified, arch MUST be one of "noarch", "i686", "x86_64", or "*". The wildcard + * is equivalent to not specifying arch. + * <li>rel cannot end in something that would be mistaken for the '.arch' suffix. + * <li>ver and rel are assumed to not contain any '-' to uniquely identify name. + * </ul> + * + * @param spec A package name of the form epoch:name-ver-rel.arch, name-ver-rel.arch, or name-ver-rel. + * @return The package with that name. + * @throws IllegalArgumentException if spec does not specify a package name. + */ + public static PackageName fromString(String spec) { + return parseString(spec).orElseThrow(() -> new IllegalArgumentException("Failed to decode the YUM package spec '" + spec + "'")); + } + + /** See {@link #fromString(String)}. */ + public static Optional<PackageName> parseString(String spec) { + Optional<String> epoch = Optional.empty(); + int epochColon = spec.indexOf(':'); + if (epochColon >= 0) { + epoch = Optional.of(spec.substring(0, epochColon)); + spec = spec.substring(epochColon + 1); + } + + Optional<String> architecture = Optional.empty(); + Matcher architectureMatcher = ARCHITECTURE_PATTERN.matcher(spec); + if (architectureMatcher.find()) { + architecture = Optional.of(architectureMatcher.group(1)); + spec = spec.substring(0, architectureMatcher.start()); + } + + + Matcher matcher = NAME_VER_REL_PATTERN.matcher(spec); + if (matcher.find()) { + return Optional.of(new PackageName( + epoch, + matcher.group(1), + Optional.of(matcher.group(2)), + Optional.of(matcher.group(3)), + architecture)); + } + + return Optional.empty(); + } + + public Optional<String> getEpoch() { return epoch; } + public String getName() { return name; } + public Optional<String> getVersion() { return version; } + public Optional<String> getRelease() { return release; } + public Optional<String> getArchitecture() { return architecture; } + + /** + * Return the full name of the package in the format epoch:name-ver-rel.arch, which can + * be used with e.g. the YUM install and versionlock commands. + * + * <p>The package MUST have both version and release. Absent epoch defaults to "0". + * Absent arch defaults to "*". + */ + public String toFullName() { + return String.format("%s:%s-%s-%s.%s", + epoch.orElse("0"), + name, + version.orElseThrow(() -> new IllegalStateException("Version is missing for YUM package " + name)), + release.orElseThrow(() -> new IllegalStateException("Release is missing for YUM package " + name)), + architecture.orElse("*")); + } + + public static class Builder { + private Optional<String> epoch; + private String name; + private Optional<String> version; + private Optional<String> release; + private Optional<String> architecture; + + public Builder(PackageName aPackage) { + epoch = aPackage.epoch; + name = aPackage.name; + version = aPackage.version; + release = aPackage.release; + architecture = aPackage.architecture; + } + + public Builder setEpoch(String epoch) { this.epoch = Optional.of(epoch); return this; } + public Builder setName(String name) { this.name = name; return this; } + public Builder setRelease(String version) { this.version = Optional.of(version); return this; } + public Builder setVersion(String release) { this.release = Optional.of(release); return this; } + public Builder setArchitecture(String architecture) { this.architecture = Optional.of(architecture); return this; } + + public PackageName build() { return new PackageName(epoch, name, version, release, architecture); } + } + } } diff --git a/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/yum/YumTest.java b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/yum/YumTest.java index 7f37336db70..f2a2306263a 100644 --- a/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/yum/YumTest.java +++ b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/yum/YumTest.java @@ -4,7 +4,7 @@ package com.yahoo.vespa.hosted.node.admin.task.util.yum; import com.yahoo.vespa.hosted.node.admin.component.TaskContext; import com.yahoo.vespa.hosted.node.admin.task.util.process.ChildProcessFailureException; import com.yahoo.vespa.hosted.node.admin.task.util.process.TestTerminal; -import org.junit.Before; +import org.junit.After; import org.junit.Test; import static org.junit.Assert.assertEquals; @@ -19,7 +19,7 @@ public class YumTest { private final TestTerminal terminal = new TestTerminal(); private final Yum yum = new Yum(terminal); - @Before + @After public void tearDown() { terminal.verifyAllCommandsExecuted(); } @@ -86,6 +86,65 @@ public class YumTest { .converge(taskContext)); } + @Test + public void testWithVersionLock() { + terminal.expectCommand("yum --quiet versionlock list 2>&1", + 0, + "Repository chef_rpms-release is listed more than once in the configuration\n" + + "0:chef-12.21.1-1.el7.*\n"); + terminal.expectCommand( + "yum install --assumeyes \"0:package-1-0.10-654.el7.*\" 2>&1", + 0, + "installing"); + terminal.expectCommand("yum versionlock add \"0:package-1-0.10-654.el7.*\" 2>&1"); + + assertTrue(yum + .install("0:package-1-0.10-654.el7.*") + .lockVersion() + .converge(taskContext)); + } + + @Test + public void testWithDifferentVersionLock() { + terminal.expectCommand("yum --quiet versionlock list 2>&1", + 0, + "Repository chef_rpms-release is listed more than once in the configuration\n" + + "0:chef-12.21.1-1.el7.*\n" + + "0:package-1-0.1-8.el7.*\n"); + + terminal.expectCommand("yum versionlock delete \"0:package-1-0.1-8.el7.*\" 2>&1"); + + terminal.expectCommand( + "yum install --assumeyes \"0:package-1-0.10-654.el7.*\" 2>&1", + 0, + "Nothing to do\n"); + + terminal.expectCommand("yum versionlock add \"0:package-1-0.10-654.el7.*\" 2>&1"); + + assertTrue(yum + .install("0:package-1-0.10-654.el7.*") + .lockVersion() + .converge(taskContext)); + } + + @Test + public void testWithExistingVersionLock() { + terminal.expectCommand("yum --quiet versionlock list 2>&1", + 0, + "Repository chef_rpms-release is listed more than once in the configuration\n" + + "0:chef-12.21.1-1.el7.*\n" + + "0:package-1-0.10-654.el7.*\n"); + terminal.expectCommand( + "yum install --assumeyes \"0:package-1-0.10-654.el7.*\" 2>&1", + 0, + "Nothing to do\n"); + + assertFalse(yum + .install("0:package-1-0.10-654.el7.*") + .lockVersion() + .converge(taskContext)); + } + @Test(expected = ChildProcessFailureException.class) public void testFailedInstall() { terminal.expectCommand( |