Skip to content

Commit

Permalink
[v15] Add support for generating a kubeconfig that does not depend on…
Browse files Browse the repository at this point in the history
… tbot binary (#40162)

* Add support for generating a kubeconfig that does not depend on tbot binary

* Fix tests
  • Loading branch information
strideynet committed Apr 3, 2024
1 parent 5a31752 commit 19d24f9
Show file tree
Hide file tree
Showing 7 changed files with 170 additions and 28 deletions.
7 changes: 0 additions & 7 deletions lib/tbot/config/bot_test.go
Expand Up @@ -184,13 +184,6 @@ func (p *mockProvider) Config() *BotConfig {
// getTestIdent.
type identRequest func(id *tlsca.Identity)

// kubernetesRequest requests a Kubernetes cluster.
func kubernetesRequest(k8sCluster string) identRequest {
return func(id *tlsca.Identity) {
id.KubernetesCluster = k8sCluster
}
}

// getTestIdent returns a mostly-valid bot Identity without starting up an
// entire Teleport server instance.
func getTestIdent(t *testing.T, username string, reqs ...identRequest) *identity.Identity {
Expand Down
8 changes: 8 additions & 0 deletions lib/tbot/config/output_kubernetes.go
Expand Up @@ -45,6 +45,13 @@ type KubernetesOutput struct {
// This is named a little more verbosely to avoid conflicting with the
// name of the Teleport cluster to use.
KubernetesCluster string `yaml:"kubernetes_cluster"`

// DisableExecPlugin disables the default behavior of using `tbot` as a
// `kubectl` credentials exec plugin. This is useful in environments where
// `tbot` may not exist on the system that will consume the outputted
// kubeconfig. It does mean that kubectl will not be able to automatically
// refresh the credentials within an individual invocation.
DisableExecPlugin bool `yaml:"disable_exec_plugin"`
}

func (o *KubernetesOutput) templates() []template {
Expand All @@ -54,6 +61,7 @@ func (o *KubernetesOutput) templates() []template {
&templateKubernetes{
clusterName: o.KubernetesCluster,
executablePathGetter: os.Executable,
disableExecPlugin: o.DisableExecPlugin,
},
}
}
Expand Down
84 changes: 68 additions & 16 deletions lib/tbot/config/template_kubernetes.go
Expand Up @@ -42,6 +42,7 @@ const defaultKubeconfigPath = "kubeconfig.yaml"
type templateKubernetes struct {
clusterName string
executablePathGetter executablePathGetter
disableExecPlugin bool
}

func (t *templateKubernetes) name() string {
Expand All @@ -66,9 +67,43 @@ type kubernetesStatus struct {
credentials *client.Key
}

// generateKubeConfig creates a Kubernetes config object with the given cluster
func generateKubeConfigWithoutPlugin(ks *kubernetesStatus) (*clientcmdapi.Config, error) {
config := clientcmdapi.NewConfig()

contextName := kubeconfig.ContextName(ks.teleportClusterName, ks.kubernetesClusterName)
// Configure the cluster.
clusterCAs, err := ks.credentials.RootClusterCAs()
if err != nil {
return nil, trace.Wrap(err)
}
cas := bytes.Join(clusterCAs, []byte("\n"))
if len(cas) == 0 {
return nil, trace.BadParameter("TLS trusted CAs missing in provided credentials")
}
config.Clusters[contextName] = &clientcmdapi.Cluster{
Server: ks.clusterAddr,
CertificateAuthorityData: cas,
TLSServerName: ks.tlsServerName,
}

config.AuthInfos[contextName] = &clientcmdapi.AuthInfo{
ClientCertificateData: ks.credentials.TLSCert,
ClientKeyData: ks.credentials.PrivateKeyPEM(),
}

// Last, create a context linking the cluster to the auth info.
config.Contexts[contextName] = &clientcmdapi.Context{
Cluster: contextName,
AuthInfo: contextName,
}
config.CurrentContext = contextName

return config, nil
}

// generateKubeConfigWithPlugin creates a Kubernetes config object with the given cluster
// config.
func generateKubeConfig(ks *kubernetesStatus, destPath string, executablePath string) (*clientcmdapi.Config, error) {
func generateKubeConfigWithPlugin(ks *kubernetesStatus, destPath string, executablePath string) (*clientcmdapi.Config, error) {
config := clientcmdapi.NewConfig()

// Implementation note: tsh/kube.go generates a kubeconfig with all
Expand Down Expand Up @@ -138,13 +173,6 @@ func (t *templateKubernetes) render(
)
defer span.End()

// Only Destination dirs are supported right now, but we could be flexible
// on this in the future if needed.
destinationDir, ok := destination.(*DestinationDirectory)
if !ok {
return trace.BadParameter("Destination %s must be a directory", destination)
}

// Ping the proxy to resolve connection addresses.
proxyPong, err := bot.ProxyPing(ctx)
if err != nil {
Expand Down Expand Up @@ -173,14 +201,38 @@ func (t *templateKubernetes) render(
kubernetesClusterName: t.clusterName,
}

executablePath, err := t.executablePathGetter()
if err != nil {
return trace.Wrap(err)
}
var cfg *clientcmdapi.Config
if t.disableExecPlugin {
// If they've disabled the exec plugin, we just write the credentials
// directly into the kubeconfig.
cfg, err = generateKubeConfigWithoutPlugin(status)
if err != nil {
return trace.Wrap(err)
}
} else {
// In exec plugin mode, we write the credentials to disk and write a
// kubeconfig that execs `tbot` to load those credentials.

// We only support directory mode for this since the exec plugin needs
// to know the path to read the credentials from, and this is
// unpredictable with other types of destination.
destinationDir, ok := destination.(*DestinationDirectory)
if !ok {
return trace.BadParameter(
"Destination %s must be a directory in exec plugin mode",
destination,
)
}

cfg, err := generateKubeConfig(status, destinationDir.Path, executablePath)
if err != nil {
return trace.Wrap(err)
executablePath, err := t.executablePathGetter()
if err != nil {
return trace.Wrap(err)
}

cfg, err = generateKubeConfigWithPlugin(status, destinationDir.Path, executablePath)
if err != nil {
return trace.Wrap(err)
}
}

yamlCfg, err := clientcmd.Write(*cfg)
Expand Down
77 changes: 72 additions & 5 deletions lib/tbot/config/template_kubernetes_test.go
Expand Up @@ -29,9 +29,64 @@ import (

"github.com/gravitational/teleport/api/client/webclient"
"github.com/gravitational/teleport/lib/tbot/botfs"
"github.com/gravitational/teleport/lib/tbot/identity"
"github.com/gravitational/teleport/lib/utils/golden"
)

// Fairly ugly hardcoded certs to use in the generation so that the tests are
// deterministic.
var (
tlsCert = []byte(`-----BEGIN CERTIFICATE-----
MIIDfDCCAmSgAwIBAgIQP/jI85sqjDWDTd9qF0V5qjANBgkqhkiG9w0BAQsFADBA
MRUwEwYDVQQKEwxUZWxlcG9ydCBPU1MxJzAlBgNVBAMTHnRlbGVwb3J0LmxvY2Fs
aG9zdC5sb2NhbGRvbWFpbjAeFw0yNDA0MDIxNDA5MjZaFw0yNDA0MDIxNTEwMjZa
MHMxGzAZBgNVBAkTEnRlbGUuYmxhY2ttZXNhLmdvdjENMAsGA1UEERMEbnVsbDER
MA8GA1UEAxMIYm90LXRlc3QxDjAMBgUrzg8BARMDZm9vMQ4wDAYFK84PAQITA2Jh
cjESMBAGBSvODwEDEwdleGFtcGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAr8WfEDOq1TN0bT0SGEtEuDrRaf+VudmbypHokewy46md9XB3gQWbin9N
/5tyNdbFsWsDgDIyXP3Ube0ubcPYlcsCNtgCvK4qd3RyRvxY5lOfS1pZESPEtvO/
sxEu6E3O0ofcwq4uKenHuf1EUQuVD6WxABUOaOs2/3aahmYy4SnKNUsM2/l1XrcI
0ekvB0h10nXUC4VJS4sKGzGzThD308ia/bgDSXc0fiUwZPB5TLn7lScuisi+8JSs
qWccknXonGEEtism7FNi+mseV1ahzjEbRM/kfFwZ0H+ekz3CdnsmkND0FmxB9WTf
5PwG8oXM42QJkwuEIu+8Q/VVSSFe4wIDAQABoz8wPTAOBgNVHQ8BAf8EBAMCBaAw
HQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwDQYJ
KoZIhvcNAQELBQADggEBAEZYzIS0tx+Yn+cEfS83hpL1jELq9C8V3PTC6y44kjAK
i85Mx+ridYq0ddMdV/91JZ5t1Rqde4LSYUVUP6q/Ukih0mx/I3z2Siwp/YjpOu6x
SZXakjGnt5pCCa/t1NcVNSrqQpLQ8bJ0mruRiNawrKo3/ge57rgidnBFcOv3zV5t
BIkYjQJK2YVYcJjJ68olXzpCEw5hTZ9fw42fs2TORHHrPNUAzPf3Qdrzi03hrvhd
p6nYNA18b6ggygkcRU2MRte5E+/2ABYiwRklwJWNIMQJJHyZAW8Dyws0E2e+B3w+
7/SYPyeKOlt7foD7z0XM2Kw9ndurJq97AWfkcnrZwbQ=
-----END CERTIFICATE-----`)

keyPEM = []byte(`-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAr8WfEDOq1TN0bT0SGEtEuDrRaf+VudmbypHokewy46md9XB3
gQWbin9N/5tyNdbFsWsDgDIyXP3Ube0ubcPYlcsCNtgCvK4qd3RyRvxY5lOfS1pZ
ESPEtvO/sxEu6E3O0ofcwq4uKenHuf1EUQuVD6WxABUOaOs2/3aahmYy4SnKNUsM
2/l1XrcI0ekvB0h10nXUC4VJS4sKGzGzThD308ia/bgDSXc0fiUwZPB5TLn7lScu
isi+8JSsqWccknXonGEEtism7FNi+mseV1ahzjEbRM/kfFwZ0H+ekz3CdnsmkND0
FmxB9WTf5PwG8oXM42QJkwuEIu+8Q/VVSSFe4wIDAQABAoIBACvb8vnW+pyibz3G
zFoVhfs2agS6CsFKJE6io9atinE2ZLzWqGsgXBRt+ad7QT9f7Qp9Om1lmR2NFNGt
KjWndcbC1jWbJuuvxdbyzoUZ+JDYctoZnDnjo/VG0yG6eurqZ14vGo3Vap14wSaO
pNpYOoSiAo2Ts3nIn3uVO6+nlrCKFBWiIMMvJ4L89vSCXyI/5kpwCURxpYeYJrQb
Jzi4TsN0ViYqf6XNfhRCSD+Fk9km2e6zsPIuWyfPrtGx1cA2UeAjnjKK9IS9qkAy
632T63X6M5kWtirIHM/r/IbdSj+lxGCMqnsSgNYILQv5sNkjgjwlkKl182w5l3CZ
TkjyLPkCgYEA0guhs3KBqM4ASDzQI3+h5GTGA87ITb0B/TcitElPQ+u3PLypwz9u
KzS9BHMpaWPWIOJzYRAjb8BDldyCcAhmyr5lv7O/ezRmgGD9NPV66IE5AX9nKVNS
PhJTNiYPSH7g4zO3K4sd5397YunRZzAxgsVWzu7E2gUrJSCsf0nL77UCgYEA1jpg
mJbSGYVrYEyQmt6YjHnwcLiNTJDPbcn27g0LmcRbq9SfIva/IfZaf0ru8b9c8QNM
WMag57WGQghS2B7698GvlwF+nKXXZDjCZ4z6+Efi/T5uHL5VDcpHE4yqdZG42hTW
m+K4wl4Z6B50xGC/mJlxzB6je4qBM9Zsn8wSwzcCgYEAk5O0kv4a9111eUuw+aAN
QQlEzxwUQ/pOUXjRm1X+qTwOTFBJ/nKslxLA00WOjQumQQiaBFJwc23kjoCV7N0a
S8ymdKB4IrpYYk7C2Ni4+G8CfHjlJHX0TMRXTq5DAq6Sl0+YnLFr22EIciDSDewg
fT7llRLRoFUNUVK5n91buhkCgYEAwqUEA2B1wQ6Cg1rNwIkjne9lUWW9rKWecqig
naZoteu9RyDG/qOnAhquGx5ggHJY5fsTMU44AI/kTrb1Xry3Vsk62z9WZMoiLEOO
Dzv/A/t8+I/yyFb/PKpfbhnO/0fJ5wwr+jNDoAaUD10sxwkIzIQO62GjNKqhvhHD
XGW1Xn0CgYEAzULAI9ij/q5S+GMyYj1xLCU4qxBpU/04nE+PfSSmfv8Ma34uh4QM
nRDcZHBqZYNRDt5zNvRTjgwJi4iHGwSB+D4SIYGb0ioTI2MOS2F7zBDyl8FXp7dT
3TSKronwoWYoLSisqnn/s8iN1M9RJA9pyIy7FTVwq59XL3NoetISuKc=
-----END RSA PRIVATE KEY-----`)
)

// TestTemplateKubernetesRender renders a Kubernetes template and compares it
// to the saved golden result.
func TestTemplateKubernetesRender(t *testing.T) {
Expand All @@ -40,9 +95,18 @@ func TestTemplateKubernetesRender(t *testing.T) {
k8sCluster := "example"
mockBot := newMockProvider(cfg)

// We need a fixed cert/key pair here for the golden files testing
// to behave properly.
id := &identity.Identity{
PrivateKeyBytes: keyPEM,
TLSCertBytes: tlsCert,
ClusterName: mockClusterName,
}

tests := []struct {
name string
useRelativePath bool
name string
useRelativePath bool
disableExecPlugin bool
}{
{
name: "absolute path",
Expand All @@ -51,6 +115,10 @@ func TestTemplateKubernetesRender(t *testing.T) {
name: "relative path",
useRelativePath: true,
},
{
name: "exec plugin disabled",
disableExecPlugin: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -59,6 +127,7 @@ func TestTemplateKubernetesRender(t *testing.T) {
tmpl := templateKubernetes{
clusterName: k8sCluster,
executablePathGetter: fakeGetExecutablePath,
disableExecPlugin: tt.disableExecPlugin,
}
dest := &DestinationDirectory{
Path: dir,
Expand All @@ -73,9 +142,7 @@ func TestTemplateKubernetesRender(t *testing.T) {
dest.Path = relativePath
}

ident := getTestIdent(t, "bot-test", kubernetesRequest(k8sCluster))

err = tmpl.render(context.Background(), mockBot, ident, dest)
err = tmpl.render(context.Background(), mockBot, id, dest)
require.NoError(t, err)

kubeconfigBytes, err := os.ReadFile(filepath.Join(dir, defaultKubeconfigPath))
Expand Down
Expand Up @@ -4,3 +4,4 @@ destination:
roles:
- access
kubernetes_cluster: k8s.example.com
disable_exec_plugin: false
Expand Up @@ -2,3 +2,4 @@ type: kubernetes
destination:
type: memory
kubernetes_cluster: k8s.example.com
disable_exec_plugin: false
@@ -0,0 +1,20 @@
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURLakNDQWhLZ0F3SUJBZ0lRSnRKREpaWkJrZy9hZk04ZDJaSkNUakFOQmdrcWhraUc5dzBCQVFzRkFEQkEKTVJVd0V3WURWUVFLRXd4VVpXeGxjRzl5ZENCUFUxTXhKekFsQmdOVkJBTVRIblJsYkdWd2IzSjBMbXh2WTJGcwphRzl6ZEM1c2IyTmhiR1J2YldGcGJqQWVGdzB4TnpBMU1Ea3hPVFF3TXpaYUZ3MHlOekExTURjeE9UUXdNelphCk1FQXhGVEFUQmdOVkJBb1RERlJsYkdWd2IzSjBJRTlUVXpFbk1DVUdBMVVFQXhNZWRHVnNaWEJ2Y25RdWJHOWoKWVd4b2IzTjBMbXh2WTJGc1pHOXRZV2x1TUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQwpBUUVBdUtGTGFmMmlJSS94RFIrbTJZajZQblVFYStxenF3eHNkTFVqbnVuRlphQVhHK2habTRNbDgwU0NpQmdJCmdUSFFsSnlMSWtUdHVSb0g1YWVNeXoxRVJVQ3RpaTRac1RxRHJqalV5YnhQNHIrNEhWWDZtMzRzNmh3RXI4RmkKZnRzOXBNcDRpUzN0UWd1UmMyOGdQZERvL1Q2VnJKVFZZVWZVVXNORFJ0SXJsQjVPOWlncXFMbnVhWTllcUdpNApQVXgwRzB3UllKcFJ5d29qOEcwSWtwZlFUaVgrQ0FDN2R0NXdzN1pybkdxQ05CTEdpNWJHc2FNbXB0VmJzU0VwCjFUZW5udEY1NFYxaVI0OUlWNUpxRGhtMVMwSG1rbGVvSnpLZGMrNnNQL3hOZXB6OVBKenVGOWQ5TnViVExXZ0IKc0syOFlJdGNtV0hkSFhEL09EeFZhZWhSandJREFRQUJveUF3SGpBT0JnTlZIUThCQWY4RUJBTUNCNEF3REFZRApWUjBUQVFIL0JBSXdBREFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBQVZVNnNOQmRqNzZzYUh3T3hHU2RuRXFRCm8ydE11UjNtc1NNNEY2d0ZLMlVrS2Vwc0Q3Q1lJZi9Qek5TTlVxQTVKSUVVVmVNcUd5aUh1QWJVNEM2NTVuVDEKSXlKWDFELytyNzNzU3A1amJJcFFtMnhvUUdabmo2Zy9LbHR3OE9TT0F3K0RzTUYvUExWcW9XSnAwN3U2ZXcvbQpOeFdzSktjWjVrK3E0ZU14Y2k5bUtSSEhxc3F1V0tYelFsVVJNTkZJK21HYUZ3cktNNGRtemFSMEJFYytpbFN4ClFxVXZRNzRzbXNMSyt6aE5pa21namxHQzVvYjlnOFhraFZBa0pNQWgycmI5b25ETmlSbDY4aUFnY3pQODhtWHUKdk4vbzk4ZHlwenNQeFhtdzZ0a0RxSVJQVUFVYmg0NjVybFk1c0tNbVJnWGkyclVmbC9RVjVuYm96VW8vSFE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t
server: https://tele.blackmesa.gov:443
tls-server-name: kube-teleport-proxy-alpn.tele.blackmesa.gov
name: tele.blackmesa.gov-example
contexts:
- context:
cluster: tele.blackmesa.gov-example
user: tele.blackmesa.gov-example
name: tele.blackmesa.gov-example
current-context: tele.blackmesa.gov-example
kind: Config
preferences: {}
users:
- name: tele.blackmesa.gov-example
user:
client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURmRENDQW1TZ0F3SUJBZ0lRUC9qSTg1c3FqRFdEVGQ5cUYwVjVxakFOQmdrcWhraUc5dzBCQVFzRkFEQkEKTVJVd0V3WURWUVFLRXd4VVpXeGxjRzl5ZENCUFUxTXhKekFsQmdOVkJBTVRIblJsYkdWd2IzSjBMbXh2WTJGcwphRzl6ZEM1c2IyTmhiR1J2YldGcGJqQWVGdzB5TkRBME1ESXhOREE1TWpaYUZ3MHlOREEwTURJeE5URXdNalphCk1ITXhHekFaQmdOVkJBa1RFblJsYkdVdVlteGhZMnR0WlhOaExtZHZkakVOTUFzR0ExVUVFUk1FYm5Wc2JERVIKTUE4R0ExVUVBeE1JWW05MExYUmxjM1F4RGpBTUJnVXJ6ZzhCQVJNRFptOXZNUTR3REFZRks4NFBBUUlUQTJKaApjakVTTUJBR0JTdk9Ed0VERXdkbGVHRnRjR3hsTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCCkNnS0NBUUVBcjhXZkVET3ExVE4wYlQwU0dFdEV1RHJSYWYrVnVkbWJ5cEhva2V3eTQ2bWQ5WEIzZ1FXYmluOU4KLzV0eU5kYkZzV3NEZ0RJeVhQM1ViZTB1YmNQWWxjc0NOdGdDdks0cWQzUnlSdnhZNWxPZlMxcFpFU1BFdHZPLwpzeEV1NkUzTzBvZmN3cTR1S2VuSHVmMUVVUXVWRDZXeEFCVU9hT3MyLzNhYWhtWXk0U25LTlVzTTIvbDFYcmNJCjBla3ZCMGgxMG5YVUM0VkpTNHNLR3pHelRoRDMwOGlhL2JnRFNYYzBmaVV3WlBCNVRMbjdsU2N1aXNpKzhKU3MKcVdjY2tuWG9uR0VFdGlzbTdGTmkrbXNlVjFhaHpqRWJSTS9rZkZ3WjBIK2VrejNDZG5zbWtORDBGbXhCOVdUZgo1UHdHOG9YTTQyUUprd3VFSXUrOFEvVlZTU0ZlNHdJREFRQUJvejh3UFRBT0JnTlZIUThCQWY4RUJBTUNCYUF3CkhRWURWUjBsQkJZd0ZBWUlLd1lCQlFVSEF3RUdDQ3NHQVFVRkJ3TUNNQXdHQTFVZEV3RUIvd1FDTUFBd0RRWUoKS29aSWh2Y05BUUVMQlFBRGdnRUJBRVpZeklTMHR4K1luK2NFZlM4M2hwTDFqRUxxOUM4VjNQVEM2eTQ0a2pBSwppODVNeCtyaWRZcTBkZE1kVi85MUpaNXQxUnFkZTRMU1lVVlVQNnEvVWtpaDBteC9JM3oyU2l3cC9ZanBPdTZ4ClNaWGFrakdudDVwQ0NhL3QxTmNWTlNycVFwTFE4YkowbXJ1UmlOYXdyS28zL2dlNTdyZ2lkbkJGY092M3pWNXQKQklrWWpRSksyWVZZY0pqSjY4b2xYenBDRXc1aFRaOWZ3NDJmczJUT1JISHJQTlVBelBmM1FkcnppMDNocnZoZApwNm5ZTkExOGI2Z2d5Z2tjUlUyTVJ0ZTVFKy8yQUJZaXdSa2x3SldOSU1RSkpIeVpBVzhEeXdzMEUyZStCM3crCjcvU1lQeWVLT2x0N2ZvRDd6MFhNMkt3OW5kdXJKcTk3QVdma2Nuclp3YlE9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=
client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcFFJQkFBS0NBUUVBcjhXZkVET3ExVE4wYlQwU0dFdEV1RHJSYWYrVnVkbWJ5cEhva2V3eTQ2bWQ5WEIzCmdRV2JpbjlOLzV0eU5kYkZzV3NEZ0RJeVhQM1ViZTB1YmNQWWxjc0NOdGdDdks0cWQzUnlSdnhZNWxPZlMxcFoKRVNQRXR2Ty9zeEV1NkUzTzBvZmN3cTR1S2VuSHVmMUVVUXVWRDZXeEFCVU9hT3MyLzNhYWhtWXk0U25LTlVzTQoyL2wxWHJjSTBla3ZCMGgxMG5YVUM0VkpTNHNLR3pHelRoRDMwOGlhL2JnRFNYYzBmaVV3WlBCNVRMbjdsU2N1CmlzaSs4SlNzcVdjY2tuWG9uR0VFdGlzbTdGTmkrbXNlVjFhaHpqRWJSTS9rZkZ3WjBIK2VrejNDZG5zbWtORDAKRm14QjlXVGY1UHdHOG9YTTQyUUprd3VFSXUrOFEvVlZTU0ZlNHdJREFRQUJBb0lCQUN2Yjh2blcrcHlpYnozRwp6Rm9WaGZzMmFnUzZDc0ZLSkU2aW85YXRpbkUyWkx6V3FHc2dYQlJ0K2FkN1FUOWY3UXA5T20xbG1SMk5GTkd0CktqV25kY2JDMWpXYkp1dXZ4ZGJ5em9VWitKRFljdG9abkRuam8vVkcweUc2ZXVycVoxNHZHbzNWYXAxNHdTYU8KcE5wWU9vU2lBbzJUczNuSW4zdVZPNitubHJDS0ZCV2lJTU12SjRMODl2U0NYeUkvNWtwd0NVUnhwWWVZSnJRYgpKemk0VHNOMFZpWXFmNlhOZmhSQ1NEK0ZrOWttMmU2enNQSXVXeWZQcnRHeDFjQTJVZUFqbmpLSzlJUzlxa0F5CjYzMlQ2M1g2TTVrV3RpcklITS9yL0liZFNqK2x4R0NNcW5zU2dOWUlMUXY1c05ramdqd2xrS2wxODJ3NWwzQ1oKVGtqeUxQa0NnWUVBMGd1aHMzS0JxTTRBU0R6UUkzK2g1R1RHQTg3SVRiMEIvVGNpdEVsUFErdTNQTHlwd3o5dQpLelM5QkhNcGFXUFdJT0p6WVJBamI4QkRsZHlDY0FobXlyNWx2N08vZXpSbWdHRDlOUFY2NklFNUFYOW5LVk5TClBoSlROaVlQU0g3ZzR6TzNLNHNkNTM5N1l1blJaekF4Z3NWV3p1N0UyZ1VySlNDc2Ywbkw3N1VDZ1lFQTFqcGcKbUpiU0dZVnJZRXlRbXQ2WWpIbndjTGlOVEpEUGJjbjI3ZzBMbWNSYnE5U2ZJdmEvSWZaYWYwcnU4YjljOFFOTQpXTWFnNTdXR1FnaFMyQjc2OThHdmx3RituS1hYWkRqQ1o0ejYrRWZpL1Q1dUhMNVZEY3BIRTR5cWRaRzQyaFRXCm0rSzR3bDRaNkI1MHhHQy9tSmx4ekI2amU0cUJNOVpzbjh3U3d6Y0NnWUVBazVPMGt2NGE5MTExZVV1dythQU4KUVFsRXp4d1VRL3BPVVhqUm0xWCtxVHdPVEZCSi9uS3NseExBMDBXT2pRdW1RUWlhQkZKd2MyM2tqb0NWN04wYQpTOHltZEtCNElycFlZazdDMk5pNCtHOENmSGpsSkhYMFRNUlhUcTVEQXE2U2wwK1luTEZyMjJFSWNpRFNEZXdnCmZUN2xsUkxSb0ZVTlVWSzVuOTFidWhrQ2dZRUF3cVVFQTJCMXdRNkNnMXJOd0lram5lOWxVV1c5cktXZWNxaWcKbmFab3RldTlSeURHL3FPbkFocXVHeDVnZ0hKWTVmc1RNVTQ0QUkva1RyYjFYcnkzVnNrNjJ6OVdaTW9pTEVPTwpEenYvQS90OCtJL3l5RmIvUEtwZmJobk8vMGZKNXd3citqTkRvQWFVRDEwc3h3a0l6SVFPNjJHak5LcWh2aEhEClhHVzFYbjBDZ1lFQXpVTEFJOWlqL3E1UytHTXlZajF4TENVNHF4QnBVLzA0bkUrUGZTU21mdjhNYTM0dWg0UU0KblJEY1pIQnFaWU5SRHQ1ek52UlRqZ3dKaTRpSEd3U0IrRDRTSVlHYjBpb1RJMk1PUzJGN3pCRHlsOEZYcDdkVAozVFNLcm9ud29XWW9MU2lzcW5uL3M4aU4xTTlSSkE5cHlJeTdGVFZ3cTU5WEwzTm9ldElTdUtjPQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQ==

0 comments on commit 19d24f9

Please sign in to comment.