blob: d401c1ab78f1fad7bb8d19d4e83481d7c0382693 [file] [log] [blame]
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +02001// Copyright 2020 The Monogon Project Authors.
2//
3// SPDX-License-Identifier: Apache-2.0
4//
5// Licensed under the Apache License, Version 2.0 (the "License");
6// you may not use this file except in compliance with the License.
7// You may obtain a copy of the License at
8//
9// http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing, software
12// distributed under the License is distributed on an "AS IS" BASIS,
13// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14// See the License for the specific language governing permissions and
15// limitations under the License.
16
Leopold Schabel68c58752019-11-14 21:00:59 +010017// package consensus manages the embedded etcd cluster.
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +020018package consensus
19
20import (
Lorenz Bruna4ea9d02019-10-31 11:40:30 +010021 "bytes"
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +020022 "context"
Lorenz Bruna4ea9d02019-10-31 11:40:30 +010023 "crypto/x509"
24 "encoding/hex"
25 "encoding/pem"
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +020026 "fmt"
Lorenz Bruna4ea9d02019-10-31 11:40:30 +010027 "io/ioutil"
28 "math/rand"
29 "net/url"
30 "os"
31 "path"
32 "path/filepath"
33 "strings"
34 "time"
35
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +020036 "github.com/pkg/errors"
37 "go.etcd.io/etcd/clientv3"
38 "go.etcd.io/etcd/clientv3/namespace"
39 "go.etcd.io/etcd/embed"
40 "go.etcd.io/etcd/etcdserver/api/membership"
41 "go.etcd.io/etcd/pkg/types"
42 "go.etcd.io/etcd/proxy/grpcproxy/adapter"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020043 "go.uber.org/atomic"
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +020044 "go.uber.org/zap"
Lorenz Brun60febd92020-05-07 14:08:18 +020045 "go.uber.org/zap/zapcore"
Lorenz Bruna4ea9d02019-10-31 11:40:30 +010046 "golang.org/x/sys/unix"
Hendrik Hofstadt8efe51e2020-02-28 12:53:41 +010047
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020048 "git.monogon.dev/source/nexantic.git/core/generated/api"
49 "git.monogon.dev/source/nexantic.git/core/internal/common"
50 "git.monogon.dev/source/nexantic.git/core/internal/common/service"
Hendrik Hofstadt8efe51e2020-02-28 12:53:41 +010051 "git.monogon.dev/source/nexantic.git/core/internal/consensus/ca"
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +020052)
53
54const (
55 DefaultClusterToken = "SIGNOS"
56 DefaultLogger = "zap"
57)
58
Lorenz Bruna4ea9d02019-10-31 11:40:30 +010059const (
60 CAPath = "ca.pem"
61 CertPath = "cert.pem"
62 KeyPath = "cert-key.pem"
63 CRLPath = "ca-crl.der"
64 CRLSwapPath = "ca-crl.der.swp"
65)
66
Lorenz Brun6e8f69c2019-11-18 10:44:24 +010067const (
68 LocalListenerURL = "unix:///consensus/listener.sock:0"
69)
70
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +020071type (
72 Service struct {
Leopold Schabel68c58752019-11-14 21:00:59 +010073 *service.BaseService
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +020074
Leopold Schabel68c58752019-11-14 21:00:59 +010075 etcd *embed.Etcd
76 kv clientv3.KV
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020077 ready atomic.Bool
Leopold Schabel68c58752019-11-14 21:00:59 +010078
79 // bootstrapCA and bootstrapCert cache the etcd cluster CA data during bootstrap.
80 bootstrapCA *ca.CA
81 bootstrapCert []byte
82
Lorenz Bruna4ea9d02019-10-31 11:40:30 +010083 watchCRLTicker *time.Ticker
84 lastCRL []byte
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +020085
86 config *Config
87 }
88
89 Config struct {
90 Name string
91 DataDir string
92 InitialCluster string
93 NewCluster bool
Leopold Schabel68c58752019-11-14 21:00:59 +010094 ExternalHost string
95 ListenHost string
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +020096 }
97
98 Member struct {
99 ID uint64
100 Name string
101 Address string
102 Synced bool
103 }
104)
105
106func NewConsensusService(config Config, logger *zap.Logger) (*Service, error) {
107 consensusServer := &Service{
108 config: &config,
109 }
Leopold Schabel68c58752019-11-14 21:00:59 +0100110 consensusServer.BaseService = service.NewBaseService("consensus", logger, consensusServer)
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200111
112 return consensusServer, nil
113}
114
115func (s *Service) OnStart() error {
Leopold Schabel68c58752019-11-14 21:00:59 +0100116 // See: https://godoc.org/github.com/coreos/etcd/embed#Config
117
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200118 if s.config == nil {
119 return errors.New("config for consensus is nil")
120 }
121
122 cfg := embed.NewConfig()
123
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100124 cfg.PeerTLSInfo.CertFile = filepath.Join(s.config.DataDir, CertPath)
125 cfg.PeerTLSInfo.KeyFile = filepath.Join(s.config.DataDir, KeyPath)
126 cfg.PeerTLSInfo.TrustedCAFile = filepath.Join(s.config.DataDir, CAPath)
127 cfg.PeerTLSInfo.ClientCertAuth = true
128 cfg.PeerTLSInfo.CRLFile = filepath.Join(s.config.DataDir, CRLPath)
129
130 lastCRL, err := ioutil.ReadFile(cfg.PeerTLSInfo.CRLFile)
131 if err != nil {
132 return fmt.Errorf("failed to read etcd CRL: %w", err)
133 }
134 s.lastCRL = lastCRL
135
Lorenz Brun6e8f69c2019-11-18 10:44:24 +0100136 // Expose etcd to local processes
137 if err := os.MkdirAll("/consensus", 0700); err != nil {
138 return fmt.Errorf("Failed to create consensus runtime state directory: %w", err)
139 }
140 listenerURL, err := url.Parse(LocalListenerURL)
141 if err != nil {
142 panic(err)
143 }
144 cfg.LCUrls = []url.URL{*listenerURL}
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200145
Leopold Schabel68c58752019-11-14 21:00:59 +0100146 // Advertise Peer URLs
Lorenz Brunaa6b7342019-12-12 02:55:02 +0100147 apURL, err := url.Parse(fmt.Sprintf("https://%s:%d", s.config.ExternalHost, common.ConsensusPort))
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200148 if err != nil {
Leopold Schabel68c58752019-11-14 21:00:59 +0100149 return fmt.Errorf("invalid external_host or listen_port: %w", err)
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200150 }
151
Leopold Schabel68c58752019-11-14 21:00:59 +0100152 // Listen Peer URLs
Lorenz Brunaa6b7342019-12-12 02:55:02 +0100153 lpURL, err := url.Parse(fmt.Sprintf("https://%s:%d", s.config.ListenHost, common.ConsensusPort))
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200154 if err != nil {
Leopold Schabel68c58752019-11-14 21:00:59 +0100155 return fmt.Errorf("invalid listen_host or listen_port: %w", err)
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200156 }
157 cfg.APUrls = []url.URL{*apURL}
158 cfg.LPUrls = []url.URL{*lpURL}
159 cfg.ACUrls = []url.URL{}
160
161 cfg.Dir = s.config.DataDir
162 cfg.InitialClusterToken = DefaultClusterToken
163 cfg.Name = s.config.Name
164
165 // Only relevant if creating or joining a cluster; otherwise settings will be ignored
166 if s.config.NewCluster {
167 cfg.ClusterState = "new"
168 cfg.InitialCluster = cfg.InitialClusterFromName(cfg.Name)
169 } else if s.config.InitialCluster != "" {
170 cfg.ClusterState = "existing"
171 cfg.InitialCluster = s.config.InitialCluster
172 }
173
174 cfg.Logger = DefaultLogger
Lorenz Brun60febd92020-05-07 14:08:18 +0200175 cfg.ZapLoggerBuilder = embed.NewZapCoreLoggerBuilder(
176 s.Logger.With(zap.String("component", "etcd")).WithOptions(zap.IncreaseLevel(zapcore.WarnLevel)),
177 s.Logger.Core(),
178 nil,
179 )
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200180
181 server, err := embed.StartEtcd(cfg)
182 if err != nil {
183 return err
184 }
185 s.etcd = server
186
187 // Override the logger
188 //*server.GetLogger() = *s.Logger.With(zap.String("component", "etcd"))
Leopold Schabel68c58752019-11-14 21:00:59 +0100189 // TODO(leo): can we uncomment this?
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200190
191 go func() {
192 s.Logger.Info("waiting for etcd to become ready")
193 <-s.etcd.Server.ReadyNotify()
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200194 s.ready.Store(true)
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200195 s.Logger.Info("etcd is now ready")
196 }()
197
198 // Inject kv client
199 s.kv = clientv3.NewKVFromKVClient(adapter.KvServerToKvClient(s.etcd.Server), nil)
200
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100201 // Start CRL watcher
202 go s.watchCRL()
203
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200204 return nil
205}
206
Leopold Schabel68c58752019-11-14 21:00:59 +0100207// WriteCertificateFiles writes the given node certificate data to local storage
208// such that it can be used by the embedded etcd server.
209// Unfortunately, we cannot pass the certificates directly to etcd.
210func (s *Service) WriteCertificateFiles(certs *api.ConsensusCertificates) error {
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100211 if err := ioutil.WriteFile(filepath.Join(s.config.DataDir, CRLPath), certs.Crl, 0600); err != nil {
212 return err
213 }
214 if err := ioutil.WriteFile(filepath.Join(s.config.DataDir, CertPath),
215 pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certs.Cert}), 0600); err != nil {
216 return err
217 }
218 if err := ioutil.WriteFile(filepath.Join(s.config.DataDir, KeyPath),
219 pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: certs.Key}), 0600); err != nil {
220 return err
221 }
222 if err := ioutil.WriteFile(filepath.Join(s.config.DataDir, CAPath),
223 pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certs.Ca}), 0600); err != nil {
224 return err
225 }
226 return nil
227}
228
Leopold Schabel68c58752019-11-14 21:00:59 +0100229// PrecreateCA generates the etcd cluster certificate authority and writes it to local storage.
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100230func (s *Service) PrecreateCA() error {
231 // Provision an etcd CA
232 etcdRootCA, err := ca.New("Smalltown etcd Root CA")
233 if err != nil {
234 return err
235 }
236 cert, privkey, err := etcdRootCA.IssueCertificate(s.config.ExternalHost)
237 if err != nil {
238 return fmt.Errorf("failed to self-issue a certificate: %w", err)
239 }
240 if err := os.MkdirAll(s.config.DataDir, 0700); err != nil {
241 return fmt.Errorf("failed to create consensus data dir: %w", err)
242 }
243 // Preserve certificate for later injection
244 s.bootstrapCert = cert
Leopold Schabel68c58752019-11-14 21:00:59 +0100245 if err := s.WriteCertificateFiles(&api.ConsensusCertificates{
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100246 Ca: etcdRootCA.CACertRaw,
247 Crl: etcdRootCA.CRLRaw,
248 Cert: cert,
249 Key: privkey,
250 }); err != nil {
251 return fmt.Errorf("failed to setup certificates: %w", err)
252 }
253 s.bootstrapCA = etcdRootCA
254 return nil
255}
256
257const (
Lorenz Brun6e8f69c2019-11-18 10:44:24 +0100258 caPathEtcd = "/etcd-ca/ca.der"
259 caKeyPathEtcd = "/etcd-ca/ca-key.der"
260 crlPathEtcd = "/etcd-ca/crl.der"
Leopold Schabel68c58752019-11-14 21:00:59 +0100261
262 // This prefix stores the individual certs the etcd CA has issued.
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100263 certPrefixEtcd = "/etcd-ca/certs"
264)
265
Leopold Schabel68c58752019-11-14 21:00:59 +0100266// InjectCA copies the CA from data cached during PrecreateCA to etcd.
267// Requires a previous call to PrecreateCA.
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100268func (s *Service) InjectCA() error {
Leopold Schabel68c58752019-11-14 21:00:59 +0100269 if s.bootstrapCA == nil || s.bootstrapCert == nil {
270 panic("bootstrapCA or bootstrapCert are nil - missing PrecreateCA call?")
271 }
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100272 if _, err := s.kv.Put(context.Background(), caPathEtcd, string(s.bootstrapCA.CACertRaw)); err != nil {
273 return err
274 }
Leopold Schabel68c58752019-11-14 21:00:59 +0100275 // TODO(lorenz): Should be wrapped by the master key
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100276 if _, err := s.kv.Put(context.Background(), caKeyPathEtcd, string([]byte(*s.bootstrapCA.PrivateKey))); err != nil {
277 return err
278 }
279 if _, err := s.kv.Put(context.Background(), crlPathEtcd, string(s.bootstrapCA.CRLRaw)); err != nil {
280 return err
281 }
282 certVal, err := x509.ParseCertificate(s.bootstrapCert)
283 if err != nil {
284 return err
285 }
286 serial := hex.EncodeToString(certVal.SerialNumber.Bytes())
287 if _, err := s.kv.Put(context.Background(), path.Join(certPrefixEtcd, serial), string(s.bootstrapCert)); err != nil {
288 return fmt.Errorf("failed to persist certificate: %w", err)
289 }
290 // Clear out bootstrap CA after injecting
291 s.bootstrapCA = nil
292 s.bootstrapCert = []byte{}
293 return nil
294}
295
296func (s *Service) etcdGetSingle(path string) ([]byte, int64, error) {
297 res, err := s.kv.Get(context.Background(), path)
298 if err != nil {
299 return nil, -1, fmt.Errorf("failed to get key from etcd: %w", err)
300 }
301 if len(res.Kvs) != 1 {
Leopold Schabel68c58752019-11-14 21:00:59 +0100302 return nil, -1, errors.New("key not available or multiple keys returned")
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100303 }
304 return res.Kvs[0].Value, res.Kvs[0].ModRevision, nil
305}
306
Leopold Schabel68c58752019-11-14 21:00:59 +0100307func (s *Service) getCAFromEtcd() (*ca.CA, int64, error) {
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100308 // TODO: Technically this could be done in a single request, but it's more logic
309 caCert, _, err := s.etcdGetSingle(caPathEtcd)
310 if err != nil {
311 return nil, -1, fmt.Errorf("failed to get CA certificate from etcd: %w", err)
312 }
313 caKey, _, err := s.etcdGetSingle(caKeyPathEtcd)
314 if err != nil {
315 return nil, -1, fmt.Errorf("failed to get CA key from etcd: %w", err)
316 }
317 // TODO: Unwrap CA key once wrapping is implemented
318 crl, crlRevision, err := s.etcdGetSingle(crlPathEtcd)
319 if err != nil {
320 return nil, -1, fmt.Errorf("failed to get CRL from etcd: %w", err)
321 }
322 idCA, err := ca.FromCertificates(caCert, caKey, crl)
323 if err != nil {
324 return nil, -1, fmt.Errorf("failed to take CA online: %w", err)
325 }
326 return idCA, crlRevision, nil
327}
328
329func (s *Service) IssueCertificate(hostname string) (*api.ConsensusCertificates, error) {
Leopold Schabel68c58752019-11-14 21:00:59 +0100330 idCA, _, err := s.getCAFromEtcd()
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100331 if err != nil {
332 return nil, err
333 }
334 cert, key, err := idCA.IssueCertificate(hostname)
335 if err != nil {
336 return nil, fmt.Errorf("failed to issue certificate: %w", err)
337 }
338 certVal, err := x509.ParseCertificate(cert)
339 if err != nil {
340 return nil, err
341 }
342 serial := hex.EncodeToString(certVal.SerialNumber.Bytes())
343 if _, err := s.kv.Put(context.Background(), path.Join(certPrefixEtcd, serial), string(cert)); err != nil {
Leopold Schabel68c58752019-11-14 21:00:59 +0100344 // We issued a certificate, but failed to persist it. Return an error and forget it ever happened.
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100345 return nil, fmt.Errorf("failed to persist certificate: %w", err)
346 }
347 return &api.ConsensusCertificates{
348 Ca: idCA.CACertRaw,
349 Cert: cert,
350 Crl: idCA.CRLRaw,
351 Key: key,
352 }, nil
353}
354
355func (s *Service) RevokeCertificate(hostname string) error {
356 rand.Seed(time.Now().UnixNano())
357 for {
Leopold Schabel68c58752019-11-14 21:00:59 +0100358 idCA, crlRevision, err := s.getCAFromEtcd()
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100359 if err != nil {
360 return err
361 }
362 allIssuedCerts, err := s.kv.Get(context.Background(), certPrefixEtcd, clientv3.WithPrefix())
363 for _, cert := range allIssuedCerts.Kvs {
364 certVal, err := x509.ParseCertificate(cert.Value)
365 if err != nil {
366 s.Logger.Error("Failed to parse previously issued certificate, this is a security risk", zap.Error(err))
367 continue
368 }
369 for _, dnsName := range certVal.DNSNames {
370 if dnsName == hostname {
371 // Revoke this
372 if err := idCA.Revoke(certVal.SerialNumber); err != nil {
373 // We need to fail if any single revocation fails otherwise outer applications
374 // have no chance of calling this safely
375 return err
376 }
377 }
378 }
379 }
Leopold Schabel68c58752019-11-14 21:00:59 +0100380 // TODO(leo): this needs a test
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100381 cmp := clientv3.Compare(clientv3.ModRevision(crlPathEtcd), "=", crlRevision)
382 op := clientv3.OpPut(crlPathEtcd, string(idCA.CRLRaw))
383 res, err := s.kv.Txn(context.Background()).If(cmp).Then(op).Commit()
384 if err != nil {
385 return fmt.Errorf("failed to persist new CRL in etcd: %w", err)
386 }
387 if res.Succeeded { // Transaction has succeeded
388 break
389 }
390 // Sleep a random duration between 0 and 300ms to reduce serialization failures
391 time.Sleep(time.Duration(rand.Intn(300)) * time.Millisecond)
392 }
393 return nil
394}
395
396func (s *Service) watchCRL() {
Leopold Schabel68c58752019-11-14 21:00:59 +0100397 // TODO(lorenz): Change etcd client to WatchableKV and make this an actual watch
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100398 // This needs changes in more places, so leaving it now
399 s.watchCRLTicker = time.NewTicker(30 * time.Second)
400 for range s.watchCRLTicker.C {
401 crl, _, err := s.etcdGetSingle(crlPathEtcd)
402 if err != nil {
403 s.Logger.Warn("Failed to check for new CRL", zap.Error(err))
404 continue
405 }
406 // This is cryptographic material but not secret, so no constant time compare necessary here
407 if !bytes.Equal(crl, s.lastCRL) {
408 if err := ioutil.WriteFile(filepath.Join(s.config.DataDir, CRLSwapPath), crl, 0600); err != nil {
409 s.Logger.Warn("Failed to write updated CRL", zap.Error(err))
410 }
411 // This uses unix.Rename to guarantee a particular atomic update behavior
412 if err := unix.Rename(filepath.Join(s.config.DataDir, CRLSwapPath), filepath.Join(s.config.DataDir, CRLPath)); err != nil {
413 s.Logger.Warn("Failed to atomically swap updated CRL", zap.Error(err))
414 }
415 }
416 }
417}
418
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200419func (s *Service) OnStop() error {
Lorenz Bruna4ea9d02019-10-31 11:40:30 +0100420 s.watchCRLTicker.Stop()
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200421 s.etcd.Close()
422
423 return nil
424}
425
426// IsProvisioned returns whether the node has been setup before and etcd has a data directory
427func (s *Service) IsProvisioned() bool {
428 _, err := os.Stat(s.config.DataDir)
429
430 return !os.IsNotExist(err)
431}
432
433// IsReady returns whether etcd is ready and synced
434func (s *Service) IsReady() bool {
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200435 return s.ready.Load()
Hendrik Hofstadt0d7c91e2019-10-23 21:44:47 +0200436}
437
438// AddMember adds a new etcd member to the cluster
439func (s *Service) AddMember(ctx context.Context, name string, url string) (uint64, error) {
440 urls, err := types.NewURLs([]string{url})
441 if err != nil {
442 return 0, err
443 }
444
445 member := membership.NewMember(name, urls, DefaultClusterToken, nil)
446
447 _, err = s.etcd.Server.AddMember(ctx, *member)
448 if err != nil {
449 return 0, err
450 }
451
452 return uint64(member.ID), nil
453}
454
455// RemoveMember removes a member from the etcd cluster
456func (s *Service) RemoveMember(ctx context.Context, id uint64) error {
457 _, err := s.etcd.Server.RemoveMember(ctx, id)
458 return err
459}
460
461// Health returns the current cluster health
462func (s *Service) Health() {
463}
464
465// GetConfig returns the current consensus config
466func (s *Service) GetConfig() Config {
467 return *s.config
468}
469
470// SetConfig sets the consensus config. Changes are only applied when the service is restarted.
471func (s *Service) SetConfig(config Config) {
472 s.config = &config
473}
474
475// GetInitialClusterString returns the InitialCluster string that can be used to bootstrap a consensus node
476func (s *Service) GetInitialClusterString() string {
477 members := s.etcd.Server.Cluster().Members()
478 clusterString := strings.Builder{}
479
480 for i, m := range members {
481 if i != 0 {
482 clusterString.WriteString(",")
483 }
484 clusterString.WriteString(m.Name)
485 clusterString.WriteString("=")
486 clusterString.WriteString(m.PickPeerURL())
487 }
488
489 return clusterString.String()
490}
491
492// GetNodes returns a list of consensus nodes
493func (s *Service) GetNodes() []Member {
494 members := s.etcd.Server.Cluster().Members()
495 cMembers := make([]Member, len(members))
496 for i, m := range members {
497 cMembers[i] = Member{
498 ID: uint64(m.ID),
499 Name: m.Name,
500 Address: m.PickPeerURL(),
501 Synced: !m.IsLearner,
502 }
503 }
504
505 return cMembers
506}
507
508func (s *Service) GetStore(module, space string) clientv3.KV {
509 return namespace.NewKV(s.kv, fmt.Sprintf("%s:%s", module, space))
510}