Added namespace configurator for existing user SSH keys (#192)

Signed-off-by: Max Shaposhnik <mshaposh@redhat.com>
pull/193/head
Max Shaposhnik 2021-12-13 09:00:28 +02:00 committed by GitHub
parent fb8735b8c4
commit ff26653628
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 336 additions and 0 deletions

View File

@ -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";

View File

@ -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);

View File

@ -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";
}
}

View File

@ -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);
}
}

View File

@ -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);