diff --git a/cmd/admin-handlers.go b/cmd/admin-handlers.go index dce7d812d..4a21d2cee 100644 --- a/cmd/admin-handlers.go +++ b/cmd/admin-handlers.go @@ -17,8 +17,10 @@ package cmd import ( + "bytes" "encoding/json" "encoding/xml" + "fmt" "io/ioutil" "net/http" "net/url" @@ -27,7 +29,8 @@ import ( ) const ( - minioAdminOpHeader = "X-Minio-Operation" + minioAdminOpHeader = "X-Minio-Operation" + minioConfigTmpFormat = "config-%s.json" ) // Type-safe query params. @@ -694,10 +697,127 @@ func (adminAPI adminAPIHandlers) GetConfigHandler(w http.ResponseWriter, r *http // returns local config.json. configBytes, err := getPeerConfig(globalAdminPeers) if err != nil { - writeErrorResponse(w, toAPIErrorCode(err), r.URL) errorIf(err, "Failed to get config from peers") + writeErrorResponse(w, toAdminAPIErrCode(err), r.URL) return } writeSuccessResponseJSON(w, configBytes) } + +// toAdminAPIErrCode - converts errXLWriteQuorum error to admin API +// specific error. +func toAdminAPIErrCode(err error) APIErrorCode { + switch err { + case errXLWriteQuorum: + return ErrAdminConfigNoQuorum + } + return toAPIErrorCode(err) +} + +// SetConfigResult - represents detailed results of a set-config +// operation. +type nodeSummary struct { + Name string `json:"name"` + Err string `json:"err"` +} +type setConfigResult struct { + NodeResults []nodeSummary `json:"nodeResults"` + Status bool `json:"status"` +} + +// writeSetConfigResponse - writes setConfigResult value as json depending on the status. +func writeSetConfigResponse(w http.ResponseWriter, peers adminPeers, errs []error, status bool, reqURL *url.URL) { + var nodeResults []nodeSummary + // Build nodeResults based on error values received during + // set-config operation. + for i := range errs { + nodeResults = append(nodeResults, nodeSummary{ + Name: peers[i].addr, + Err: fmt.Sprintf("%v", errs[i]), + }) + + } + + result := setConfigResult{ + Status: status, + NodeResults: nodeResults, + } + + // The following elaborate json encoding is to avoid escaping + // '<', '>' in . Note: json.Encoder.Encode() adds a + // gratuitous "\n". + var resultBuf bytes.Buffer + enc := json.NewEncoder(&resultBuf) + enc.SetEscapeHTML(false) + jsonErr := enc.Encode(result) + if jsonErr != nil { + writeErrorResponse(w, toAPIErrorCode(jsonErr), reqURL) + return + } + + writeSuccessResponseJSON(w, resultBuf.Bytes()) + return +} + +// SetConfigHandler - PUT /?config +// - x-minio-operation = set +func (adminAPI adminAPIHandlers) SetConfigHandler(w http.ResponseWriter, r *http.Request) { + // Get current object layer instance. + objectAPI := newObjectLayerFn() + if objectAPI == nil { + writeErrorResponse(w, ErrServerNotInitialized, r.URL) + return + } + + // Validate request signature. + adminAPIErr := checkRequestAuthType(r, "", "", "") + if adminAPIErr != ErrNone { + writeErrorResponse(w, adminAPIErr, r.URL) + return + } + + // Read configuration bytes from request body. + configBytes, err := ioutil.ReadAll(r.Body) + if err != nil { + errorIf(err, "Failed to read config from request body.") + writeErrorResponse(w, toAPIErrorCode(err), r.URL) + return + } + + // Write config received from request onto a temporary file on + // all nodes. + tmpFileName := fmt.Sprintf(minioConfigTmpFormat, mustGetUUID()) + errs := writeTmpConfigPeers(globalAdminPeers, tmpFileName, configBytes) + + // Check if the operation succeeded in quorum or more nodes. + rErr := reduceWriteQuorumErrs(errs, nil, len(globalAdminPeers)/2+1) + if rErr != nil { + writeSetConfigResponse(w, globalAdminPeers, errs, false, r.URL) + return + } + + // Take a lock on minio/config.json. NB minio is a reserved + // bucket name and wouldn't conflict with normal object + // operations. + configLock := globalNSMutex.NewNSLock(minioReservedBucket, globalMinioConfigFile) + configLock.Lock() + defer configLock.Unlock() + + // Rename the temporary config file to config.json + errs = commitConfigPeers(globalAdminPeers, tmpFileName) + rErr = reduceWriteQuorumErrs(errs, nil, len(globalAdminPeers)/2+1) + if rErr != nil { + writeSetConfigResponse(w, globalAdminPeers, errs, false, r.URL) + return + } + + // serverMux (cmd/server-mux.go) implements graceful shutdown, + // where all listeners are closed and process restart/shutdown + // happens after 5s or completion of all ongoing http + // requests, whichever is earlier. + writeSetConfigResponse(w, globalAdminPeers, errs, true, r.URL) + + // Restart all node for the modified config to take effect. + sendServiceCmd(globalAdminPeers, serviceRestart) +} diff --git a/cmd/admin-handlers_test.go b/cmd/admin-handlers_test.go index 7c7bb1b54..931c0c208 100644 --- a/cmd/admin-handlers_test.go +++ b/cmd/admin-handlers_test.go @@ -20,6 +20,7 @@ import ( "bytes" "encoding/json" "encoding/xml" + "fmt" "io/ioutil" "net/http" "net/http/httptest" @@ -30,6 +31,102 @@ import ( router "github.com/gorilla/mux" ) +var configJSON = []byte(`{ + "version": "13", + "credential": { + "accessKey": "minio", + "secretKey": "minio123" + }, + "region": "us-west-1", + "logger": { + "console": { + "enable": true, + "level": "fatal" + }, + "file": { + "enable": false, + "fileName": "", + "level": "" + } + }, + "notify": { + "amqp": { + "1": { + "enable": false, + "url": "", + "exchange": "", + "routingKey": "", + "exchangeType": "", + "mandatory": false, + "immediate": false, + "durable": false, + "internal": false, + "noWait": false, + "autoDeleted": false + } + }, + "nats": { + "1": { + "enable": false, + "address": "", + "subject": "", + "username": "", + "password": "", + "token": "", + "secure": false, + "pingInterval": 0, + "streaming": { + "enable": false, + "clusterID": "", + "clientID": "", + "async": false, + "maxPubAcksInflight": 0 + } + } + }, + "elasticsearch": { + "1": { + "enable": false, + "url": "", + "index": "" + } + }, + "redis": { + "1": { + "enable": false, + "address": "", + "password": "", + "key": "" + } + }, + "postgresql": { + "1": { + "enable": false, + "connectionString": "", + "table": "", + "host": "", + "port": "", + "user": "", + "password": "", + "database": "" + } + }, + "kafka": { + "1": { + "enable": false, + "brokers": null, + "topic": "" + } + }, + "webhook": { + "1": { + "enable": false, + "endpoint": "" + } + } + } +}`) + // adminXLTestBed - encapsulates subsystems that need to be setup for // admin-handler unit tests. type adminXLTestBed struct { @@ -1033,3 +1130,159 @@ func TestGetConfigHandler(t *testing.T) { } } + +// TestSetConfigHandler - test for SetConfigHandler. +func TestSetConfigHandler(t *testing.T) { + adminTestBed, err := prepareAdminXLTestBed() + if err != nil { + t.Fatal("Failed to initialize a single node XL backend for admin handler tests.") + } + defer adminTestBed.TearDown() + + // Initialize admin peers to make admin RPC calls. + eps, err := parseStorageEndpoints([]string{"http://127.0.0.1"}) + if err != nil { + t.Fatalf("Failed to parse storage end point - %v", err) + } + + // Set globalMinioAddr to be able to distinguish local endpoints from remote. + globalMinioAddr = eps[0].Host + initGlobalAdminPeers(eps) + + // SetConfigHandler restarts minio setup - need to start a + // signal receiver to receive on globalServiceSignalCh. + go testServiceSignalReceiver(restartCmd, t) + + // Prepare query params for set-config mgmt REST API. + queryVal := url.Values{} + queryVal.Set("config", "") + + req, err := newTestRequest("PUT", "/?"+queryVal.Encode(), int64(len(configJSON)), bytes.NewReader(configJSON)) + if err != nil { + t.Fatalf("Failed to construct get-config object request - %v", err) + } + + // Set x-minio-operation header to set. + req.Header.Set(minioAdminOpHeader, "set") + + // Sign the request using signature v4. + cred := serverConfig.GetCredential() + err = signRequestV4(req, cred.AccessKey, cred.SecretKey) + if err != nil { + t.Fatalf("Failed to sign heal object request - %v", err) + } + + rec := httptest.NewRecorder() + adminTestBed.mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("Expected to succeed but failed with %d", rec.Code) + } + + result := setConfigResult{} + err = json.NewDecoder(rec.Body).Decode(&result) + if err != nil { + t.Fatalf("Failed to decode set config result json %v", err) + } + + if result.Status != true { + t.Error("Expected set-config to succeed, but failed") + } +} + +// TestToAdminAPIErr - test for toAdminAPIErr helper function. +func TestToAdminAPIErr(t *testing.T) { + testCases := []struct { + err error + expectedAPIErr APIErrorCode + }{ + // 1. Server not in quorum. + { + err: errXLWriteQuorum, + expectedAPIErr: ErrAdminConfigNoQuorum, + }, + // 2. No error. + { + err: nil, + expectedAPIErr: ErrNone, + }, + // 3. Non-admin API specific error. + { + err: errDiskNotFound, + expectedAPIErr: toAPIErrorCode(errDiskNotFound), + }, + } + + for i, test := range testCases { + actualErr := toAdminAPIErrCode(test.err) + if actualErr != test.expectedAPIErr { + t.Errorf("Test %d: Expected %v but received %v", + i+1, test.expectedAPIErr, actualErr) + } + } +} + +func TestWriteSetConfigResponse(t *testing.T) { + testCases := []struct { + status bool + errs []error + }{ + // 1. all nodes returned success. + { + status: true, + errs: []error{nil, nil, nil, nil}, + }, + // 2. some nodes returned errors. + { + status: false, + errs: []error{errDiskNotFound, nil, errDiskAccessDenied, errFaultyDisk}, + }, + } + + testPeers := []adminPeer{ + adminPeer{ + addr: "localhost:9001", + }, + adminPeer{ + addr: "localhost:9002", + }, + adminPeer{ + addr: "localhost:9003", + }, + adminPeer{ + addr: "localhost:9004", + }, + } + + testURL, err := url.Parse("dummy.com") + if err != nil { + t.Fatalf("Failed to parse a place-holder url") + } + + var actualResult setConfigResult + for i, test := range testCases { + rec := httptest.NewRecorder() + writeSetConfigResponse(rec, testPeers, test.errs, test.status, testURL) + resp := rec.Result() + jsonBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Test %d: Failed to read response %v", i+1, err) + } + + err = json.Unmarshal(jsonBytes, &actualResult) + if err != nil { + t.Fatalf("Test %d: Failed to unmarshal json %v", i+1, err) + } + if actualResult.Status != test.status { + t.Errorf("Test %d: Expected status %v but received %v", i+1, test.status, actualResult.Status) + } + for p, res := range actualResult.NodeResults { + if res.Name != testPeers[p].addr { + t.Errorf("Test %d: Expected node name %s but received %s", i+1, testPeers[p].addr, res.Name) + } + expectedErrStr := fmt.Sprintf("%v", test.errs[p]) + if res.Err != expectedErrStr { + t.Errorf("Test %d: Expected error %s but received %s", i+1, expectedErrStr, res.Err) + } + } + } +} diff --git a/cmd/admin-router.go b/cmd/admin-router.go index 6bdcc8dc6..fa0b805b2 100644 --- a/cmd/admin-router.go +++ b/cmd/admin-router.go @@ -67,4 +67,6 @@ func registerAdminRouter(mux *router.Router) { // Get config adminRouter.Methods("GET").Queries("config", "").Headers(minioAdminOpHeader, "get").HandlerFunc(adminAPI.GetConfigHandler) + // Set Config + adminRouter.Methods("PUT").Queries("config", "").Headers(minioAdminOpHeader, "set").HandlerFunc(adminAPI.SetConfigHandler) } diff --git a/cmd/admin-rpc-client.go b/cmd/admin-rpc-client.go index 392f62c51..9241731e1 100644 --- a/cmd/admin-rpc-client.go +++ b/cmd/admin-rpc-client.go @@ -20,13 +20,26 @@ import ( "encoding/json" "errors" "net/url" + "os" "path" + "path/filepath" "reflect" "sort" "sync" "time" ) +const ( + // Admin service names + serviceRestartRPC = "Admin.Restart" + listLocksRPC = "Admin.ListLocks" + reInitDisksRPC = "Admin.ReInitDisks" + uptimeRPC = "Admin.Uptime" + getConfigRPC = "Admin.GetConfig" + writeTmpConfigRPC = "Admin.WriteTmpConfig" + commitConfigRPC = "Admin.CommitConfig" +) + // localAdminClient - represents admin operation to be executed locally. type localAdminClient struct { } @@ -45,6 +58,8 @@ type adminCmdRunner interface { ReInitDisks() error Uptime() (time.Duration, error) GetConfig() ([]byte, error) + WriteTmpConfig(tmpFileName string, configBytes []byte) error + CommitConfig(tmpFileName string) error } // Restart - Sends a message over channel to the go-routine @@ -63,7 +78,7 @@ func (lc localAdminClient) ListLocks(bucket, prefix string, duration time.Durati func (rc remoteAdminClient) Restart() error { args := AuthRPCArgs{} reply := AuthRPCReply{} - return rc.Call("Admin.Restart", &args, &reply) + return rc.Call(serviceRestartRPC, &args, &reply) } // ListLocks - Sends list locks command to remote server via RPC. @@ -74,7 +89,7 @@ func (rc remoteAdminClient) ListLocks(bucket, prefix string, duration time.Durat duration: duration, } var reply ListLocksReply - if err := rc.Call("Admin.ListLocks", &listArgs, &reply); err != nil { + if err := rc.Call(listLocksRPC, &listArgs, &reply); err != nil { return nil, err } return reply.volLocks, nil @@ -91,7 +106,7 @@ func (lc localAdminClient) ReInitDisks() error { func (rc remoteAdminClient) ReInitDisks() error { args := AuthRPCArgs{} reply := AuthRPCReply{} - return rc.Call("Admin.ReInitDisks", &args, &reply) + return rc.Call(reInitDisksRPC, &args, &reply) } // Uptime - Returns the uptime of this server. Timestamp is taken @@ -108,7 +123,7 @@ func (lc localAdminClient) Uptime() (time.Duration, error) { func (rc remoteAdminClient) Uptime() (time.Duration, error) { args := AuthRPCArgs{} reply := UptimeReply{} - err := rc.Call("Admin.Uptime", &args, &reply) + err := rc.Call(uptimeRPC, &args, &reply) if err != nil { return time.Duration(0), err } @@ -129,12 +144,70 @@ func (lc localAdminClient) GetConfig() ([]byte, error) { func (rc remoteAdminClient) GetConfig() ([]byte, error) { args := AuthRPCArgs{} reply := ConfigReply{} - if err := rc.Call("Admin.GetConfig", &args, &reply); err != nil { + if err := rc.Call(getConfigRPC, &args, &reply); err != nil { return nil, err } return reply.Config, nil } +// WriteTmpConfig - writes config file content to a temporary file on +// the local server. +func (lc localAdminClient) WriteTmpConfig(tmpFileName string, configBytes []byte) error { + return writeTmpConfigCommon(tmpFileName, configBytes) +} + +// WriteTmpConfig - writes config file content to a temporary file on +// a remote node. +func (rc remoteAdminClient) WriteTmpConfig(tmpFileName string, configBytes []byte) error { + wArgs := WriteConfigArgs{ + TmpFileName: tmpFileName, + Buf: configBytes, + } + + err := rc.Call(writeTmpConfigRPC, &wArgs, &WriteConfigReply{}) + if err != nil { + errorIf(err, "Failed to write temporary config file.") + return err + } + + return nil +} + +// CommitConfig - Move the new config in tmpFileName onto config.json +// on a local node. +func (lc localAdminClient) CommitConfig(tmpFileName string) error { + configDir, err := getConfigPath() + if err != nil { + errorIf(err, "Failed to get config directory path.") + return err + } + + configFilePath := filepath.Join(configDir, globalMinioConfigFile) + err = os.Rename(filepath.Join(configDir, tmpFileName), configFilePath) + if err != nil { + errorIf(err, "Failed to rename to config.json") + return err + } + + return nil +} + +// CommitConfig - Move the new config in tmpFileName onto config.json +// on a remote node. +func (rc remoteAdminClient) CommitConfig(tmpFileName string) error { + cArgs := CommitConfigArgs{ + FileName: tmpFileName, + } + cReply := CommitConfigReply{} + err := rc.Call(commitConfigRPC, &cArgs, &cReply) + if err != nil { + errorIf(err, "Failed to rename config file.") + return err + } + + return nil +} + // adminPeer - represents an entity that implements Restart methods. type adminPeer struct { addr string @@ -489,3 +562,55 @@ func getValidServerConfig(serverConfigs []serverConfigV13, errs []error) (server return configJSON, nil } + +// Write config contents into a temporary file on all nodes. +func writeTmpConfigPeers(peers adminPeers, tmpFileName string, configBytes []byte) []error { + // For a single-node minio server setup. + if !globalIsDistXL { + err := peers[0].cmdRunner.WriteTmpConfig(tmpFileName, configBytes) + return []error{err} + } + + errs := make([]error, len(peers)) + + // Write config into temporary file on all nodes. + wg := sync.WaitGroup{} + for i, peer := range peers { + wg.Add(1) + go func(idx int, peer adminPeer) { + defer wg.Done() + errs[idx] = peer.cmdRunner.WriteTmpConfig(tmpFileName, configBytes) + }(i, peer) + } + wg.Wait() + + // Return bytes written and errors (if any) during writing + // temporary config file. + return errs +} + +// Move config contents from the given temporary file onto config.json +// on all nodes. +func commitConfigPeers(peers adminPeers, tmpFileName string) []error { + // For a single-node minio server setup. + if !globalIsDistXL { + return []error{peers[0].cmdRunner.CommitConfig(tmpFileName)} + } + + errs := make([]error, len(peers)) + + // Rename temporary config file into configDir/config.json on + // all nodes. + wg := sync.WaitGroup{} + for i, peer := range peers { + wg.Add(1) + go func(idx int, peer adminPeer) { + defer wg.Done() + errs[idx] = peer.cmdRunner.CommitConfig(tmpFileName) + }(i, peer) + } + wg.Wait() + + // Return errors (if any) received during rename. + return errs +} diff --git a/cmd/admin-rpc-server.go b/cmd/admin-rpc-server.go index 213fbdd8e..6109b9c43 100644 --- a/cmd/admin-rpc-server.go +++ b/cmd/admin-rpc-server.go @@ -19,7 +19,10 @@ package cmd import ( "encoding/json" "errors" + "io/ioutil" "net/rpc" + "os" + "path/filepath" "time" router "github.com/gorilla/mux" @@ -158,6 +161,75 @@ func (s *adminCmd) GetConfig(args *AuthRPCArgs, reply *ConfigReply) error { return nil } +// WriteConfigArgs - wraps the bytes to be written and temporary file name. +type WriteConfigArgs struct { + AuthRPCArgs + TmpFileName string + Buf []byte +} + +// WriteConfigReply - wraps the result of a writing config into a temporary file. +// the remote node. +type WriteConfigReply struct { + AuthRPCReply +} + +func writeTmpConfigCommon(tmpFileName string, configBytes []byte) error { + configDir, err := getConfigPath() + if err != nil { + errorIf(err, "Failed to get config path") + return err + } + + err = ioutil.WriteFile(filepath.Join(configDir, tmpFileName), configBytes, 0666) + if err != nil { + errorIf(err, "Failed to write to temporary config file.") + return err + } + return err +} + +// WriteTmpConfig - writes the supplied config contents onto the +// supplied temporary file. +func (s *adminCmd) WriteTmpConfig(wArgs *WriteConfigArgs, wReply *WriteConfigReply) error { + if err := wArgs.IsAuthenticated(); err != nil { + return err + } + + return writeTmpConfigCommon(wArgs.TmpFileName, wArgs.Buf) +} + +// CommitConfigArgs - wraps the config file name that needs to be +// committed into config.json on this node. +type CommitConfigArgs struct { + AuthRPCArgs + FileName string +} + +// CommitConfigReply - represents response to commit of config file on +// this node. +type CommitConfigReply struct { + AuthRPCReply +} + +// CommitConfig - Renames the temporary file into config.json on this node. +func (s *adminCmd) CommitConfig(cArgs *CommitConfigArgs, cReply *CommitConfigReply) error { + configDir, err := getConfigPath() + if err != nil { + errorIf(err, "Failed to get config path.") + return err + } + + configFilePath := filepath.Join(configDir, globalMinioConfigFile) + err = os.Rename(filepath.Join(configDir, cArgs.FileName), configFilePath) + if err != nil { + errorIf(err, "Failed to rename config file.") + return err + } + + return nil +} + // registerAdminRPCRouter - registers RPC methods for service status, // stop and restart commands. func registerAdminRPCRouter(mux *router.Router) error { diff --git a/cmd/admin-rpc-server_test.go b/cmd/admin-rpc-server_test.go index 44705a7a2..f41c4e28d 100644 --- a/cmd/admin-rpc-server_test.go +++ b/cmd/admin-rpc-server_test.go @@ -144,6 +144,7 @@ func TestReInitDisks(t *testing.T) { } } +// TestGetConfig - Test for GetConfig admin RPC. func TestGetConfig(t *testing.T) { // Reset global variables to start afresh. resetTestGlobals() @@ -185,3 +186,63 @@ func TestGetConfig(t *testing.T) { t.Errorf("Expected json unmarshal to pass but failed with %v", err) } } + +// TestWriteAndCommitConfig - test for WriteTmpConfig and CommitConfig +// RPC handler. +func TestWriteAndCommitConfig(t *testing.T) { + // Reset global variables to start afresh. + resetTestGlobals() + + rootPath, err := newTestConfig("us-east-1") + if err != nil { + t.Fatalf("Unable to initialize server config. %s", err) + } + defer removeAll(rootPath) + + adminServer := adminCmd{} + creds := serverConfig.GetCredential() + args := LoginRPCArgs{ + Username: creds.AccessKey, + Password: creds.SecretKey, + Version: Version, + RequestTime: time.Now().UTC(), + } + reply := LoginRPCReply{} + err = adminServer.Login(&args, &reply) + if err != nil { + t.Fatalf("Failed to login to admin server - %v", err) + } + + // Write temporary config. + buf := []byte("hello") + tmpFileName := mustGetUUID() + wArgs := WriteConfigArgs{ + AuthRPCArgs: AuthRPCArgs{ + AuthToken: reply.AuthToken, + }, + TmpFileName: tmpFileName, + Buf: buf, + } + + err = adminServer.WriteTmpConfig(&wArgs, &WriteConfigReply{}) + if err != nil { + t.Fatalf("Failed to write temporary config %v", err) + } + + if err != nil { + t.Errorf("Expected to succeed but failed %v", err) + } + + cArgs := CommitConfigArgs{ + AuthRPCArgs: AuthRPCArgs{ + AuthToken: reply.AuthToken, + }, + FileName: tmpFileName, + } + cReply := CommitConfigReply{} + + err = adminServer.CommitConfig(&cArgs, &cReply) + if err != nil { + t.Fatalf("Failed to commit config file %v", err) + } +} diff --git a/cmd/api-errors.go b/cmd/api-errors.go index 8eb82761a..33cd41a75 100644 --- a/cmd/api-errors.go +++ b/cmd/api-errors.go @@ -144,6 +144,7 @@ const ( ErrAdminInvalidAccessKey ErrAdminInvalidSecretKey + ErrAdminConfigNoQuorum ) // error code to APIError structure, these fields carry respective @@ -593,6 +594,11 @@ var errorCodeResponse = map[APIErrorCode]APIError{ Description: "The secret key is invalid.", HTTPStatusCode: http.StatusBadRequest, }, + ErrAdminConfigNoQuorum: { + Code: "XMinioAdminConfigNoQuorum", + Description: "Configuration update failed because server quorum was not met", + HTTPStatusCode: http.StatusServiceUnavailable, + }, // Add your error structure here. } diff --git a/cmd/generic-handlers.go b/cmd/generic-handlers.go index 0b3c7f3dc..9af4d6d14 100644 --- a/cmd/generic-handlers.go +++ b/cmd/generic-handlers.go @@ -351,7 +351,7 @@ func (h resourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } // A put method on path "/" doesn't make sense, ignore it. - if r.Method == httpPUT && r.URL.Path == "/" { + if r.Method == httpPUT && r.URL.Path == "/" && r.Header.Get(minioAdminOpHeader) == "" { writeErrorResponse(w, ErrNotImplemented, r.URL) return } diff --git a/pkg/madmin/API.md b/pkg/madmin/API.md index 361034092..033bb0816 100644 --- a/pkg/madmin/API.md +++ b/pkg/madmin/API.md @@ -39,7 +39,7 @@ func main() { | Service operations|LockInfo operations|Healing operations|Config operations| Misc | |:---|:---|:---|:---|:---| |[`ServiceStatus`](#ServiceStatus)| [`ListLocks`](#ListLocks)| [`ListObjectsHeal`](#ListObjectsHeal)|[`GetConfig`](#GetConfig)| [`SetCredentials`](#SetCredentials)| -|[`ServiceRestart`](#ServiceRestart)| [`ClearLocks`](#ClearLocks)| [`ListBucketsHeal`](#ListBucketsHeal)||| +|[`ServiceRestart`](#ServiceRestart)| [`ClearLocks`](#ClearLocks)| [`ListBucketsHeal`](#ListBucketsHeal)|[`SetConfig`](#SetConfig)|| | | |[`HealBucket`](#HealBucket) ||| | | |[`HealObject`](#HealObject)||| | | |[`HealFormat`](#HealFormat)||| @@ -306,7 +306,7 @@ __Example__ -### SetCredentials() error +### SetCredentials() error Set new credentials of a Minio setup. __Example__ @@ -320,4 +320,35 @@ __Example__ ``` + +### SetConfig(config io.Reader) (SetConfigResult, error) +Set config.json of a minio setup and restart setup for configuration +change to take effect. + +| Param | Type | Description | +|---|---|---| +|`st.Status` | _bool_ | true if set-config succeeded, false otherwise. | +|`st.NodeSummary.Name` | _string_ | Network address of the node. | +|`st.NodeSummary.Err` | _string_ | String representation of the error (if any) on the node.| + + +__Example__ + +``` go + config := bytes.NewReader([]byte(`config.json contents go here`)) + result, err := madmClnt.SetConfig(config) + if err != nil { + log.Fatalf("failed due to: %v", err) + } + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", "\t") + err = enc.Encode(result) + if err != nil { + log.Fatalln(err) + } + log.Println("SetConfig: ", string(buf.Bytes())) +``` diff --git a/pkg/madmin/config-commands.go b/pkg/madmin/config-commands.go index 2e839bac3..d63aece4a 100644 --- a/pkg/madmin/config-commands.go +++ b/pkg/madmin/config-commands.go @@ -18,15 +18,36 @@ package madmin import ( + "bytes" + "encoding/json" + "io" "io/ioutil" "net/http" "net/url" ) +const ( + configQueryParam = "config" +) + +// NodeSummary - represents the result of an operation part of +// set-config on a node. +type NodeSummary struct { + Name string `json:"name"` + Err string `json:"err"` +} + +// SetConfigResult - represents detailed results of a set-config +// operation. +type SetConfigResult struct { + NodeResults []NodeSummary `json:"nodeResults"` + Status bool `json:"status"` +} + // GetConfig - returns the config.json of a minio setup. func (adm *AdminClient) GetConfig() ([]byte, error) { queryVal := make(url.Values) - queryVal.Set("config", "") + queryVal.Set(configQueryParam, "") hdrs := make(http.Header) hdrs.Set(minioAdminOpHeader, "get") @@ -36,7 +57,7 @@ func (adm *AdminClient) GetConfig() ([]byte, error) { customHeaders: hdrs, } - // Execute GET on /?lock to list locks. + // Execute GET on /?config to get config of a setup. resp, err := adm.executeMethod("GET", reqData) defer closeResponse(resp) @@ -44,6 +65,59 @@ func (adm *AdminClient) GetConfig() ([]byte, error) { return nil, err } + if resp.StatusCode != http.StatusOK { + return nil, httpRespToErrorResponse(resp) + } + // Return the JSON marshalled bytes to user. return ioutil.ReadAll(resp.Body) } + +// SetConfig - set config supplied as config.json for the setup. +func (adm *AdminClient) SetConfig(config io.Reader) (SetConfigResult, error) { + queryVal := url.Values{} + queryVal.Set(configQueryParam, "") + + // Set x-minio-operation to set. + hdrs := make(http.Header) + hdrs.Set(minioAdminOpHeader, "set") + + // Read config bytes to calculate MD5, SHA256 and content length. + configBytes, err := ioutil.ReadAll(config) + if err != nil { + return SetConfigResult{}, err + } + + reqData := requestData{ + queryValues: queryVal, + customHeaders: hdrs, + contentBody: bytes.NewReader(configBytes), + contentMD5Bytes: sumMD5(configBytes), + contentSHA256Bytes: sum256(configBytes), + } + + // Execute PUT on /?config to set config. + resp, err := adm.executeMethod("PUT", reqData) + + defer closeResponse(resp) + if err != nil { + return SetConfigResult{}, err + } + + if resp.StatusCode != http.StatusOK { + return SetConfigResult{}, httpRespToErrorResponse(resp) + } + + var result SetConfigResult + jsonBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return SetConfigResult{}, err + } + + err = json.Unmarshal(jsonBytes, &result) + if err != nil { + return SetConfigResult{}, err + } + + return result, nil +} diff --git a/pkg/madmin/examples/set-config.go b/pkg/madmin/examples/set-config.go new file mode 100644 index 000000000..6ab560121 --- /dev/null +++ b/pkg/madmin/examples/set-config.go @@ -0,0 +1,156 @@ +// +build ignore + +/* + * Minio Cloud Storage, (C) 2017 Minio, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + + "github.com/minio/minio/pkg/madmin" +) + +var configJSON = []byte(`{ + "version": "13", + "credential": { + "accessKey": "minio", + "secretKey": "minio123" + }, + "region": "us-west-1", + "logger": { + "console": { + "enable": true, + "level": "fatal" + }, + "file": { + "enable": false, + "fileName": "", + "level": "" + } + }, + "notify": { + "amqp": { + "1": { + "enable": false, + "url": "", + "exchange": "", + "routingKey": "", + "exchangeType": "", + "mandatory": false, + "immediate": false, + "durable": false, + "internal": false, + "noWait": false, + "autoDeleted": false + } + }, + "nats": { + "1": { + "enable": false, + "address": "", + "subject": "", + "username": "", + "password": "", + "token": "", + "secure": false, + "pingInterval": 0, + "streaming": { + "enable": false, + "clusterID": "", + "clientID": "", + "async": false, + "maxPubAcksInflight": 0 + } + } + }, + "elasticsearch": { + "1": { + "enable": false, + "url": "", + "index": "" + } + }, + "redis": { + "1": { + "enable": false, + "address": "", + "password": "", + "key": "" + } + }, + "postgresql": { + "1": { + "enable": false, + "connectionString": "", + "table": "", + "host": "", + "port": "", + "user": "", + "password": "", + "database": "" + } + }, + "kafka": { + "1": { + "enable": false, + "brokers": null, + "topic": "" + } + }, + "webhook": { + "1": { + "enable": false, + "endpoint": "" + } + } + } +}`) + +func main() { + // Note: YOUR-ACCESSKEYID, YOUR-SECRETACCESSKEY are + // dummy values, please replace them with original values. + + // Note: YOUR-ACCESSKEYID, YOUR-SECRETACCESSKEY are + // dummy values, please replace them with original values. + + // API requests are secure (HTTPS) if secure=true and insecure (HTTPS) otherwise. + // New returns an Minio Admin client object. + madmClnt, err := madmin.New("your-minio.example.com:9000", "YOUR-ACCESSKEYID", "YOUR-SECRETACCESSKEY", true) + if err != nil { + log.Fatalln(err) + } + + result, err := madmClnt.SetConfig(bytes.NewReader(configJSON)) + if err != nil { + log.Fatalln(err) + } + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", "\t") + err = enc.Encode(result) + if err != nil { + log.Fatalln(err) + } + + fmt.Println("SetConfig: ", string(buf.Bytes())) +}