Added namespace configurator for existing user SSH keys (#192)
Signed-off-by: Max Shaposhnik <mshaposh@redhat.com>pull/193/head
parent
fb8735b8c4
commit
ff26653628
|
|
@ -50,6 +50,9 @@ public final class Constants {
|
|||
public static final String DEV_WORKSPACE_MOUNT_LABEL =
|
||||
"controller.devfile.io/mount-to-devworkspace";
|
||||
|
||||
public static final String DEV_WORKSPACE_WATCH_SECRET_LABEL =
|
||||
"controller.devfile.io/watch-secret";
|
||||
|
||||
public static final String DEV_WORKSPACE_MOUNT_PATH_ANNOTATION =
|
||||
"controller.devfile.io/mount-path";
|
||||
public static final String DEV_WORKSPACE_MOUNT_AS_ANNOTATION = "controller.devfile.io/mount-as";
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.RemoveNames
|
|||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.CredentialsSecretConfigurator;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.NamespaceConfigurator;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.PreferencesConfigMapConfigurator;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.SshKeysConfigurator;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.UserPermissionConfigurator;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.UserPreferencesConfigurator;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.UserProfileConfigurator;
|
||||
|
|
@ -116,6 +117,7 @@ public class KubernetesInfraModule extends AbstractModule {
|
|||
namespaceConfigurators.addBinding().to(WorkspaceServiceAccountConfigurator.class);
|
||||
namespaceConfigurators.addBinding().to(UserProfileConfigurator.class);
|
||||
namespaceConfigurators.addBinding().to(UserPreferencesConfigurator.class);
|
||||
namespaceConfigurators.addBinding().to(SshKeysConfigurator.class);
|
||||
|
||||
bind(KubernetesNamespaceService.class);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
/*
|
||||
* Copyright (c) 2012-2021 Red Hat, Inc.
|
||||
* This program and the accompanying materials are made
|
||||
* available under the terms of the Eclipse Public License 2.0
|
||||
* which is available at https://www.eclipse.org/legal/epl-2.0/
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*
|
||||
* Contributors:
|
||||
* Red Hat, Inc. - initial API and implementation
|
||||
*/
|
||||
package org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator;
|
||||
|
||||
import static com.google.common.base.Strings.isNullOrEmpty;
|
||||
import static java.lang.String.format;
|
||||
import static java.util.stream.Collectors.toList;
|
||||
import static org.eclipse.che.workspace.infrastructure.kubernetes.Constants.DEV_WORKSPACE_MOUNT_LABEL;
|
||||
import static org.eclipse.che.workspace.infrastructure.kubernetes.Constants.DEV_WORKSPACE_MOUNT_PATH_ANNOTATION;
|
||||
import static org.eclipse.che.workspace.infrastructure.kubernetes.Constants.DEV_WORKSPACE_WATCH_SECRET_LABEL;
|
||||
import static org.eclipse.che.workspace.infrastructure.kubernetes.namespace.KubernetesObjectUtil.isValidConfigMapKeyName;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.fabric8.kubernetes.api.model.ObjectMeta;
|
||||
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
|
||||
import io.fabric8.kubernetes.api.model.Secret;
|
||||
import io.fabric8.kubernetes.api.model.SecretBuilder;
|
||||
import io.fabric8.kubernetes.client.KubernetesClient;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.inject.Inject;
|
||||
import org.eclipse.che.api.core.ServerException;
|
||||
import org.eclipse.che.api.ssh.server.SshManager;
|
||||
import org.eclipse.che.api.ssh.server.model.impl.SshPairImpl;
|
||||
import org.eclipse.che.api.ssh.shared.model.SshPair;
|
||||
import org.eclipse.che.api.workspace.server.spi.InfrastructureException;
|
||||
import org.eclipse.che.api.workspace.server.spi.NamespaceResolutionContext;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.KubernetesClientFactory;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* This class mounts existing user SSH Keys into a special Kubernetes Secret on user-s namespace.
|
||||
*/
|
||||
public class SshKeysConfigurator implements NamespaceConfigurator {
|
||||
|
||||
public static final String SSH_KEY_SECRET_NAME = "che-git-ssh-key";
|
||||
private static final String SSH_KEYS_WILL_NOT_BE_MOUNTED_MESSAGE =
|
||||
"Ssh keys %s have invalid names and can't be mounted to namespace %s.";
|
||||
|
||||
private static final String SSH_BASE_CONFIG_PATH = "/etc/ssh/";
|
||||
|
||||
private final SshManager sshManager;
|
||||
|
||||
private final KubernetesClientFactory clientFactory;
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SshKeysConfigurator.class);
|
||||
|
||||
public static final Pattern VALID_DOMAIN_PATTERN =
|
||||
Pattern.compile("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$");
|
||||
|
||||
@Inject
|
||||
public SshKeysConfigurator(SshManager sshManager, KubernetesClientFactory clientFactory) {
|
||||
this.sshManager = sshManager;
|
||||
this.clientFactory = clientFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(NamespaceResolutionContext namespaceResolutionContext, String namespaceName)
|
||||
throws InfrastructureException {
|
||||
|
||||
var client = clientFactory.create();
|
||||
List<SshPairImpl> vcsSshPairs = getVcsSshPairs(namespaceResolutionContext);
|
||||
|
||||
List<String> invalidSshKeyNames =
|
||||
vcsSshPairs
|
||||
.stream()
|
||||
.filter(keyPair -> !isValidSshKeyPair(keyPair))
|
||||
.map(SshPairImpl::getName)
|
||||
.collect(toList());
|
||||
|
||||
if (!invalidSshKeyNames.isEmpty()) {
|
||||
String message =
|
||||
format(
|
||||
SSH_KEYS_WILL_NOT_BE_MOUNTED_MESSAGE, invalidSshKeyNames.toString(), namespaceName);
|
||||
LOG.warn(message);
|
||||
// filter
|
||||
vcsSshPairs = vcsSshPairs.stream().filter(this::isValidSshKeyPair).collect(toList());
|
||||
}
|
||||
|
||||
if (vcsSshPairs.size() == 0) {
|
||||
// nothing to provision
|
||||
return;
|
||||
}
|
||||
doProvisionSshKeys(vcsSshPairs, client, namespaceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return list of keys related to the VCS (Version Control Systems), Git, SVN and etc. Usually
|
||||
* managed by user
|
||||
*
|
||||
* @param context NamespaceResolutionContext
|
||||
* @return list of ssh pairs
|
||||
*/
|
||||
private List<SshPairImpl> getVcsSshPairs(NamespaceResolutionContext context)
|
||||
throws InfrastructureException {
|
||||
List<SshPairImpl> sshPairs;
|
||||
try {
|
||||
sshPairs = sshManager.getPairs(context.getUserId(), "vcs");
|
||||
} catch (ServerException e) {
|
||||
String message = format("Unable to get SSH Keys. Cause: %s", e.getMessage());
|
||||
LOG.warn(message);
|
||||
throw new InfrastructureException(e);
|
||||
}
|
||||
return sshPairs;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
boolean isValidSshKeyPair(SshPairImpl keyPair) {
|
||||
return isValidConfigMapKeyName(keyPair.getName())
|
||||
&& VALID_DOMAIN_PATTERN.matcher(keyPair.getName()).matches();
|
||||
}
|
||||
|
||||
private void doProvisionSshKeys(
|
||||
List<SshPairImpl> sshPairs, KubernetesClient client, String namespaceName) {
|
||||
|
||||
StringBuilder sshConfigData = new StringBuilder();
|
||||
Map<String, String> data = new HashMap<>();
|
||||
|
||||
for (SshPair sshPair : sshPairs) {
|
||||
sshConfigData.append(buildConfig(sshPair.getName()));
|
||||
if (!isNullOrEmpty(sshPair.getPrivateKey())) {
|
||||
data.put(
|
||||
sshPair.getName(),
|
||||
Base64.getEncoder().encodeToString(sshPair.getPrivateKey().getBytes()));
|
||||
data.put(
|
||||
sshPair.getName() + ".pub",
|
||||
Base64.getEncoder().encodeToString(sshPair.getPublicKey().getBytes()));
|
||||
}
|
||||
}
|
||||
data.put("ssh_config", Base64.getEncoder().encodeToString(sshConfigData.toString().getBytes()));
|
||||
Secret secret =
|
||||
new SecretBuilder()
|
||||
.addToData(data)
|
||||
.withType("generic")
|
||||
.withMetadata(buildMetadata())
|
||||
.build();
|
||||
|
||||
client.secrets().inNamespace(namespaceName).createOrReplace(secret);
|
||||
}
|
||||
|
||||
private ObjectMeta buildMetadata() {
|
||||
return new ObjectMetaBuilder()
|
||||
.withName(SSH_KEY_SECRET_NAME)
|
||||
.withLabels(
|
||||
Map.of(
|
||||
DEV_WORKSPACE_MOUNT_LABEL, "true",
|
||||
DEV_WORKSPACE_WATCH_SECRET_LABEL, "true"))
|
||||
.withAnnotations(Map.of(DEV_WORKSPACE_MOUNT_PATH_ANNOTATION, SSH_BASE_CONFIG_PATH))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ssh configuration entry which includes host, identity file location and Host Key
|
||||
* checking policy
|
||||
*
|
||||
* <p>Example of provided configuration:
|
||||
*
|
||||
* <pre>
|
||||
* host github.com
|
||||
* IdentityFile /.ssh/private/github-com/private
|
||||
* StrictHostKeyChecking = no
|
||||
* </pre>
|
||||
*
|
||||
* or
|
||||
*
|
||||
* <pre>
|
||||
* host *
|
||||
* IdentityFile /.ssh/private/default-123456/private
|
||||
* StrictHostKeyChecking = no
|
||||
* </pre>
|
||||
*
|
||||
* @param name the of key given during generate for vcs service we will consider it as host of
|
||||
* version control service (e.g. github.com, gitlab.com and etc) if name starts from
|
||||
* "default-{anyString}" it will be replaced on wildcard "*" host name. Name with format
|
||||
* "default-{anyString}" will be generated on client side by Theia SSH Plugin, if user doesn't
|
||||
* provide own name. Details see here:
|
||||
* https://github.com/eclipse/che/issues/13494#issuecomment-512761661. Note: behavior can be
|
||||
* improved in 7.x releases after 7.0.0
|
||||
* @return the ssh configuration which include host, identity file location and Host Key checking
|
||||
* policy
|
||||
*/
|
||||
private String buildConfig(@NotNull String name) {
|
||||
String host = name.startsWith("default-") ? "*" : name;
|
||||
return "host "
|
||||
+ host
|
||||
+ "\nIdentityFile "
|
||||
+ SSH_BASE_CONFIG_PATH
|
||||
+ name
|
||||
+ "\nStrictHostKeyChecking = no"
|
||||
+ "\n\n";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright (c) 2012-2021 Red Hat, Inc.
|
||||
* This program and the accompanying materials are made
|
||||
* available under the terms of the Eclipse Public License 2.0
|
||||
* which is available at https://www.eclipse.org/legal/epl-2.0/
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*
|
||||
* Contributors:
|
||||
* Red Hat, Inc. - initial API and implementation
|
||||
*/
|
||||
package org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator;
|
||||
|
||||
import static org.eclipse.che.workspace.infrastructure.kubernetes.Constants.DEV_WORKSPACE_MOUNT_LABEL;
|
||||
import static org.eclipse.che.workspace.infrastructure.kubernetes.Constants.DEV_WORKSPACE_WATCH_SECRET_LABEL;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.testng.Assert.assertEquals;
|
||||
|
||||
import io.fabric8.kubernetes.api.model.Secret;
|
||||
import io.fabric8.kubernetes.client.server.mock.KubernetesServer;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.eclipse.che.api.core.NotFoundException;
|
||||
import org.eclipse.che.api.core.ServerException;
|
||||
import org.eclipse.che.api.ssh.server.SshManager;
|
||||
import org.eclipse.che.api.ssh.server.model.impl.SshPairImpl;
|
||||
import org.eclipse.che.api.workspace.server.spi.InfrastructureException;
|
||||
import org.eclipse.che.api.workspace.server.spi.NamespaceResolutionContext;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.KubernetesClientFactory;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.testng.MockitoTestNGListener;
|
||||
import org.testng.annotations.AfterMethod;
|
||||
import org.testng.annotations.BeforeMethod;
|
||||
import org.testng.annotations.Listeners;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
@Listeners(MockitoTestNGListener.class)
|
||||
public class SshKeysConfiguratorTest {
|
||||
|
||||
private static final String USER_ID = "user-id";
|
||||
private static final String USER_NAME = "user-name";
|
||||
private static final String USER_NAMESPACE = "user-namespace";
|
||||
|
||||
@Mock private KubernetesClientFactory clientFactory;
|
||||
@Mock private SshManager sshManager;
|
||||
|
||||
@InjectMocks private SshKeysConfigurator sshKeysConfigurator;
|
||||
|
||||
private KubernetesServer kubernetesServer;
|
||||
private NamespaceResolutionContext context;
|
||||
|
||||
private final SshPairImpl sshPair =
|
||||
new SshPairImpl(USER_ID, "vcs", "github.com", "public-key", "private-key");
|
||||
|
||||
@BeforeMethod
|
||||
public void setUp() throws InfrastructureException, NotFoundException, ServerException {
|
||||
context = new NamespaceResolutionContext(null, USER_ID, USER_NAME);
|
||||
kubernetesServer = new KubernetesServer(true, true);
|
||||
kubernetesServer.before();
|
||||
|
||||
when(sshManager.getPairs(USER_ID, "vcs")).thenReturn(Collections.singletonList(sshPair));
|
||||
when(clientFactory.create()).thenReturn(kubernetesServer.getClient());
|
||||
}
|
||||
|
||||
@AfterMethod
|
||||
public void cleanUp() {
|
||||
kubernetesServer.getClient().secrets().inNamespace(USER_NAMESPACE).delete();
|
||||
kubernetesServer.after();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateSSHKeysSecret() throws InfrastructureException {
|
||||
sshKeysConfigurator.configure(context, USER_NAMESPACE);
|
||||
List<Secret> secrets =
|
||||
kubernetesServer
|
||||
.getClient()
|
||||
.secrets()
|
||||
.inNamespace(USER_NAMESPACE)
|
||||
.withLabels(
|
||||
Map.of(
|
||||
DEV_WORKSPACE_MOUNT_LABEL, "true",
|
||||
DEV_WORKSPACE_WATCH_SECRET_LABEL, "true"))
|
||||
.list()
|
||||
.getItems();
|
||||
assertEquals(secrets.size(), 1);
|
||||
assertEquals(secrets.get(0).getMetadata().getName(), "che-git-ssh-key");
|
||||
assertEquals(secrets.get(0).getData().size(), 3);
|
||||
assertEquals(
|
||||
new String(Base64.getDecoder().decode(secrets.get(0).getData().get("github.com"))),
|
||||
"private-key");
|
||||
assertEquals(
|
||||
new String(Base64.getDecoder().decode(secrets.get(0).getData().get("github.com.pub"))),
|
||||
"public-key");
|
||||
assertEquals(
|
||||
new String(Base64.getDecoder().decode(secrets.get(0).getData().get("ssh_config"))),
|
||||
"host github.com\n"
|
||||
+ "IdentityFile /etc/ssh/github.com\n"
|
||||
+ "StrictHostKeyChecking = no\n\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotCreateSSHKeysSecretFromBadSecret() throws Exception {
|
||||
SshPairImpl sshPairLocal =
|
||||
new SshPairImpl(USER_ID, "vcs", "%sd$$$", "public-key", "private-key");
|
||||
when(sshManager.getPairs(USER_ID, "vcs")).thenReturn(List.of(sshPairLocal));
|
||||
sshKeysConfigurator.configure(context, USER_NAMESPACE);
|
||||
List<Secret> secrets =
|
||||
kubernetesServer
|
||||
.getClient()
|
||||
.secrets()
|
||||
.inNamespace(USER_NAMESPACE)
|
||||
.withLabels(
|
||||
Map.of(
|
||||
DEV_WORKSPACE_MOUNT_LABEL, "true",
|
||||
DEV_WORKSPACE_WATCH_SECRET_LABEL, "true"))
|
||||
.list()
|
||||
.getItems();
|
||||
assertEquals(secrets.size(), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.KubernetesN
|
|||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.CredentialsSecretConfigurator;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.NamespaceConfigurator;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.PreferencesConfigMapConfigurator;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.SshKeysConfigurator;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.UserPreferencesConfigurator;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.UserProfileConfigurator;
|
||||
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.pvc.CommonPVCStrategy;
|
||||
|
|
@ -125,6 +126,7 @@ public class OpenShiftInfraModule extends AbstractModule {
|
|||
namespaceConfigurators.addBinding().to(PreferencesConfigMapConfigurator.class);
|
||||
namespaceConfigurators.addBinding().to(OpenShiftWorkspaceServiceAccountConfigurator.class);
|
||||
namespaceConfigurators.addBinding().to(OpenShiftStopWorkspaceRoleConfigurator.class);
|
||||
namespaceConfigurators.addBinding().to(SshKeysConfigurator.class);
|
||||
|
||||
bind(KubernetesNamespaceService.class);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue