Merge pull request #711 from hashicorp/f-scada

Add support for SCADA for Atlas Integration
This commit is contained in:
Armon Dadgar 2015-02-18 16:54:50 -08:00
commit 7316b5be32
17 changed files with 729 additions and 152 deletions

View File

@ -289,6 +289,49 @@ func (c *Client) doRequest(r *request) (time.Duration, *http.Response, error) {
return diff, resp, err return diff, resp, err
} }
// Query is used to do a GET request against an endpoint
// and deserialize the response into an interface using
// standard Consul conventions.
func (c *Client) query(endpoint string, out interface{}, q *QueryOptions) (*QueryMeta, error) {
r := c.newRequest("GET", endpoint)
r.setQueryOptions(q)
rtt, resp, err := requireOK(c.doRequest(r))
if err != nil {
return nil, err
}
defer resp.Body.Close()
qm := &QueryMeta{}
parseQueryMeta(resp, qm)
qm.RequestTime = rtt
if err := decodeBody(resp, out); err != nil {
return nil, err
}
return qm, nil
}
// write is used to do a PUT request against an endpoint
// and serialize/deserialized using the standard Consul conventions.
func (c *Client) write(endpoint string, in, out interface{}, q *WriteOptions) (*WriteMeta, error) {
r := c.newRequest("PUT", endpoint)
r.setWriteOptions(q)
r.obj = in
rtt, resp, err := requireOK(c.doRequest(r))
if err != nil {
return nil, err
}
defer resp.Body.Close()
wm := &WriteMeta{RequestTime: rtt}
if out != nil {
if err := decodeBody(resp, &out); err != nil {
return nil, err
}
}
return wm, nil
}
// parseQueryMeta is used to help parse query meta-data // parseQueryMeta is used to help parse query meta-data
func parseQueryMeta(resp *http.Response, q *QueryMeta) error { func parseQueryMeta(resp *http.Response, q *QueryMeta) error {
header := resp.Header header := resp.Header

24
api/raw.go Normal file
View File

@ -0,0 +1,24 @@
package api
// Raw can be used to do raw queries against custom endpoints
type Raw struct {
c *Client
}
// Raw returns a handle to query endpoints
func (c *Client) Raw() *Raw {
return &Raw{c}
}
// Query is used to do a GET request against an endpoint
// and deserialize the response into an interface using
// standard Consul conventions.
func (raw *Raw) Query(endpoint string, out interface{}, q *QueryOptions) (*QueryMeta, error) {
return raw.c.query(endpoint, out, q)
}
// Write is used to do a PUT request against an endpoint
// and serialize/deserialized using the standard Consul conventions.
func (raw *Raw) Write(endpoint string, in, out interface{}, q *WriteOptions) (*WriteMeta, error) {
return raw.c.write(endpoint, in, out, q)
}

View File

@ -93,18 +93,9 @@ func (s *Session) Create(se *SessionEntry, q *WriteOptions) (string, *WriteMeta,
} }
func (s *Session) create(obj interface{}, q *WriteOptions) (string, *WriteMeta, error) { func (s *Session) create(obj interface{}, q *WriteOptions) (string, *WriteMeta, error) {
r := s.c.newRequest("PUT", "/v1/session/create")
r.setWriteOptions(q)
r.obj = obj
rtt, resp, err := requireOK(s.c.doRequest(r))
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
wm := &WriteMeta{RequestTime: rtt}
var out struct{ ID string } var out struct{ ID string }
if err := decodeBody(resp, &out); err != nil { wm, err := s.c.write("/v1/session/create", obj, &out, q)
if err != nil {
return "", nil, err return "", nil, err
} }
return out.ID, wm, nil return out.ID, wm, nil
@ -112,35 +103,20 @@ func (s *Session) create(obj interface{}, q *WriteOptions) (string, *WriteMeta,
// Destroy invalides a given session // Destroy invalides a given session
func (s *Session) Destroy(id string, q *WriteOptions) (*WriteMeta, error) { func (s *Session) Destroy(id string, q *WriteOptions) (*WriteMeta, error) {
r := s.c.newRequest("PUT", "/v1/session/destroy/"+id) wm, err := s.c.write("/v1/session/destroy/"+id, nil, nil, q)
r.setWriteOptions(q)
rtt, resp, err := requireOK(s.c.doRequest(r))
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp.Body.Close()
wm := &WriteMeta{RequestTime: rtt}
return wm, nil return wm, nil
} }
// Renew renews the TTL on a given session // Renew renews the TTL on a given session
func (s *Session) Renew(id string, q *WriteOptions) (*SessionEntry, *WriteMeta, error) { func (s *Session) Renew(id string, q *WriteOptions) (*SessionEntry, *WriteMeta, error) {
r := s.c.newRequest("PUT", "/v1/session/renew/"+id) var entries []*SessionEntry
r.setWriteOptions(q) wm, err := s.c.write("/v1/session/renew/"+id, nil, &entries, q)
rtt, resp, err := requireOK(s.c.doRequest(r))
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
defer resp.Body.Close()
wm := &WriteMeta{RequestTime: rtt}
var entries []*SessionEntry
if err := decodeBody(resp, &entries); err != nil {
return nil, wm, err
}
if len(entries) > 0 { if len(entries) > 0 {
return entries[0], wm, nil return entries[0], wm, nil
} }
@ -179,23 +155,11 @@ func (s *Session) RenewPeriodic(initialTTL string, id string, q *WriteOptions, d
// Info looks up a single session // Info looks up a single session
func (s *Session) Info(id string, q *QueryOptions) (*SessionEntry, *QueryMeta, error) { func (s *Session) Info(id string, q *QueryOptions) (*SessionEntry, *QueryMeta, error) {
r := s.c.newRequest("GET", "/v1/session/info/"+id) var entries []*SessionEntry
r.setQueryOptions(q) qm, err := s.c.query("/v1/session/info/"+id, &entries, q)
rtt, resp, err := requireOK(s.c.doRequest(r))
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
defer resp.Body.Close()
qm := &QueryMeta{}
parseQueryMeta(resp, qm)
qm.RequestTime = rtt
var entries []*SessionEntry
if err := decodeBody(resp, &entries); err != nil {
return nil, nil, err
}
if len(entries) > 0 { if len(entries) > 0 {
return entries[0], qm, nil return entries[0], qm, nil
} }
@ -204,20 +168,9 @@ func (s *Session) Info(id string, q *QueryOptions) (*SessionEntry, *QueryMeta, e
// List gets sessions for a node // List gets sessions for a node
func (s *Session) Node(node string, q *QueryOptions) ([]*SessionEntry, *QueryMeta, error) { func (s *Session) Node(node string, q *QueryOptions) ([]*SessionEntry, *QueryMeta, error) {
r := s.c.newRequest("GET", "/v1/session/node/"+node)
r.setQueryOptions(q)
rtt, resp, err := requireOK(s.c.doRequest(r))
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
qm := &QueryMeta{}
parseQueryMeta(resp, qm)
qm.RequestTime = rtt
var entries []*SessionEntry var entries []*SessionEntry
if err := decodeBody(resp, &entries); err != nil { qm, err := s.c.query("/v1/session/node/"+node, &entries, q)
if err != nil {
return nil, nil, err return nil, nil, err
} }
return entries, qm, nil return entries, qm, nil
@ -225,20 +178,9 @@ func (s *Session) Node(node string, q *QueryOptions) ([]*SessionEntry, *QueryMet
// List gets all active sessions // List gets all active sessions
func (s *Session) List(q *QueryOptions) ([]*SessionEntry, *QueryMeta, error) { func (s *Session) List(q *QueryOptions) ([]*SessionEntry, *QueryMeta, error) {
r := s.c.newRequest("GET", "/v1/session/list")
r.setQueryOptions(q)
rtt, resp, err := requireOK(s.c.doRequest(r))
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
qm := &QueryMeta{}
parseQueryMeta(resp, qm)
qm.RequestTime = rtt
var entries []*SessionEntry var entries []*SessionEntry
if err := decodeBody(resp, &entries); err != nil { qm, err := s.c.query("/v1/session/list", &entries, q)
if err != nil {
return nil, nil, err return nil, nil, err
} }
return entries, qm, nil return entries, qm, nil

View File

@ -19,6 +19,7 @@ import (
"github.com/hashicorp/go-checkpoint" "github.com/hashicorp/go-checkpoint"
"github.com/hashicorp/go-syslog" "github.com/hashicorp/go-syslog"
"github.com/hashicorp/logutils" "github.com/hashicorp/logutils"
scada "github.com/hashicorp/scada-client"
"github.com/mitchellh/cli" "github.com/mitchellh/cli"
) )
@ -45,6 +46,7 @@ type Command struct {
rpcServer *AgentRPC rpcServer *AgentRPC
httpServers []*HTTPServer httpServers []*HTTPServer
dnsServer *DNSServer dnsServer *DNSServer
scadaProvider *scada.Provider
} }
// readConfig is responsible for setup of our configuration using // readConfig is responsible for setup of our configuration using
@ -76,6 +78,10 @@ func (c *Command) readConfig() *Config {
cmdFlags.StringVar(&cmdConfig.BindAddr, "bind", "", "address to bind server listeners to") cmdFlags.StringVar(&cmdConfig.BindAddr, "bind", "", "address to bind server listeners to")
cmdFlags.StringVar(&cmdConfig.AdvertiseAddr, "advertise", "", "address to advertise instead of bind addr") cmdFlags.StringVar(&cmdConfig.AdvertiseAddr, "advertise", "", "address to advertise instead of bind addr")
cmdFlags.StringVar(&cmdConfig.AtlasInfrastructure, "atlas", "", "infrastructure name in Atlas")
cmdFlags.StringVar(&cmdConfig.AtlasToken, "atlas-token", "", "authentication token for Atlas")
cmdFlags.BoolVar(&cmdConfig.AtlasJoin, "atlas-join", false, "auto-join with Atlas")
cmdFlags.IntVar(&cmdConfig.Protocol, "protocol", -1, "protocol version") cmdFlags.IntVar(&cmdConfig.Protocol, "protocol", -1, "protocol version")
cmdFlags.BoolVar(&cmdConfig.EnableSyslog, "syslog", false, cmdFlags.BoolVar(&cmdConfig.EnableSyslog, "syslog", false,
@ -327,8 +333,21 @@ func (c *Command) setupAgent(config *Config, logOutput io.Writer, logWriter *log
c.Ui.Output("Starting Consul agent RPC...") c.Ui.Output("Starting Consul agent RPC...")
c.rpcServer = NewAgentRPC(agent, rpcListener, logOutput, logWriter) c.rpcServer = NewAgentRPC(agent, rpcListener, logOutput, logWriter)
if config.Ports.HTTP > 0 || config.Ports.HTTPS > 0 { // Enable the SCADA integration
servers, err := NewHTTPServers(agent, config, logOutput) var scadaList net.Listener
if config.AtlasInfrastructure != "" {
provider, list, err := NewProvider(config, logOutput)
if err != nil {
agent.Shutdown()
c.Ui.Error(fmt.Sprintf("Error starting SCADA connection: %s", err))
return err
}
c.scadaProvider = provider
scadaList = list
}
if config.Ports.HTTP > 0 || config.Ports.HTTPS > 0 || scadaList != nil {
servers, err := NewHTTPServers(agent, config, scadaList, logOutput)
if err != nil { if err != nil {
agent.Shutdown() agent.Shutdown()
c.Ui.Error(fmt.Sprintf("Error starting http servers: %s", err)) c.Ui.Error(fmt.Sprintf("Error starting http servers: %s", err))
@ -378,7 +397,6 @@ func (c *Command) setupAgent(config *Config, logOutput io.Writer, logWriter *log
c.checkpointResults(checkpoint.Check(updateParams)) c.checkpointResults(checkpoint.Check(updateParams))
}() }()
} }
return nil return nil
} }
@ -586,10 +604,12 @@ func (c *Command) Run(args []string) int {
if c.dnsServer != nil { if c.dnsServer != nil {
defer c.dnsServer.Shutdown() defer c.dnsServer.Shutdown()
} }
for _, server := range c.httpServers { for _, server := range c.httpServers {
defer server.Shutdown() defer server.Shutdown()
} }
if c.scadaProvider != nil {
defer c.scadaProvider.Shutdown()
}
// Join startup nodes if specified // Join startup nodes if specified
if err := c.startupJoin(config); err != nil { if err := c.startupJoin(config); err != nil {
@ -628,6 +648,12 @@ func (c *Command) Run(args []string) int {
gossipEncrypted = c.agent.client.Encrypted() gossipEncrypted = c.agent.client.Encrypted()
} }
// Determine the Atlas cluster
atlas := "<disabled>"
if config.AtlasInfrastructure != "" {
atlas = fmt.Sprintf("(Infrastructure: '%s' Join: %v)", config.AtlasInfrastructure, config.AtlasJoin)
}
// Let the agent know we've finished registration // Let the agent know we've finished registration
c.agent.StartSync() c.agent.StartSync()
@ -641,6 +667,7 @@ func (c *Command) Run(args []string) int {
config.Ports.SerfLan, config.Ports.SerfWan)) config.Ports.SerfLan, config.Ports.SerfWan))
c.Ui.Info(fmt.Sprintf("Gossip encrypt: %v, RPC-TLS: %v, TLS-Incoming: %v", c.Ui.Info(fmt.Sprintf("Gossip encrypt: %v, RPC-TLS: %v, TLS-Incoming: %v",
gossipEncrypted, config.VerifyOutgoing, config.VerifyIncoming)) gossipEncrypted, config.VerifyOutgoing, config.VerifyIncoming))
c.Ui.Info(fmt.Sprintf(" Atlas: %s", atlas))
// Enable log streaming // Enable log streaming
c.Ui.Info("") c.Ui.Info("")
@ -815,6 +842,9 @@ Usage: consul agent [options]
Options: Options:
-advertise=addr Sets the advertise address to use -advertise=addr Sets the advertise address to use
-atlas=org/name Sets the Atlas infrastructure name, enables SCADA.
-atlas-join Enables auto-joining the Atlas cluster
-atlas-token=token Provides the Atlas API token
-bootstrap Sets server to bootstrap mode -bootstrap Sets server to bootstrap mode
-bind=0.0.0.0 Sets the bind address for cluster communication -bind=0.0.0.0 Sets the bind address for cluster communication
-bootstrap-expect=0 Sets server to expect bootstrap mode. -bootstrap-expect=0 Sets server to expect bootstrap mode.

View File

@ -318,6 +318,23 @@ type Config struct {
// HTTPAPIResponseHeaders are used to add HTTP header response fields to the HTTP API responses. // HTTPAPIResponseHeaders are used to add HTTP header response fields to the HTTP API responses.
HTTPAPIResponseHeaders map[string]string `mapstructure:"http_api_response_headers"` HTTPAPIResponseHeaders map[string]string `mapstructure:"http_api_response_headers"`
// AtlasInfrastructure is the name of the infrastructure we belong to. e.g. hashicorp/stage
AtlasInfrastructure string `mapstructure:"atlas_infrastructure"`
// AtlasToken is our authentication token from Atlas
AtlasToken string `mapstructure:"atlas_token" json:"-"`
// AtlasACLToken is applied to inbound requests if no other token
// is provided. This takes higher precedence than the ACLToken.
// Without this, the ACLToken is used. If that is not specified either,
// then the 'anonymous' token is used. This can be set to 'anonymous'
// to reduce the Atlas privileges to below that of the ACLToken.
AtlasACLToken string `mapstructure:"atlas_acl_token" json:"-"`
// AtlasJoin controls if Atlas will attempt to auto-join the node
// to it's cluster. Requires Atlas integration.
AtlasJoin bool `mapstructure:"atlas_join"`
// AEInterval controls the anti-entropy interval. This is how often // AEInterval controls the anti-entropy interval. This is how often
// the agent attempts to reconcile it's local state with the server' // the agent attempts to reconcile it's local state with the server'
// representation of our state. Defaults to every 60s. // representation of our state. Defaults to every 60s.
@ -941,6 +958,18 @@ func MergeConfig(a, b *Config) *Config {
if b.UnixSockets.Perms != "" { if b.UnixSockets.Perms != "" {
result.UnixSockets.Perms = b.UnixSockets.Perms result.UnixSockets.Perms = b.UnixSockets.Perms
} }
if b.AtlasInfrastructure != "" {
result.AtlasInfrastructure = b.AtlasInfrastructure
}
if b.AtlasToken != "" {
result.AtlasToken = b.AtlasToken
}
if b.AtlasACLToken != "" {
result.AtlasACLToken = b.AtlasACLToken
}
if b.AtlasJoin {
result.AtlasJoin = true
}
if len(b.HTTPAPIResponseHeaders) != 0 { if len(b.HTTPAPIResponseHeaders) != 0 {
if result.HTTPAPIResponseHeaders == nil { if result.HTTPAPIResponseHeaders == nil {

View File

@ -633,6 +633,26 @@ func TestDecodeConfig(t *testing.T) {
if config.HTTPAPIResponseHeaders["X-XSS-Protection"] != "1; mode=block" { if config.HTTPAPIResponseHeaders["X-XSS-Protection"] != "1; mode=block" {
t.Fatalf("bad: %#v", config) t.Fatalf("bad: %#v", config)
} }
// Atlas configs
input = `{"atlas_infrastructure": "hashicorp/prod", "atlas_token": "abcdefg", "atlas_acl_token": "123456789", "atlas_join": true}`
config, err = DecodeConfig(bytes.NewReader([]byte(input)))
if err != nil {
t.Fatalf("err: %s", err)
}
if config.AtlasInfrastructure != "hashicorp/prod" {
t.Fatalf("bad: %#v", config)
}
if config.AtlasToken != "abcdefg" {
t.Fatalf("bad: %#v", config)
}
if config.AtlasACLToken != "123456789" {
t.Fatalf("bad: %#v", config)
}
if !config.AtlasJoin {
t.Fatalf("bad: %#v", config)
}
} }
func TestDecodeConfig_invalidKeys(t *testing.T) { func TestDecodeConfig_invalidKeys(t *testing.T) {
@ -1096,6 +1116,10 @@ func TestMergeConfig(t *testing.T) {
Perms: "0700", Perms: "0700",
}, },
}, },
AtlasInfrastructure: "hashicorp/prod",
AtlasToken: "123456789",
AtlasACLToken: "abcdefgh",
AtlasJoin: true,
} }
c := MergeConfig(a, b) c := MergeConfig(a, b)

View File

@ -19,6 +19,14 @@ import (
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
) )
var (
// scadaHTTPAddr is the address associated with the
// HTTPServer. When populating an ACL token for a request,
// this is checked to switch between the ACLToken and
// AtlasACLToken
scadaHTTPAddr = "SCADA"
)
// HTTPServer is used to wrap an Agent and expose various API's // HTTPServer is used to wrap an Agent and expose various API's
// in a RESTful manner // in a RESTful manner
type HTTPServer struct { type HTTPServer struct {
@ -32,15 +40,11 @@ type HTTPServer struct {
// NewHTTPServers starts new HTTP servers to provide an interface to // NewHTTPServers starts new HTTP servers to provide an interface to
// the agent. // the agent.
func NewHTTPServers(agent *Agent, config *Config, logOutput io.Writer) ([]*HTTPServer, error) { func NewHTTPServers(agent *Agent, config *Config, scada net.Listener, logOutput io.Writer) ([]*HTTPServer, error) {
var tlsConfig *tls.Config
var list net.Listener
var httpAddr net.Addr
var err error
var servers []*HTTPServer var servers []*HTTPServer
if config.Ports.HTTPS > 0 { if config.Ports.HTTPS > 0 {
httpAddr, err = config.ClientListener(config.Addresses.HTTPS, config.Ports.HTTPS) httpAddr, err := config.ClientListener(config.Addresses.HTTPS, config.Ports.HTTPS)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -54,7 +58,7 @@ func NewHTTPServers(agent *Agent, config *Config, logOutput io.Writer) ([]*HTTPS
NodeName: config.NodeName, NodeName: config.NodeName,
ServerName: config.ServerName} ServerName: config.ServerName}
tlsConfig, err = tlsConf.IncomingTLSConfig() tlsConfig, err := tlsConf.IncomingTLSConfig()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -64,7 +68,7 @@ func NewHTTPServers(agent *Agent, config *Config, logOutput io.Writer) ([]*HTTPS
return nil, fmt.Errorf("Failed to get Listen on %s: %v", httpAddr.String(), err) return nil, fmt.Errorf("Failed to get Listen on %s: %v", httpAddr.String(), err)
} }
list = tls.NewListener(tcpKeepAliveListener{ln.(*net.TCPListener)}, tlsConfig) list := tls.NewListener(tcpKeepAliveListener{ln.(*net.TCPListener)}, tlsConfig)
// Create the mux // Create the mux
mux := http.NewServeMux() mux := http.NewServeMux()
@ -86,7 +90,7 @@ func NewHTTPServers(agent *Agent, config *Config, logOutput io.Writer) ([]*HTTPS
} }
if config.Ports.HTTP > 0 { if config.Ports.HTTP > 0 {
httpAddr, err = config.ClientListener(config.Addresses.HTTP, config.Ports.HTTP) httpAddr, err := config.ClientListener(config.Addresses.HTTP, config.Ports.HTTP)
if err != nil { if err != nil {
return nil, fmt.Errorf("Failed to get ClientListener address:port: %v", err) return nil, fmt.Errorf("Failed to get ClientListener address:port: %v", err)
} }
@ -107,6 +111,7 @@ func NewHTTPServers(agent *Agent, config *Config, logOutput io.Writer) ([]*HTTPS
return nil, fmt.Errorf("Failed to get Listen on %s: %v", httpAddr.String(), err) return nil, fmt.Errorf("Failed to get Listen on %s: %v", httpAddr.String(), err)
} }
var list net.Listener
if isSocket { if isSocket {
// Set up ownership/permission bits on the socket file // Set up ownership/permission bits on the socket file
if err := setFilePermissions(socketPath, config.UnixSockets); err != nil { if err := setFilePermissions(socketPath, config.UnixSockets); err != nil {
@ -136,6 +141,26 @@ func NewHTTPServers(agent *Agent, config *Config, logOutput io.Writer) ([]*HTTPS
servers = append(servers, srv) servers = append(servers, srv)
} }
if scada != nil {
// Create the mux
mux := http.NewServeMux()
// Create the server
srv := &HTTPServer{
agent: agent,
mux: mux,
listener: scada,
logger: log.New(logOutput, "", log.LstdFlags),
uiDir: config.UiDir,
addr: scadaHTTPAddr,
}
srv.registerHandlers(false) // Never allow debug for SCADA
// Start the server
go http.Serve(scada, mux)
servers = append(servers, srv)
}
return servers, nil return servers, nil
} }
@ -159,7 +184,7 @@ func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) {
// Shutdown is used to shutdown the HTTP server // Shutdown is used to shutdown the HTTP server
func (s *HTTPServer) Shutdown() { func (s *HTTPServer) Shutdown() {
if s != nil { if s != nil {
s.logger.Printf("[DEBUG] http: Shutting down http server(%v)", s.addr) s.logger.Printf("[DEBUG] http: Shutting down http server (%v)", s.addr)
s.listener.Close() s.listener.Close()
} }
} }
@ -241,7 +266,10 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) {
if s.uiDir != "" { if s.uiDir != "" {
// Static file serving done from /ui/ // Static file serving done from /ui/
s.mux.Handle("/ui/", http.StripPrefix("/ui/", http.FileServer(http.Dir(s.uiDir)))) s.mux.Handle("/ui/", http.StripPrefix("/ui/", http.FileServer(http.Dir(s.uiDir))))
}
// Enable the special endpoints for UI or SCADA
if s.uiDir != "" || s.agent.config.AtlasInfrastructure != "" {
// API's are under /internal/ui/ to avoid conflict // API's are under /internal/ui/ to avoid conflict
s.mux.HandleFunc("/v1/internal/ui/nodes", s.wrap(s.UINodes)) s.mux.HandleFunc("/v1/internal/ui/nodes", s.wrap(s.UINodes))
s.mux.HandleFunc("/v1/internal/ui/node/", s.wrap(s.UINodeInfo)) s.mux.HandleFunc("/v1/internal/ui/node/", s.wrap(s.UINodeInfo))
@ -422,9 +450,17 @@ func (s *HTTPServer) parseDC(req *http.Request, dc *string) {
func (s *HTTPServer) parseToken(req *http.Request, token *string) { func (s *HTTPServer) parseToken(req *http.Request, token *string) {
if other := req.URL.Query().Get("token"); other != "" { if other := req.URL.Query().Get("token"); other != "" {
*token = other *token = other
} else if *token == "" { return
*token = s.agent.config.ACLToken
} }
// Set the AtlasACLToken if SCADA
if s.addr == scadaHTTPAddr && s.agent.config.AtlasACLToken != "" {
*token = s.agent.config.AtlasACLToken
return
}
// Set the default ACLToken
*token = s.agent.config.ACLToken
} }
// parse is a convenience method for endpoints that need // parse is a convenience method for endpoints that need

View File

@ -36,7 +36,7 @@ func makeHTTPServerWithConfig(t *testing.T, cb func(c *Config)) (string, *HTTPSe
t.Fatalf("err: %v", err) t.Fatalf("err: %v", err)
} }
conf.UiDir = uiDir conf.UiDir = uiDir
servers, err := NewHTTPServers(agent, conf, agent.logOutput) servers, err := NewHTTPServers(agent, conf, nil, agent.logOutput)
if err != nil { if err != nil {
t.Fatalf("err: %v", err) t.Fatalf("err: %v", err)
} }
@ -146,7 +146,7 @@ func TestHTTPServer_UnixSocket_FileExists(t *testing.T) {
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
// Try to start the server with the same path anyways. // Try to start the server with the same path anyways.
if _, err := NewHTTPServers(agent, conf, agent.logOutput); err != nil { if _, err := NewHTTPServers(agent, conf, nil, agent.logOutput); err != nil {
t.Fatalf("err: %s", err) t.Fatalf("err: %s", err)
} }
@ -429,6 +429,67 @@ func TestParseConsistency_Invalid(t *testing.T) {
} }
} }
// Test ACL token is resolved in correct order
func TestACLResolution(t *testing.T) {
var token string
// Request without token
req, err := http.NewRequest("GET",
"/v1/catalog/nodes", nil)
if err != nil {
t.Fatalf("err: %v", err)
}
// Request with explicit token
reqToken, err := http.NewRequest("GET",
"/v1/catalog/nodes?token=foo", nil)
if err != nil {
t.Fatalf("err: %v", err)
}
httpTest(t, func(srv *HTTPServer) {
// Check when no token is set
srv.agent.config.ACLToken = ""
srv.parseToken(req, &token)
if token != "" {
t.Fatalf("bad: %s", token)
}
// Check when ACLToken set
srv.agent.config.ACLToken = "agent"
srv.parseToken(req, &token)
if token != "agent" {
t.Fatalf("bad: %s", token)
}
// Check when AtlasACLToken set, wrong server
srv.agent.config.AtlasACLToken = "atlas"
srv.parseToken(req, &token)
if token != "agent" {
t.Fatalf("bad: %s", token)
}
// Check when AtlasACLToken set, correct server
srv.addr = scadaHTTPAddr
srv.parseToken(req, &token)
if token != "atlas" {
t.Fatalf("bad: %s", token)
}
// Check when AtlasACLToken not, correct server
srv.agent.config.AtlasACLToken = ""
srv.parseToken(req, &token)
if token != "agent" {
t.Fatalf("bad: %s", token)
}
// Explicit token has highest precedence
srv.parseToken(reqToken, &token)
if token != "foo" {
t.Fatalf("bad: %s", token)
}
})
}
// assertIndex tests that X-Consul-Index is set and non-zero // assertIndex tests that X-Consul-Index is set and non-zero
func assertIndex(t *testing.T, resp *httptest.ResponseRecorder) { func assertIndex(t *testing.T, resp *httptest.ResponseRecorder) {
header := resp.Header().Get("X-Consul-Index") header := resp.Header().Get("X-Consul-Index")

192
command/agent/scada.go Normal file
View File

@ -0,0 +1,192 @@
package agent
import (
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"os"
"strconv"
"sync"
"time"
"github.com/hashicorp/scada-client"
)
const (
// providerService is the service name we use
providerService = "consul"
// resourceType is the type of resource we represent
// when connecting to SCADA
resourceType = "infrastructures"
)
// ProviderService returns the service information for the provider
func ProviderService(c *Config) *client.ProviderService {
return &client.ProviderService{
Service: providerService,
ServiceVersion: fmt.Sprintf("%s%s", c.Version, c.VersionPrerelease),
Capabilities: map[string]int{
"http": 1,
},
Meta: map[string]string{
"auto-join": strconv.FormatBool(c.AtlasJoin),
"datacenter": c.Datacenter,
"server": strconv.FormatBool(c.Server),
},
ResourceType: resourceType,
}
}
// ProviderConfig returns the configuration for the SCADA provider
func ProviderConfig(c *Config) *client.ProviderConfig {
return &client.ProviderConfig{
Service: ProviderService(c),
Handlers: map[string]client.CapabilityProvider{
"http": nil,
},
ResourceGroup: c.AtlasInfrastructure,
Token: c.AtlasToken,
}
}
// NewProvider creates a new SCADA provider using the
// given configuration. Requests for the HTTP capability
// are passed off to the listener that is returned.
func NewProvider(c *Config, logOutput io.Writer) (*client.Provider, net.Listener, error) {
// Get the configuration of the provider
config := ProviderConfig(c)
config.LogOutput = logOutput
// SCADA_INSECURE env variable is used for testing to disable
// TLS certificate verification.
if os.Getenv("SCADA_INSECURE") != "" {
config.TLSConfig = &tls.Config{
InsecureSkipVerify: true,
}
}
// Create an HTTP listener and handler
list := newScadaListener(c.AtlasInfrastructure)
config.Handlers["http"] = func(capability string, meta map[string]string,
conn io.ReadWriteCloser) error {
return list.PushRWC(conn)
}
// Create the provider
provider, err := client.NewProvider(config)
if err != nil {
list.Close()
return nil, nil, err
}
return provider, list, nil
}
// scadaListener is used to return a net.Listener for
// incoming SCADA connections
type scadaListener struct {
addr *scadaAddr
pending chan net.Conn
closed bool
closedCh chan struct{}
l sync.Mutex
}
// newScadaListener returns a new listener
func newScadaListener(infra string) *scadaListener {
l := &scadaListener{
addr: &scadaAddr{infra},
pending: make(chan net.Conn),
closedCh: make(chan struct{}),
}
return l
}
// PushRWC is used to push a io.ReadWriteCloser as a net.Conn
func (s *scadaListener) PushRWC(conn io.ReadWriteCloser) error {
// Check if this already implements net.Conn
if nc, ok := conn.(net.Conn); ok {
return s.Push(nc)
}
// Wrap to implement the interface
wrapped := &scadaRWC{conn, s.addr}
return s.Push(wrapped)
}
// Push is used to add a connection to the queu
func (s *scadaListener) Push(conn net.Conn) error {
select {
case s.pending <- conn:
return nil
case <-time.After(time.Second):
return fmt.Errorf("accept timed out")
case <-s.closedCh:
return fmt.Errorf("scada listener closed")
}
}
func (s *scadaListener) Accept() (net.Conn, error) {
select {
case conn := <-s.pending:
return conn, nil
case <-s.closedCh:
return nil, fmt.Errorf("scada listener closed")
}
}
func (s *scadaListener) Close() error {
s.l.Lock()
defer s.l.Unlock()
if s.closed {
return nil
}
s.closed = true
close(s.closedCh)
return nil
}
func (s *scadaListener) Addr() net.Addr {
return s.addr
}
// scadaAddr is used to return a net.Addr for SCADA
type scadaAddr struct {
infra string
}
func (s *scadaAddr) Network() string {
return "SCADA"
}
func (s *scadaAddr) String() string {
return fmt.Sprintf("SCADA::Atlas::%s", s.infra)
}
type scadaRWC struct {
io.ReadWriteCloser
addr *scadaAddr
}
func (s *scadaRWC) LocalAddr() net.Addr {
return s.addr
}
func (s *scadaRWC) RemoteAddr() net.Addr {
return s.addr
}
func (s *scadaRWC) SetDeadline(t time.Time) error {
return errors.New("SCADA.Conn does not support deadlines")
}
func (s *scadaRWC) SetReadDeadline(t time.Time) error {
return errors.New("SCADA.Conn does not support deadlines")
}
func (s *scadaRWC) SetWriteDeadline(t time.Time) error {
return errors.New("SCADA.Conn does not support deadlines")
}

104
command/agent/scada_test.go Normal file
View File

@ -0,0 +1,104 @@
package agent
import (
"net"
"reflect"
"testing"
"github.com/hashicorp/scada-client"
)
func TestProviderService(t *testing.T) {
conf := DefaultConfig()
conf.Version = "0.5.0"
conf.VersionPrerelease = "rc1"
conf.AtlasJoin = true
conf.Server = true
ps := ProviderService(conf)
expect := &client.ProviderService{
Service: "consul",
ServiceVersion: "0.5.0rc1",
Capabilities: map[string]int{
"http": 1,
},
Meta: map[string]string{
"auto-join": "true",
"datacenter": "dc1",
"server": "true",
},
ResourceType: "infrastructures",
}
if !reflect.DeepEqual(ps, expect) {
t.Fatalf("bad: %v", ps)
}
}
func TestProviderConfig(t *testing.T) {
conf := DefaultConfig()
conf.Version = "0.5.0"
conf.VersionPrerelease = "rc1"
conf.AtlasJoin = true
conf.Server = true
conf.AtlasInfrastructure = "armon/test"
conf.AtlasToken = "foobarbaz"
pc := ProviderConfig(conf)
expect := &client.ProviderConfig{
Service: &client.ProviderService{
Service: "consul",
ServiceVersion: "0.5.0rc1",
Capabilities: map[string]int{
"http": 1,
},
Meta: map[string]string{
"auto-join": "true",
"datacenter": "dc1",
"server": "true",
},
ResourceType: "infrastructures",
},
Handlers: map[string]client.CapabilityProvider{
"http": nil,
},
ResourceGroup: "armon/test",
Token: "foobarbaz",
}
if !reflect.DeepEqual(pc, expect) {
t.Fatalf("bad: %v", pc)
}
}
func TestSCADAListener(t *testing.T) {
list := newScadaListener("armon/test")
defer list.Close()
var raw interface{} = list
_, ok := raw.(net.Listener)
if !ok {
t.Fatalf("bad")
}
a, b := net.Pipe()
defer a.Close()
defer b.Close()
go list.Push(a)
out, err := list.Accept()
if err != nil {
t.Fatalf("err: %v", err)
}
if out != a {
t.Fatalf("bad")
}
}
func TestSCADAAddr(t *testing.T) {
var addr interface{} = &scadaAddr{"armon/test"}
_, ok := addr.(net.Addr)
if !ok {
t.Fatalf("bad")
}
}

View File

@ -1,10 +1,11 @@
package agent package agent
import ( import (
"github.com/hashicorp/consul/consul/structs"
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"github.com/hashicorp/consul/consul/structs"
) )
// ServiceSummary is used to summarize a service // ServiceSummary is used to summarize a service
@ -19,99 +20,88 @@ type ServiceSummary struct {
// UINodes is used to list the nodes in a given datacenter. We return a // UINodes is used to list the nodes in a given datacenter. We return a
// NodeDump which provides overview information for all the nodes // NodeDump which provides overview information for all the nodes
func (s *HTTPServer) UINodes(resp http.ResponseWriter, req *http.Request) (interface{}, error) { func (s *HTTPServer) UINodes(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
// Get the datacenter // Parse arguments
var dc string args := structs.DCSpecificRequest{}
s.parseDC(req, &dc) if done := s.parse(resp, req, &args.Datacenter, &args.QueryOptions); done {
return nil, nil
// Try to ge ta node dump
var dump structs.NodeDump
if err := s.getNodeDump(resp, dc, "", &dump); err != nil {
return nil, err
} }
return dump, nil // Make the RPC request
var out structs.IndexedNodeDump
defer setMeta(resp, &out.QueryMeta)
RPC:
if err := s.agent.RPC("Internal.NodeDump", &args, &out); err != nil {
// Retry the request allowing stale data if no leader
if strings.Contains(err.Error(), structs.ErrNoLeader.Error()) && !args.AllowStale {
args.AllowStale = true
goto RPC
}
return nil, err
}
return out.Dump, nil
} }
// UINodeInfo is used to get info on a single node in a given datacenter. We return a // UINodeInfo is used to get info on a single node in a given datacenter. We return a
// NodeInfo which provides overview information for the node // NodeInfo which provides overview information for the node
func (s *HTTPServer) UINodeInfo(resp http.ResponseWriter, req *http.Request) (interface{}, error) { func (s *HTTPServer) UINodeInfo(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
// Get the datacenter // Parse arguments
var dc string args := structs.NodeSpecificRequest{}
s.parseDC(req, &dc) if done := s.parse(resp, req, &args.Datacenter, &args.QueryOptions); done {
return nil, nil
}
// Verify we have some DC, or use the default // Verify we have some DC, or use the default
node := strings.TrimPrefix(req.URL.Path, "/v1/internal/ui/node/") args.Node = strings.TrimPrefix(req.URL.Path, "/v1/internal/ui/node/")
if node == "" { if args.Node == "" {
resp.WriteHeader(400) resp.WriteHeader(400)
resp.Write([]byte("Missing node name")) resp.Write([]byte("Missing node name"))
return nil, nil return nil, nil
} }
// Try to get a node dump // Make the RPC request
var dump structs.NodeDump var out structs.IndexedNodeDump
if err := s.getNodeDump(resp, dc, node, &dump); err != nil { defer setMeta(resp, &out.QueryMeta)
RPC:
if err := s.agent.RPC("Internal.NodeInfo", &args, &out); err != nil {
// Retry the request allowing stale data if no leader
if strings.Contains(err.Error(), structs.ErrNoLeader.Error()) && !args.AllowStale {
args.AllowStale = true
goto RPC
}
return nil, err return nil, err
} }
// Return only the first entry // Return only the first entry
if len(dump) > 0 { if len(out.Dump) > 0 {
return dump[0], nil return out.Dump[0], nil
} }
return nil, nil return nil, nil
} }
// getNodeDump is used to get a dump of all node data. We make a best effort by
// reading stale data in the case of an availability outage.
func (s *HTTPServer) getNodeDump(resp http.ResponseWriter, dc, node string, dump *structs.NodeDump) error {
var args interface{}
var method string
var allowStale *bool
if node == "" {
raw := structs.DCSpecificRequest{Datacenter: dc}
method = "Internal.NodeDump"
allowStale = &raw.AllowStale
args = &raw
} else {
raw := &structs.NodeSpecificRequest{Datacenter: dc, Node: node}
method = "Internal.NodeInfo"
allowStale = &raw.AllowStale
args = &raw
}
var out structs.IndexedNodeDump
defer setMeta(resp, &out.QueryMeta)
START:
if err := s.agent.RPC(method, args, &out); err != nil {
// Retry the request allowing stale data if no leader. The UI should continue
// to function even during an outage
if strings.Contains(err.Error(), structs.ErrNoLeader.Error()) && !*allowStale {
*allowStale = true
goto START
}
return err
}
// Set the result
*dump = out.Dump
return nil
}
// UIServices is used to list the services in a given datacenter. We return a // UIServices is used to list the services in a given datacenter. We return a
// ServiceSummary which provides overview information for the service // ServiceSummary which provides overview information for the service
func (s *HTTPServer) UIServices(resp http.ResponseWriter, req *http.Request) (interface{}, error) { func (s *HTTPServer) UIServices(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
// Get the datacenter // Parse arguments
var dc string args := structs.DCSpecificRequest{}
s.parseDC(req, &dc) if done := s.parse(resp, req, &args.Datacenter, &args.QueryOptions); done {
return nil, nil
}
// Get the full node dump... // Make the RPC request
var dump structs.NodeDump var out structs.IndexedNodeDump
if err := s.getNodeDump(resp, dc, "", &dump); err != nil { defer setMeta(resp, &out.QueryMeta)
RPC:
if err := s.agent.RPC("Internal.NodeDump", &args, &out); err != nil {
// Retry the request allowing stale data if no leader
if strings.Contains(err.Error(), structs.ErrNoLeader.Error()) && !args.AllowStale {
args.AllowStale = true
goto RPC
}
return nil, err return nil, err
} }
// Generate the summary // Generate the summary
return summarizeServices(dump), nil return summarizeServices(out.Dump), nil
} }
func summarizeServices(dump structs.NodeDump) []*ServiceSummary { func summarizeServices(dump structs.NodeDump) []*ServiceSummary {

View File

@ -39,6 +39,7 @@ $ consul agent -data-dir=/tmp/consul
Server: false (bootstrap: false) Server: false (bootstrap: false)
Client Addr: 127.0.0.1 (HTTP: 8500, DNS: 8600, RPC: 8400) Client Addr: 127.0.0.1 (HTTP: 8500, DNS: 8600, RPC: 8400)
Cluster Addr: 192.168.1.43 (LAN: 8301, WAN: 8302) Cluster Addr: 192.168.1.43 (LAN: 8301, WAN: 8302)
Atlas: (Infrastructure: 'hashicorp/test' Join: true)
==> Log data will now stream in as it occurs: ==> Log data will now stream in as it occurs:
@ -75,6 +76,11 @@ There are several important messages that `consul agent` outputs:
Consul agents in a cluster. Not all Consul agents in a cluster have to Consul agents in a cluster. Not all Consul agents in a cluster have to
use the same port, but this address **MUST** be reachable by all other nodes. use the same port, but this address **MUST** be reachable by all other nodes.
* **Atlas**: This shows the [Atlas infrastructure](https://atlas.hashicorp.com)
the node is registered with. It also indicates if auto join is enabled.
The Atlas infrastructure is set using `-atlas` and auto-join is enabled by
setting `-atlas-join`.
## Stopping an Agent ## Stopping an Agent
An agent can be stopped in two ways: gracefully or forcefully. To gracefully An agent can be stopped in two ways: gracefully or forcefully. To gracefully

View File

@ -40,6 +40,17 @@ The options below are all specified on the command-line.
If this address is not routable, the node will be in a constant flapping state If this address is not routable, the node will be in a constant flapping state
as other nodes will treat the non-routability as a failure. as other nodes will treat the non-routability as a failure.
* <a id="atlas"></a>`-atlas` - This flag enables [Atlas](https://atlas.hashicorp.com) integration.
It is used to provide the Atlas infrastructure name and the SCADA connection.
This enables Atlas features such as the dashboard and node auto joining.
* <a id="atlas_join"></a>`-atlas-join` - When set, enables auto-join via Atlas. Atlas will track the most
recent members to join the infrastructure named by `-atlas` and automatically
join them on start. For servers, the LAN and WAN pool are both joined.
* <a id="atlas_token"></a>`-atlas-token` - Provides the Atlas API authentication token. This can also be provided
using the `ATLAS_TOKEN` environment variable. Required for use with Atlas.
* <a id="bootstrap_anchor"></a>`-bootstrap` - This flag is used to control if a server is in "bootstrap" mode. It is important that * <a id="bootstrap_anchor"></a>`-bootstrap` - This flag is used to control if a server is in "bootstrap" mode. It is important that
no more than one server *per* data center be running in this mode. Technically, a server in bootstrap mode no more than one server *per* data center be running in this mode. Technically, a server in bootstrap mode
is allowed to self-elect as the Raft leader. It is important that only a single node is in this mode; is allowed to self-elect as the Raft leader. It is important that only a single node is in this mode;
@ -260,6 +271,16 @@ definitions support being updated during a reload.
* `advertise_addr` - Equivalent to the [`-advertise` command-line flag](#advertise). * `advertise_addr` - Equivalent to the [`-advertise` command-line flag](#advertise).
* `atlas_acl_token` - When provided, any requests made by Atlas will use this ACL
token unless explicitly overriden. When not provided the `acl_token` is used.
This can be set to 'anonymous' to reduce permission below that of `acl_token`.
* `atlas_infrastructure` - Equivalent to the [`-atlas` command-line flag](#atlas).
* `atlas_join` - Equivalent to the [`-atlas-join` command-line flag](#atlas_join).
* `atlas_token` - Equivalent to the [`-atlas-token` command-line flag](#atlas_token).
* `bootstrap` - Equivalent to the [`-bootstrap` command-line flag](#bootstrap_anchor). * `bootstrap` - Equivalent to the [`-bootstrap` command-line flag](#bootstrap_anchor).
* `bootstrap_expect` - Equivalent to the [`-bootstrap-expect` command-line flag](#bootstrap_expect). * `bootstrap_expect` - Equivalent to the [`-bootstrap-expect` command-line flag](#bootstrap_expect).

View File

@ -28,4 +28,14 @@ and can be disabled.
See [`disable_anonymous_signature`](/docs/agent/options.html#disable_anonymous_signature) See [`disable_anonymous_signature`](/docs/agent/options.html#disable_anonymous_signature)
and [`disable_update_check`](/docs/agent/options.html#disable_update_check). and [`disable_update_check`](/docs/agent/options.html#disable_update_check).
## Q: How does Atlas integration work?
Consul makes use of a HashiCorp service called [SCADA](http://scada.hashicorp.com)
which stands for Supervisory Control And Data Acquisition. The SCADA system allows
clients to maintain a long-running connection to Atlas which is used to make requests
to Consul agents for features like the dashboard and auto joining. Standard ACLs can
be applied to the SCADA connection, which has no enhanced or elevated privileges.
Using the SCADA service is optional and only enabled by opt-in.
See the [Atlas integration guide](/docs/guides/atlas.html).

View File

@ -0,0 +1,59 @@
---
layout: "docs"
page_title: "Atlas Integration"
sidebar_current: "docs-guides-atlas"
description: |-
This guide covers how to integrate Atlas with Consul to provide features like an infrastructure dashboard and automatic cluster joining.
---
# Atlas Integration
[Atlas](https://atlas.hashicorp.com) is service provided by HashiCorp to deploy applications and manage infrastructure.
Starting with Consul 0.5, it is possible to integrate Consul with Atlas. This is done by registering a node as part
of an Atlas infrastructure (specified with the `-atlas` flag). Consul maintains a long running connection to the
[SCADA](http://scada.hashicorp.com) service which allows Atlas to retrieve data and control nodes.
Data acquisition allows Atlas to display the state of the Consul cluster in its dashboard as well as enabling
alerts to be setup using health checks. Remote control enables Atlas to provide features like the auto joinining
nodes.
## Enabling Atlas Integration
To enable Atlas integration, you must specify the name of the Atlas infrastructure and the Atlas authentication
token. The Atlas infrastructure name can be set either with the `-atlas` CLI flag, or with the `atlas_infrastructure`
[configuration option](/docs/agent/options.html). The Atlas token is set with the `-atlas-token` CLI flag, `atlas_token`
configuration option, or `ATLAS_TOKEN` environment variable.
To verify the integration, either run the agent with `debug` level logging or use `consul monitor -log-level=debug`
and look for a line like:
[DEBUG] scada-client: assigned session '406ca55d-1801-f964-2942-45f5f9df3995'
This shows that the Consul agent was successfully able to register with the SCADA service.
## Using Auto-Join
Once integrated with Atlas, the auto join feature can be used to have nodes automatically join other
peers in their datacenter. Server nodes will automatically join peer LAN nodes and other WAN nodes.
Client nodes will only join other LAN nodes in their datacenter.
Auto join is enabled with the `-atlas-join` CLI flag or the `atlas_join` configuration option.
## Securing Atlas
The connection to Atlas does not have elevated privileges. API requests made by Atlas
are served in the same way any other HTTP request is made. If ACLs are enabled, it is possible to
force an Atlas ACL token to be used instead of the agent's default token.
When ACLs are enabled, the `atlas_acl_token` configuration option can be specified. This changes
the ACL token resolution order to be:
1. Request specific token provided by `?token=`. These tokens are set in the Atlas UI.
2. The `atlas_acl_token` if configured.
3. The `acl_token` if configured.
4. The `anonymous` token.
Because the `acl_token` typically has elevated permissions compared to the `anonymous` token,
the `atlas_acl_token` can be set to `anonymous` to drop privileges that would otherwise be
inherited from the agent.

View File

@ -14,6 +14,8 @@ guidance to do them safely.
The following guides are available: The following guides are available:
* [Atlas Integration](/docs/guides/atlas.html) - This guide covers how to integrate [Atlas](https://atlas.hashicorp.com) with Consul.
* [Adding/Removing Servers](/docs/guides/servers.html) - This guide covers how to safely add and remove Consul servers from the cluster. This should be done carefully to avoid availability outages. * [Adding/Removing Servers](/docs/guides/servers.html) - This guide covers how to safely add and remove Consul servers from the cluster. This should be done carefully to avoid availability outages.
* [Bootstrapping](/docs/guides/bootstrapping.html) - This guide covers bootstrapping a new datacenter. This covers safely adding the initial Consul servers. * [Bootstrapping](/docs/guides/bootstrapping.html) - This guide covers bootstrapping a new datacenter. This covers safely adding the initial Consul servers.

View File

@ -196,7 +196,11 @@
<li<%= sidebar_current("docs-guides") %>> <li<%= sidebar_current("docs-guides") %>>
<a href="/docs/guides/index.html">Guides</a> <a href="/docs/guides/index.html">Guides</a>
<ul class="nav"> <ul class="nav">
<li<%= sidebar_current("docs-guides-atlas") %>>
<a href="/docs/guides/atlas.html">Atlas Integration</a>
</li>
<li<%= sidebar_current("docs-guides-servers") %>> <li<%= sidebar_current("docs-guides-servers") %>>
<a href="/docs/guides/servers.html">Adding/Removing Servers</a> <a href="/docs/guides/servers.html">Adding/Removing Servers</a>
</li> </li>