JCLOUDS-707: Improve SSH key pair creation The SSH key pair creation has been improved to avoid having multiple public keys with the same fingerprint:
* Create the SSH key pairs before creating the nodes. * Only auto-generate key pairs if the user has not configured a public key. * If the user has provided a public key, try to find one with the same fingerprint and reuse it. * To make the migration to v2 easier, now the fingerprint of the public key is used for the name of the key pair. Project: http://git-wip-us.apache.org/repos/asf/jclouds-labs/repo Commit: http://git-wip-us.apache.org/repos/asf/jclouds-labs/commit/3eaa646d Tree: http://git-wip-us.apache.org/repos/asf/jclouds-labs/tree/3eaa646d Diff: http://git-wip-us.apache.org/repos/asf/jclouds-labs/diff/3eaa646d Branch: refs/heads/master Commit: 3eaa646dc29b6a85b4fc35f6539a61173a2d3d9e Parents: 927ad1d Author: Ignasi Barrera <[email protected]> Authored: Mon Sep 8 12:33:56 2014 +0200 Committer: Ignasi Barrera <[email protected]> Committed: Wed Sep 24 14:57:41 2014 +0200 ---------------------------------------------------------------------- ...DigitalOceanComputeServiceContextModule.java | 3 + .../options/DigitalOceanTemplateOptions.java | 57 ++--- .../strategy/CreateKeyPairsThenCreateNodes.java | 206 +++++++++++++++++++ .../DigitalOceanComputeServiceAdapter.java | 48 +---- .../config/DigitalOceanHttpApiModule.java | 8 + .../config/DigitalOceanParserModule.java | 84 +++++--- .../predicates/SameFingerprint.java | 61 ++++++ .../digitalocean/strategy/ListSshKeys.java | 80 +++++++ .../predicates/SameFingerprintTest.java | 72 +++++++ .../strategy/ListSshKeysLiveTest.java | 83 ++++++++ 10 files changed, 603 insertions(+), 99 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/jclouds-labs/blob/3eaa646d/digitalocean/src/main/java/org/jclouds/digitalocean/compute/config/DigitalOceanComputeServiceContextModule.java ---------------------------------------------------------------------- diff --git a/digitalocean/src/main/java/org/jclouds/digitalocean/compute/config/DigitalOceanComputeServiceContextModule.java b/digitalocean/src/main/java/org/jclouds/digitalocean/compute/config/DigitalOceanComputeServiceContextModule.java index 5204bed..aa68f91 100644 --- a/digitalocean/src/main/java/org/jclouds/digitalocean/compute/config/DigitalOceanComputeServiceContextModule.java +++ b/digitalocean/src/main/java/org/jclouds/digitalocean/compute/config/DigitalOceanComputeServiceContextModule.java @@ -35,6 +35,7 @@ import org.jclouds.compute.functions.TemplateOptionsToStatement; import org.jclouds.compute.options.TemplateOptions; import org.jclouds.compute.reference.ComputeServiceConstants.PollPeriod; import org.jclouds.compute.reference.ComputeServiceConstants.Timeouts; +import org.jclouds.compute.strategy.CreateNodesInGroupThenAddToSet; import org.jclouds.digitalocean.DigitalOceanApi; import org.jclouds.digitalocean.compute.extensions.DigitalOceanImageExtension; import org.jclouds.digitalocean.compute.functions.DropletStatusToStatus; @@ -44,6 +45,7 @@ import org.jclouds.digitalocean.compute.functions.RegionToLocation; import org.jclouds.digitalocean.compute.functions.SizeToHardware; import org.jclouds.digitalocean.compute.functions.TemplateOptionsToStatementWithoutPublicKey; import org.jclouds.digitalocean.compute.options.DigitalOceanTemplateOptions; +import org.jclouds.digitalocean.compute.strategy.CreateKeyPairsThenCreateNodes; import org.jclouds.digitalocean.compute.strategy.DigitalOceanComputeServiceAdapter; import org.jclouds.digitalocean.domain.Droplet; import org.jclouds.digitalocean.domain.Event; @@ -88,6 +90,7 @@ public class DigitalOceanComputeServiceContextModule extends install(new LocationsFromComputeServiceAdapterModule<Droplet, Size, Image, Region>() { }); + bind(CreateNodesInGroupThenAddToSet.class).to(CreateKeyPairsThenCreateNodes.class); bind(TemplateOptions.class).to(DigitalOceanTemplateOptions.class); bind(TemplateOptionsToStatement.class).to(TemplateOptionsToStatementWithoutPublicKey.class); http://git-wip-us.apache.org/repos/asf/jclouds-labs/blob/3eaa646d/digitalocean/src/main/java/org/jclouds/digitalocean/compute/options/DigitalOceanTemplateOptions.java ---------------------------------------------------------------------- diff --git a/digitalocean/src/main/java/org/jclouds/digitalocean/compute/options/DigitalOceanTemplateOptions.java b/digitalocean/src/main/java/org/jclouds/digitalocean/compute/options/DigitalOceanTemplateOptions.java index 3a25388..bc3dade 100644 --- a/digitalocean/src/main/java/org/jclouds/digitalocean/compute/options/DigitalOceanTemplateOptions.java +++ b/digitalocean/src/main/java/org/jclouds/digitalocean/compute/options/DigitalOceanTemplateOptions.java @@ -16,6 +16,7 @@ */ package org.jclouds.digitalocean.compute.options; +import static com.google.common.base.Objects.equal; import static com.google.common.base.Preconditions.checkNotNull; import java.util.Set; @@ -23,6 +24,7 @@ import java.util.Set; import org.jclouds.compute.options.TemplateOptions; import com.google.common.base.MoreObjects.ToStringHelper; +import com.google.common.base.Objects; import com.google.common.collect.ImmutableSet; /** @@ -33,6 +35,7 @@ public class DigitalOceanTemplateOptions extends TemplateOptions implements Clon private Set<Integer> sshKeyIds = ImmutableSet.of(); private Boolean privateNetworking; private Boolean backupsEnabled; + private boolean autoCreateKeyPair = true; /** * Enables a private network interface if the region supports private networking. @@ -58,6 +61,14 @@ public class DigitalOceanTemplateOptions extends TemplateOptions implements Clon return this; } + /** + * Sets whether an SSH key pair should be created automatically. + */ + public DigitalOceanTemplateOptions autoCreateKeyPair(boolean autoCreateKeyPair) { + this.autoCreateKeyPair = autoCreateKeyPair; + return this; + } + public Set<Integer> getSshKeyIds() { return sshKeyIds; } @@ -70,6 +81,10 @@ public class DigitalOceanTemplateOptions extends TemplateOptions implements Clon return backupsEnabled; } + public boolean getAutoCreateKeyPair() { + return autoCreateKeyPair; + } + @Override public DigitalOceanTemplateOptions clone() { DigitalOceanTemplateOptions options = new DigitalOceanTemplateOptions(); @@ -88,18 +103,14 @@ public class DigitalOceanTemplateOptions extends TemplateOptions implements Clon if (backupsEnabled != null) { eTo.backupsEnabled(backupsEnabled); } + eTo.autoCreateKeyPair(autoCreateKeyPair); eTo.sshKeyIds(sshKeyIds); } } @Override public int hashCode() { - final int prime = 31; - int result = super.hashCode(); - result = prime * result + (backupsEnabled == null ? 0 : backupsEnabled.hashCode()); - result = prime * result + (privateNetworking == null ? 0 : privateNetworking.hashCode()); - result = prime * result + (sshKeyIds == null ? 0 : sshKeyIds.hashCode()); - return result; + return Objects.hashCode(super.hashCode(), backupsEnabled, privateNetworking, autoCreateKeyPair, sshKeyIds); } @Override @@ -114,28 +125,9 @@ public class DigitalOceanTemplateOptions extends TemplateOptions implements Clon return false; } DigitalOceanTemplateOptions other = (DigitalOceanTemplateOptions) obj; - if (backupsEnabled == null) { - if (other.backupsEnabled != null) { - return false; - } - } else if (!backupsEnabled.equals(other.backupsEnabled)) { - return false; - } - if (privateNetworking == null) { - if (other.privateNetworking != null) { - return false; - } - } else if (!privateNetworking.equals(other.privateNetworking)) { - return false; - } - if (sshKeyIds == null) { - if (other.sshKeyIds != null) { - return false; - } - } else if (!sshKeyIds.equals(other.sshKeyIds)) { - return false; - } - return true; + return super.equals(other) && equal(this.backupsEnabled, other.backupsEnabled) + && equal(this.privateNetworking, other.privateNetworking) + && equal(this.autoCreateKeyPair, other.autoCreateKeyPair) && equal(this.sshKeyIds, other.sshKeyIds); } @Override @@ -146,6 +138,7 @@ public class DigitalOceanTemplateOptions extends TemplateOptions implements Clon if (!sshKeyIds.isEmpty()) { toString.add("sshKeyIds", sshKeyIds); } + toString.add("shouldAutoCreateKeyPair", autoCreateKeyPair); return toString; } @@ -174,5 +167,13 @@ public class DigitalOceanTemplateOptions extends TemplateOptions implements Clon DigitalOceanTemplateOptions options = new DigitalOceanTemplateOptions(); return options.sshKeyIds(sshKeyIds); } + + /** + * @see DigitalOceanTemplateOptions#autoCreateKeyPair + */ + public static DigitalOceanTemplateOptions shouldAutoCreateKeyPair(boolean shouldAutoCreateKeyPair) { + DigitalOceanTemplateOptions options = new DigitalOceanTemplateOptions(); + return options.autoCreateKeyPair(shouldAutoCreateKeyPair); + } } } http://git-wip-us.apache.org/repos/asf/jclouds-labs/blob/3eaa646d/digitalocean/src/main/java/org/jclouds/digitalocean/compute/strategy/CreateKeyPairsThenCreateNodes.java ---------------------------------------------------------------------- diff --git a/digitalocean/src/main/java/org/jclouds/digitalocean/compute/strategy/CreateKeyPairsThenCreateNodes.java b/digitalocean/src/main/java/org/jclouds/digitalocean/compute/strategy/CreateKeyPairsThenCreateNodes.java new file mode 100644 index 0000000..b9da3dc --- /dev/null +++ b/digitalocean/src/main/java/org/jclouds/digitalocean/compute/strategy/CreateKeyPairsThenCreateNodes.java @@ -0,0 +1,206 @@ +/* + * 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.jclouds.digitalocean.compute.strategy; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.collect.Iterables.tryFind; + +import java.security.PublicKey; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.annotation.Resource; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import org.jclouds.Constants; +import org.jclouds.compute.config.CustomizationResponse; +import org.jclouds.compute.domain.NodeMetadata; +import org.jclouds.compute.domain.Template; +import org.jclouds.compute.functions.GroupNamingConvention; +import org.jclouds.compute.reference.ComputeServiceConstants; +import org.jclouds.compute.strategy.CreateNodeWithGroupEncodedIntoName; +import org.jclouds.compute.strategy.CustomizeNodeAndAddToGoodMapOrPutExceptionIntoBadMap; +import org.jclouds.compute.strategy.ListNodesStrategy; +import org.jclouds.compute.strategy.impl.CreateNodesWithGroupEncodedIntoNameThenAddToSet; +import org.jclouds.digitalocean.DigitalOceanApi; +import org.jclouds.digitalocean.compute.options.DigitalOceanTemplateOptions; +import org.jclouds.digitalocean.domain.SshKey; +import org.jclouds.digitalocean.predicates.SameFingerprint; +import org.jclouds.digitalocean.strategy.ListSshKeys; +import org.jclouds.logging.Logger; +import org.jclouds.ssh.SshKeyPairGenerator; + +import com.google.common.base.Function; +import com.google.common.base.Optional; +import com.google.common.base.Strings; +import com.google.common.collect.Multimap; +import com.google.common.collect.Sets; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; + +@Singleton +public class CreateKeyPairsThenCreateNodes extends CreateNodesWithGroupEncodedIntoNameThenAddToSet { + + @Resource + @Named(ComputeServiceConstants.COMPUTE_LOGGER) + protected Logger logger = Logger.NULL; + + private final DigitalOceanApi api; + private final SshKeyPairGenerator keyGenerator; + private final ListSshKeys listSshKeys; + private final Function<String, PublicKey> sshKeyToPublicKey; + + @Inject + protected CreateKeyPairsThenCreateNodes( + CreateNodeWithGroupEncodedIntoName addNodeWithGroupStrategy, + ListNodesStrategy listNodesStrategy, + GroupNamingConvention.Factory namingConvention, + @Named(Constants.PROPERTY_USER_THREADS) ListeningExecutorService userExecutor, + CustomizeNodeAndAddToGoodMapOrPutExceptionIntoBadMap.Factory customizeNodeAndAddToGoodMapOrPutExceptionIntoBadMapFactory, + DigitalOceanApi api, SshKeyPairGenerator keyGenerator, ListSshKeys.Factory listSshKeysFactory, + Function<String, PublicKey> sshKeyToPublicKey) { + super(addNodeWithGroupStrategy, listNodesStrategy, namingConvention, userExecutor, + customizeNodeAndAddToGoodMapOrPutExceptionIntoBadMapFactory); + this.api = checkNotNull(api, "api cannot be null"); + this.keyGenerator = checkNotNull(keyGenerator, "keyGenerator cannot be null"); + checkNotNull(listSshKeysFactory, "listSshKeysFactory cannot be null"); + checkNotNull(userExecutor, "userExecutor cannot be null"); + this.listSshKeys = listSshKeysFactory.create(userExecutor); + this.sshKeyToPublicKey = checkNotNull(sshKeyToPublicKey, "sshKeyToPublicKey cannot be null"); + } + + @Override + public Map<?, ListenableFuture<Void>> execute(String group, int count, Template template, + Set<NodeMetadata> goodNodes, Map<NodeMetadata, Exception> badNodes, + Multimap<NodeMetadata, CustomizationResponse> customizationResponses) { + + DigitalOceanTemplateOptions options = template.getOptions().as(DigitalOceanTemplateOptions.class); + Set<Integer> generatedSshKeyIds = Sets.newHashSet(); + + // If no key has been configured and the auto-create option is set, then generate a key pair + if (options.getSshKeyIds().isEmpty() && options.getAutoCreateKeyPair() + && Strings.isNullOrEmpty(options.getPublicKey())) { + generateKeyPairAndAddKeyToSet(options, generatedSshKeyIds); + } + + // If there is a script to run in the node, make sure a pivate key has been configured so jclouds will be able to + // access the node + if (options.getRunScript() != null) { + checkArgument(!Strings.isNullOrEmpty(options.getLoginPrivateKey()), + "no private key configured for: %s; please use options.overrideLoginPrivateKey(rsa_private_text)", group); + } + + // If there is a key configured, then make sure there is a key pair for it + if (!Strings.isNullOrEmpty(options.getPublicKey())) { + createKeyPairForPublicKeyInOptionsAndAddToSet(options, generatedSshKeyIds); + } + + // Set all keys (the provided and the auto-generated) in the options object so the + // DigitalOceanComputeServiceAdapter adds them all + options.sshKeyIds(Sets.union(generatedSshKeyIds, options.getSshKeyIds())); + + Map<?, ListenableFuture<Void>> responses = super.execute(group, count, template, goodNodes, badNodes, + customizationResponses); + + // Key pairs in DigitalOcean are only required to create the Droplets. They aren't used anymore so it is better + // to delete the auto-generated key pairs at this point where we know exactly which ones have been + // auto-generated by jclouds. + registerAutoGeneratedKeyPairCleanupCallbacks(responses, generatedSshKeyIds); + + return responses; + } + + private void createKeyPairForPublicKeyInOptionsAndAddToSet(DigitalOceanTemplateOptions options, + Set<Integer> generatedSshKeyIds) { + logger.debug(">> checking if the key pair already exists..."); + + PublicKey userKey = sshKeyToPublicKey.apply(options.getPublicKey()); + Optional<SshKey> key = tryFind(listSshKeys.execute(), new SameFingerprint(userKey)); + + if (!key.isPresent()) { + logger.debug(">> key pair not found. creating a new one..."); + + String userFingerprint = SameFingerprint.computeFingerprint(userKey); + SshKey newKey = api.getKeyPairApi().create(userFingerprint, options.getPublicKey()); + + generatedSshKeyIds.add(newKey.getId()); + logger.debug(">> key pair created! %s", newKey); + } else { + logger.debug(">> key pair found! %s", key.get()); + generatedSshKeyIds.add(key.get().getId()); + } + } + + private void generateKeyPairAndAddKeyToSet(DigitalOceanTemplateOptions options, Set<Integer> generatedSshKeyIds) { + logger.debug(">> creating default keypair for node..."); + + Map<String, String> defaultKeys = keyGenerator.get(); + + PublicKey defaultPublicKey = sshKeyToPublicKey.apply(defaultKeys.get("public")); + String fingerprint = SameFingerprint.computeFingerprint(defaultPublicKey); + SshKey defaultKey = api.getKeyPairApi().create(fingerprint, defaultKeys.get("public")); + + generatedSshKeyIds.add(defaultKey.getId()); + + logger.debug(">> keypair created! %s", defaultKey); + + // If a private key has not been explicitly set, configure the auto-generated one + if (Strings.isNullOrEmpty(options.getLoginPrivateKey())) { + options.overrideLoginPrivateKey(defaultKeys.get("private")); + } + } + + private void registerAutoGeneratedKeyPairCleanupCallbacks(Map<?, ListenableFuture<Void>> responses, + final Set<Integer> generatedSshKeyIds) { + // The Futures.allAsList fails immediately if some of the futures fail. The Futures.successfulAsList, however, + // returns a list containing the results or 'null' for those futures that failed. We want to wait for all them + // (even if they fail), so better use the latter form. + ListenableFuture<List<Void>> aggregatedResponses = Futures.successfulAsList(responses.values()); + + // Key pairs must be cleaned up after all futures completed (even if some failed). + Futures.addCallback(aggregatedResponses, new FutureCallback<List<Void>>() { + @Override + public void onSuccess(List<Void> result) { + cleanupAutoGeneratedKeyPairs(generatedSshKeyIds); + } + + @Override + public void onFailure(Throwable t) { + cleanupAutoGeneratedKeyPairs(generatedSshKeyIds); + } + + private void cleanupAutoGeneratedKeyPairs(Set<Integer> generatedSshKeyIds) { + logger.debug(">> cleaning up auto-generated key pairs..."); + for (Integer sshKeyId : generatedSshKeyIds) { + try { + api.getKeyPairApi().delete(sshKeyId); + } catch (Exception ex) { + logger.warn(">> could not delete key pair %s: %s", sshKeyId, ex.getMessage()); + } + } + } + + }, userExecutor); + } + +} http://git-wip-us.apache.org/repos/asf/jclouds-labs/blob/3eaa646d/digitalocean/src/main/java/org/jclouds/digitalocean/compute/strategy/DigitalOceanComputeServiceAdapter.java ---------------------------------------------------------------------- diff --git a/digitalocean/src/main/java/org/jclouds/digitalocean/compute/strategy/DigitalOceanComputeServiceAdapter.java b/digitalocean/src/main/java/org/jclouds/digitalocean/compute/strategy/DigitalOceanComputeServiceAdapter.java index e036108..db1704c 100644 --- a/digitalocean/src/main/java/org/jclouds/digitalocean/compute/strategy/DigitalOceanComputeServiceAdapter.java +++ b/digitalocean/src/main/java/org/jclouds/digitalocean/compute/strategy/DigitalOceanComputeServiceAdapter.java @@ -24,8 +24,6 @@ import static org.jclouds.compute.config.ComputeServiceProperties.TIMEOUT_NODE_S import static org.jclouds.compute.config.ComputeServiceProperties.TIMEOUT_NODE_TERMINATED; import static org.jclouds.digitalocean.compute.util.LocationNamingUtils.extractRegionId; -import java.util.Map; - import javax.annotation.Resource; import javax.inject.Inject; import javax.inject.Named; @@ -40,14 +38,11 @@ import org.jclouds.digitalocean.domain.DropletCreation; import org.jclouds.digitalocean.domain.Image; import org.jclouds.digitalocean.domain.Region; import org.jclouds.digitalocean.domain.Size; -import org.jclouds.digitalocean.domain.SshKey; import org.jclouds.digitalocean.domain.options.CreateDropletOptions; import org.jclouds.domain.LoginCredentials; import org.jclouds.logging.Logger; -import org.jclouds.ssh.SshKeyPairGenerator; import com.google.common.base.Predicate; -import com.google.common.base.Strings; import com.google.common.primitives.Ints; /** @@ -60,18 +55,16 @@ public class DigitalOceanComputeServiceAdapter implements ComputeServiceAdapter< protected Logger logger = Logger.NULL; private final DigitalOceanApi api; - private final SshKeyPairGenerator keyGenerator; private final Predicate<Integer> nodeRunningPredicate; private final Predicate<Integer> nodeStoppedPredicate; private final Predicate<Integer> nodeTerminatedPredicate; @Inject - DigitalOceanComputeServiceAdapter(DigitalOceanApi api, SshKeyPairGenerator keyGenerator, + DigitalOceanComputeServiceAdapter(DigitalOceanApi api, @Named(TIMEOUT_NODE_RUNNING) Predicate<Integer> nodeRunningPredicate, @Named(TIMEOUT_NODE_SUSPENDED) Predicate<Integer> nodeStoppedPredicate, @Named(TIMEOUT_NODE_TERMINATED) Predicate<Integer> nodeTerminatedPredicate) { this.api = checkNotNull(api, "api cannot be null"); - this.keyGenerator = checkNotNull(keyGenerator, "keyGenerator cannot be null"); this.nodeRunningPredicate = checkNotNull(nodeRunningPredicate, "nodeRunningPredicate cannot be null"); this.nodeStoppedPredicate = checkNotNull(nodeStoppedPredicate, "nodeStoppedPredicate cannot be null"); this.nodeTerminatedPredicate = checkNotNull(nodeTerminatedPredicate, "nodeTerminatedPredicate cannot be null"); @@ -83,23 +76,6 @@ public class DigitalOceanComputeServiceAdapter implements ComputeServiceAdapter< DigitalOceanTemplateOptions templateOptions = template.getOptions().as(DigitalOceanTemplateOptions.class); CreateDropletOptions.Builder options = CreateDropletOptions.builder(); - // Create a default keypair for the node so it has a known private key - Map<String, String> defaultKeys = keyGenerator.get(); - logger.debug(">> creating default keypair for node..."); - SshKey defaultKey = api.getKeyPairApi().create(name, defaultKeys.get("public")); - logger.debug(">> keypair created! %s", defaultKey); - options.addSshKeyId(defaultKey.getId()); - - // Check if there is a key to authorize in the portable options - if (!Strings.isNullOrEmpty(template.getOptions().getPublicKey())) { - logger.debug(">> creating user keypair for node..."); - // The DigitalOcean API accepts multiple key pairs with the same name. It will be useful to identify all - // keypairs associated with the node when it comes to destroy it - SshKey key = api.getKeyPairApi().create(name, template.getOptions().getPublicKey()); - logger.debug(">> keypair created! %s", key); - options.addSshKeyId(key.getId()); - } - // DigitalOcean specific options if (!templateOptions.getSshKeyIds().isEmpty()) { options.addSshKeyIds(templateOptions.getSshKeyIds()); @@ -124,7 +100,7 @@ public class DigitalOceanComputeServiceAdapter implements ComputeServiceAdapter< Droplet droplet = api.getDropletApi().get(dropletCreation.getId()); LoginCredentials defaultCredentials = LoginCredentials.builder().user("root") - .privateKey(defaultKeys.get("private")).build(); + .privateKey(templateOptions.getLoginPrivateKey()).build(); return new NodeAndInitialCredentials<Droplet>(droplet, String.valueOf(droplet.getId()), defaultCredentials); } @@ -174,30 +150,10 @@ public class DigitalOceanComputeServiceAdapter implements ComputeServiceAdapter< @Override public void destroyNode(String id) { - Droplet droplet = api.getDropletApi().get(Integer.parseInt(id)); - final String nodeName = droplet.getName(); - // We have to wait here, as the api does not properly populate the state // but fails if there is a pending event int event = api.getDropletApi().destroy(Integer.parseInt(id), true); nodeTerminatedPredicate.apply(event); - - // Destroy the keypairs created for the node - Iterable<SshKey> keys = filter(api.getKeyPairApi().list(), new Predicate<SshKey>() { - @Override - public boolean apply(SshKey input) { - return input.getName().equals(nodeName); - } - }); - - for (SshKey key : keys) { - try { - logger.info(">> deleting keypair %s...", key); - api.getKeyPairApi().delete(key.getId()); - } catch (RuntimeException ex) { - logger.warn(ex, ">> could not delete keypair %s. You can safely delete this key pair manually", key); - } - } } @Override http://git-wip-us.apache.org/repos/asf/jclouds-labs/blob/3eaa646d/digitalocean/src/main/java/org/jclouds/digitalocean/config/DigitalOceanHttpApiModule.java ---------------------------------------------------------------------- diff --git a/digitalocean/src/main/java/org/jclouds/digitalocean/config/DigitalOceanHttpApiModule.java b/digitalocean/src/main/java/org/jclouds/digitalocean/config/DigitalOceanHttpApiModule.java index ed83c08..3657198 100644 --- a/digitalocean/src/main/java/org/jclouds/digitalocean/config/DigitalOceanHttpApiModule.java +++ b/digitalocean/src/main/java/org/jclouds/digitalocean/config/DigitalOceanHttpApiModule.java @@ -19,6 +19,7 @@ package org.jclouds.digitalocean.config; import org.jclouds.digitalocean.DigitalOceanApi; import org.jclouds.digitalocean.handlers.DigitalOceanErrorHandler; import org.jclouds.digitalocean.http.ResponseStatusFromPayloadHttpCommandExecutorService; +import org.jclouds.digitalocean.strategy.ListSshKeys; import org.jclouds.http.HttpCommandExecutorService; import org.jclouds.http.HttpErrorHandler; import org.jclouds.http.annotation.ClientError; @@ -31,6 +32,7 @@ import org.jclouds.rest.config.HttpApiModule; import com.google.inject.AbstractModule; import com.google.inject.Scopes; +import com.google.inject.assistedinject.FactoryModuleBuilder; /** * Configures the DigitalOcean connection. @@ -39,6 +41,12 @@ import com.google.inject.Scopes; public class DigitalOceanHttpApiModule extends HttpApiModule<DigitalOceanApi> { @Override + protected void configure() { + super.configure(); + install(new FactoryModuleBuilder().build(ListSshKeys.Factory.class)); + } + + @Override protected void bindErrorHandlers() { bind(HttpErrorHandler.class).annotatedWith(Redirection.class).to(DigitalOceanErrorHandler.class); bind(HttpErrorHandler.class).annotatedWith(ClientError.class).to(DigitalOceanErrorHandler.class); http://git-wip-us.apache.org/repos/asf/jclouds-labs/blob/3eaa646d/digitalocean/src/main/java/org/jclouds/digitalocean/config/DigitalOceanParserModule.java ---------------------------------------------------------------------- diff --git a/digitalocean/src/main/java/org/jclouds/digitalocean/config/DigitalOceanParserModule.java b/digitalocean/src/main/java/org/jclouds/digitalocean/config/DigitalOceanParserModule.java index e50cd3f..9170c8e 100644 --- a/digitalocean/src/main/java/org/jclouds/digitalocean/config/DigitalOceanParserModule.java +++ b/digitalocean/src/main/java/org/jclouds/digitalocean/config/DigitalOceanParserModule.java @@ -17,6 +17,7 @@ package org.jclouds.digitalocean.config; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Throwables.propagate; import static com.google.common.collect.Iterables.get; import static com.google.common.collect.Iterables.size; @@ -34,6 +35,7 @@ import java.security.spec.InvalidKeySpecException; import java.security.spec.RSAPublicKeySpec; import java.util.Map; +import javax.inject.Inject; import javax.inject.Singleton; import org.jclouds.digitalocean.ssh.DSAKeys; @@ -41,6 +43,7 @@ import org.jclouds.json.config.GsonModule.DateAdapter; import org.jclouds.json.config.GsonModule.Iso8601DateAdapter; import org.jclouds.ssh.SshKeys; +import com.google.common.base.Function; import com.google.common.base.Splitter; import com.google.common.collect.ImmutableMap; import com.google.gson.TypeAdapter; @@ -62,40 +65,71 @@ public class DigitalOceanParserModule extends AbstractModule { @Singleton public static class SshPublicKeyAdapter extends TypeAdapter<PublicKey> { + private final Function<PublicKey, String> publicKeyToSshKey; + private final Function<String, PublicKey> sshKeyToPublicKey; + + @Inject + public SshPublicKeyAdapter(Function<PublicKey, String> publicKeyToSshKey, + Function<String, PublicKey> sshKeyToPublicKey) { + this.publicKeyToSshKey = checkNotNull(publicKeyToSshKey, "publicKeyToSshKey cannot be null"); + this.sshKeyToPublicKey = checkNotNull(sshKeyToPublicKey, "sshKeyToPublicKey cannot be null"); + } + @Override public void write(JsonWriter out, PublicKey value) throws IOException { - if (value instanceof RSAPublicKey) { - out.value(SshKeys.encodeAsOpenSSH((RSAPublicKey) value)); - } else if (value instanceof DSAPublicKey) { - out.value(DSAKeys.encodeAsOpenSSH((DSAPublicKey) value)); - } else { - throw new IllegalArgumentException("Only RSA and DSA keys are supported"); - } + out.value(publicKeyToSshKey.apply(value)); } @Override public PublicKey read(JsonReader in) throws IOException { - String input = in.nextString().trim(); - Iterable<String> parts = Splitter.on(' ').split(input); - checkArgument(size(parts) >= 2, "bad format, should be: [ssh-rsa|ssh-dss] AAAAB3..."); - String type = get(parts, 0); - - try { - if ("ssh-rsa".equals(type)) { - RSAPublicKeySpec spec = SshKeys.publicKeySpecFromOpenSSH(input); - return KeyFactory.getInstance("RSA").generatePublic(spec); - } else if ("ssh-dss".equals(type)) { - DSAPublicKeySpec spec = DSAKeys.publicKeySpecFromOpenSSH(input); - return KeyFactory.getInstance("DSA").generatePublic(spec); + return sshKeyToPublicKey.apply(in.nextString().trim()); + } + } + + @Provides + @Singleton + public Function<PublicKey, String> publicKeyToSshKey() { + return new Function<PublicKey, String>() { + @Override + public String apply(PublicKey input) { + if (input instanceof RSAPublicKey) { + return SshKeys.encodeAsOpenSSH((RSAPublicKey) input); + } else if (input instanceof DSAPublicKey) { + return DSAKeys.encodeAsOpenSSH((DSAPublicKey) input); } else { - throw new IllegalArgumentException("bad format, should be: [ssh-rsa|ssh-dss] AAAAB3..."); + throw new IllegalArgumentException("Only RSA and DSA keys are supported"); } - } catch (InvalidKeySpecException ex) { - throw propagate(ex); - } catch (NoSuchAlgorithmException ex) { - throw propagate(ex); } - } + }; + } + + @Provides + @Singleton + public Function<String, PublicKey> sshKeyToPublicKey() { + return new Function<String, PublicKey>() { + @Override + public PublicKey apply(String input) { + Iterable<String> parts = Splitter.on(' ').split(input); + checkArgument(size(parts) >= 2, "bad format, should be: [ssh-rsa|ssh-dss] AAAAB3..."); + String type = get(parts, 0); + + try { + if ("ssh-rsa".equals(type)) { + RSAPublicKeySpec spec = SshKeys.publicKeySpecFromOpenSSH(input); + return KeyFactory.getInstance("RSA").generatePublic(spec); + } else if ("ssh-dss".equals(type)) { + DSAPublicKeySpec spec = DSAKeys.publicKeySpecFromOpenSSH(input); + return KeyFactory.getInstance("DSA").generatePublic(spec); + } else { + throw new IllegalArgumentException("bad format, should be: [ssh-rsa|ssh-dss] AAAAB3..."); + } + } catch (InvalidKeySpecException ex) { + throw propagate(ex); + } catch (NoSuchAlgorithmException ex) { + throw propagate(ex); + } + } + }; } @Provides http://git-wip-us.apache.org/repos/asf/jclouds-labs/blob/3eaa646d/digitalocean/src/main/java/org/jclouds/digitalocean/predicates/SameFingerprint.java ---------------------------------------------------------------------- diff --git a/digitalocean/src/main/java/org/jclouds/digitalocean/predicates/SameFingerprint.java b/digitalocean/src/main/java/org/jclouds/digitalocean/predicates/SameFingerprint.java new file mode 100644 index 0000000..f7d414a --- /dev/null +++ b/digitalocean/src/main/java/org/jclouds/digitalocean/predicates/SameFingerprint.java @@ -0,0 +1,61 @@ +/* + * 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.jclouds.digitalocean.predicates; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.security.PublicKey; +import java.security.interfaces.DSAPublicKey; +import java.security.interfaces.RSAPublicKey; + +import org.jclouds.digitalocean.domain.SshKey; +import org.jclouds.digitalocean.ssh.DSAKeys; +import org.jclouds.ssh.SshKeys; + +import com.google.common.base.Predicate; + +/** + * Predicate to compare SSH keys by fingerprint. + */ +public class SameFingerprint implements Predicate<SshKey> { + + public final String fingerprint; + + public SameFingerprint(PublicKey key) { + this.fingerprint = computeFingerprint(checkNotNull(key, "key cannot be null")); + } + + @Override + public boolean apply(SshKey key) { + checkNotNull(key, "key cannot be null"); + checkNotNull(key.getPublicKey(), "public key cannot be null"); + return fingerprint.equals(computeFingerprint(key.getPublicKey())); + } + + public static String computeFingerprint(PublicKey key) { + if (key instanceof RSAPublicKey) { + RSAPublicKey rsaKey = (RSAPublicKey) key; + return SshKeys.fingerprint(rsaKey.getPublicExponent(), rsaKey.getModulus()); + } else if (key instanceof DSAPublicKey) { + DSAPublicKey dsaKey = (DSAPublicKey) key; + return DSAKeys.fingerprint(dsaKey.getParams().getP(), dsaKey.getParams().getQ(), dsaKey.getParams().getG(), + dsaKey.getY()); + } else { + throw new IllegalArgumentException("Only RSA and DSA keys are supported"); + } + } +} http://git-wip-us.apache.org/repos/asf/jclouds-labs/blob/3eaa646d/digitalocean/src/main/java/org/jclouds/digitalocean/strategy/ListSshKeys.java ---------------------------------------------------------------------- diff --git a/digitalocean/src/main/java/org/jclouds/digitalocean/strategy/ListSshKeys.java b/digitalocean/src/main/java/org/jclouds/digitalocean/strategy/ListSshKeys.java new file mode 100644 index 0000000..a51cb60 --- /dev/null +++ b/digitalocean/src/main/java/org/jclouds/digitalocean/strategy/ListSshKeys.java @@ -0,0 +1,80 @@ +/* + * 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.jclouds.digitalocean.strategy; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.collect.Iterables.transform; +import static com.google.common.util.concurrent.Futures.allAsList; +import static com.google.common.util.concurrent.Futures.getUnchecked; + +import java.util.List; +import java.util.concurrent.Callable; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.jclouds.digitalocean.DigitalOceanApi; +import org.jclouds.digitalocean.domain.SshKey; +import org.jclouds.digitalocean.features.KeyPairApi; + +import com.google.common.base.Function; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.inject.assistedinject.Assisted; + +/** + * The {@link org.jclouds.digitalocean.features.KeyPairApi} only returns the id and name of each key but not the actual + * public key when listing all keys. + * <p> + * This strategy provides a helper to get all the keys with all details populated. + */ +@Singleton +public class ListSshKeys { + + public interface Factory { + ListSshKeys create(ListeningExecutorService executor); + } + + private final KeyPairApi keyPairApi; + private final ListeningExecutorService executor; + + @Inject + ListSshKeys(DigitalOceanApi api, @Assisted ListeningExecutorService executor) { + checkNotNull(api, "api cannot be null"); + this.executor = checkNotNull(executor, "executor cannot be null"); + this.keyPairApi = api.getKeyPairApi(); + } + + public List<SshKey> execute() { + List<SshKey> keys = keyPairApi.list(); + + ListenableFuture<List<SshKey>> futures = allAsList(transform(keys, + new Function<SshKey, ListenableFuture<SshKey>>() { + @Override + public ListenableFuture<SshKey> apply(final SshKey input) { + return executor.submit(new Callable<SshKey>() { + @Override + public SshKey call() throws Exception { + return keyPairApi.get(input.getId()); + } + }); + } + })); + + return getUnchecked(futures); + } +} http://git-wip-us.apache.org/repos/asf/jclouds-labs/blob/3eaa646d/digitalocean/src/test/java/org/jclouds/digitalocean/predicates/SameFingerprintTest.java ---------------------------------------------------------------------- diff --git a/digitalocean/src/test/java/org/jclouds/digitalocean/predicates/SameFingerprintTest.java b/digitalocean/src/test/java/org/jclouds/digitalocean/predicates/SameFingerprintTest.java new file mode 100644 index 0000000..be204a6 --- /dev/null +++ b/digitalocean/src/test/java/org/jclouds/digitalocean/predicates/SameFingerprintTest.java @@ -0,0 +1,72 @@ +/* + * 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.jclouds.digitalocean.predicates; + +import static org.testng.Assert.assertTrue; + +import java.io.IOException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; + +import org.jclouds.digitalocean.domain.SshKey; +import org.jclouds.digitalocean.ssh.DSAKeys; +import org.jclouds.ssh.SshKeys; +import org.jclouds.util.Strings2; +import org.testng.annotations.Test; + +/** + * Unit tests for the {@link SameFingerprint} class. + */ +@Test(groups = "unit", testName = "SameFingerprintTest") +public class SameFingerprintTest { + + @Test(expectedExceptions = NullPointerException.class, expectedExceptionsMessageRegExp = "key cannot be null") + public void testPublicKeyCannotBeNull() { + new SameFingerprint(null); + } + + @Test(expectedExceptions = NullPointerException.class, expectedExceptionsMessageRegExp = "public key cannot be null") + public void testPublicKeyInSshKeyCannotBeNull() throws IOException, InvalidKeySpecException, + NoSuchAlgorithmException { + String rsa = Strings2.toStringAndClose(getClass().getResourceAsStream("/ssh-rsa.txt")); + PublicKey key = KeyFactory.getInstance("RSA").generatePublic(SshKeys.publicKeySpecFromOpenSSH(rsa)); + + SameFingerprint predicate = new SameFingerprint(key); + predicate.apply(new SshKey(0, "foo", null)); + } + + @Test + public void testSameFingerPrintRSA() throws IOException, InvalidKeySpecException, NoSuchAlgorithmException { + String rsa = Strings2.toStringAndClose(getClass().getResourceAsStream("/ssh-rsa.txt")); + PublicKey key = KeyFactory.getInstance("RSA").generatePublic(SshKeys.publicKeySpecFromOpenSSH(rsa)); + + SameFingerprint predicate = new SameFingerprint(key); + assertTrue(predicate.apply(new SshKey(0, "foo", key))); + } + + @Test + public void testSameFingerPrintDSA() throws IOException, InvalidKeySpecException, NoSuchAlgorithmException { + String dsa = Strings2.toStringAndClose(getClass().getResourceAsStream("/ssh-dsa.txt")); + PublicKey key = KeyFactory.getInstance("DSA").generatePublic(DSAKeys.publicKeySpecFromOpenSSH(dsa)); + + SameFingerprint predicate = new SameFingerprint(key); + assertTrue(predicate.apply(new SshKey(0, "foo", key))); + } + +} http://git-wip-us.apache.org/repos/asf/jclouds-labs/blob/3eaa646d/digitalocean/src/test/java/org/jclouds/digitalocean/strategy/ListSshKeysLiveTest.java ---------------------------------------------------------------------- diff --git a/digitalocean/src/test/java/org/jclouds/digitalocean/strategy/ListSshKeysLiveTest.java b/digitalocean/src/test/java/org/jclouds/digitalocean/strategy/ListSshKeysLiveTest.java new file mode 100644 index 0000000..336a166 --- /dev/null +++ b/digitalocean/src/test/java/org/jclouds/digitalocean/strategy/ListSshKeysLiveTest.java @@ -0,0 +1,83 @@ +/* + * 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.jclouds.digitalocean.strategy; + +import static com.google.common.collect.Iterables.all; +import static org.testng.Assert.assertTrue; + +import java.io.IOException; +import java.util.List; + +import org.jclouds.digitalocean.domain.SshKey; +import org.jclouds.digitalocean.internal.BaseDigitalOceanLiveTest; +import org.jclouds.util.Strings2; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import com.google.common.base.Predicate; +import com.google.common.util.concurrent.MoreExecutors; + +/** + * Live tests for the {@link ListSshKeys} strategy. + */ +@Test(groups = "live", testName = "ListSshKeysLiveTest") +public class ListSshKeysLiveTest extends BaseDigitalOceanLiveTest { + + private ListSshKeys strategy; + + private SshKey rsaKey; + + private SshKey dsaKey; + + @Override + protected void initialize() { + super.initialize(); + strategy = new ListSshKeys(api, MoreExecutors.newDirectExecutorService()); + } + + @BeforeClass + public void setupKeys() throws IOException { + String rsa = Strings2.toStringAndClose(getClass().getResourceAsStream("/ssh-rsa.txt")); + String dsa = Strings2.toStringAndClose(getClass().getResourceAsStream("/ssh-dsa.txt")); + + rsaKey = api.getKeyPairApi().create("jclouds-test-rsa", rsa); + dsaKey = api.getKeyPairApi().create("jclouds-test-dsa", dsa); + } + + @AfterClass(alwaysRun = true) + public void cleanupKeys() { + if (rsaKey != null) { + api.getKeyPairApi().delete(rsaKey.getId()); + } + if (dsaKey != null) { + api.getKeyPairApi().delete(dsaKey.getId()); + } + } + + public void testListWithDetails() { + List<SshKey> keys = strategy.execute(); + + assertTrue(keys.size() >= 2); + assertTrue(all(keys, new Predicate<SshKey>() { + @Override + public boolean apply(SshKey input) { + return input.getPublicKey() != null; + } + })); + } +}
