001/**
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018
019package org.apache.hadoop.hbase;
020
021import java.io.IOException;
022import java.net.URI;
023import java.util.HashMap;
024import java.util.Locale;
025import java.util.Map;
026import javax.ws.rs.client.Client;
027import javax.ws.rs.client.ClientBuilder;
028import javax.ws.rs.client.Entity;
029import javax.ws.rs.client.Invocation;
030import javax.ws.rs.client.WebTarget;
031import javax.ws.rs.core.MediaType;
032import javax.ws.rs.core.Response;
033import javax.ws.rs.core.UriBuilder;
034import javax.xml.ws.http.HTTPException;
035import org.apache.hadoop.conf.Configuration;
036import org.apache.hadoop.conf.Configured;
037import org.apache.hadoop.hbase.util.GsonUtil;
038import org.apache.hadoop.util.ReflectionUtils;
039import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
040import org.slf4j.Logger;
041import org.slf4j.LoggerFactory;
042
043import org.apache.hbase.thirdparty.com.google.gson.Gson;
044import org.apache.hbase.thirdparty.com.google.gson.JsonElement;
045import org.apache.hbase.thirdparty.com.google.gson.JsonObject;
046
047/**
048 * A ClusterManager implementation designed to control Cloudera Manager (http://www.cloudera.com)
049 * clusters via REST API. This API uses HTTP GET requests against the cluster manager server to
050 * retrieve information and POST/PUT requests to perform actions. As a simple example, to retrieve a
051 * list of hosts from a CM server with login credentials admin:admin, a simple curl command would be
052 *     curl -X POST -H "Content-Type:application/json" -u admin:admin \
053 *         "http://this.is.my.server.com:7180/api/v8/hosts"
054 *
055 * This command would return a JSON result, which would need to be parsed to retrieve relevant
056 * information. This action and many others are covered by this class.
057 *
058 * A note on nomenclature: while the ClusterManager interface uses a ServiceType enum when
059 * referring to things like RegionServers and DataNodes, cluster managers often use different
060 * terminology. As an example, Cloudera Manager (http://www.cloudera.com) would refer to a
061 * RegionServer as a "role" of the HBase "service." It would further refer to "hbase" as the
062 * "serviceType." Apache Ambari (http://ambari.apache.org) would call the RegionServer a
063 * "component" of the HBase "service."
064 *
065 * This class will defer to the ClusterManager terminology in methods that it implements from
066 * that interface, but uses Cloudera Manager's terminology when dealing with its API directly.
067 */
068public class RESTApiClusterManager extends Configured implements ClusterManager {
069  // Properties that need to be in the Configuration object to interact with the REST API cluster
070  // manager. Most easily defined in hbase-site.xml, but can also be passed on the command line.
071  private static final String REST_API_CLUSTER_MANAGER_HOSTNAME =
072      "hbase.it.clustermanager.restapi.hostname";
073  private static final String REST_API_CLUSTER_MANAGER_USERNAME =
074      "hbase.it.clustermanager.restapi.username";
075  private static final String REST_API_CLUSTER_MANAGER_PASSWORD =
076      "hbase.it.clustermanager.restapi.password";
077  private static final String REST_API_CLUSTER_MANAGER_CLUSTER_NAME =
078      "hbase.it.clustermanager.restapi.clustername";
079
080  private static final Gson GSON = GsonUtil.createGson().create();
081
082  // Some default values for the above properties.
083  private static final String DEFAULT_SERVER_HOSTNAME = "http://localhost:7180";
084  private static final String DEFAULT_SERVER_USERNAME = "admin";
085  private static final String DEFAULT_SERVER_PASSWORD = "admin";
086  private static final String DEFAULT_CLUSTER_NAME = "Cluster 1";
087
088  // Fields for the hostname, username, password, and cluster name of the cluster management server
089  // to be used.
090  private String serverHostname;
091  private String serverUsername;
092  private String serverPassword;
093  private String clusterName;
094
095  // Each version of Cloudera Manager supports a particular API versions. Version 6 of this API
096  // provides all the features needed by this class.
097  private static final String API_VERSION = "v6";
098
099  // Client instances are expensive, so use the same one for all our REST queries.
100  private Client client = ClientBuilder.newClient();
101
102  // An instance of HBaseClusterManager is used for methods like the kill, resume, and suspend
103  // because cluster managers don't tend to implement these operations.
104  private ClusterManager hBaseClusterManager;
105
106  private static final Logger LOG = LoggerFactory.getLogger(RESTApiClusterManager.class);
107
108  RESTApiClusterManager() {
109    hBaseClusterManager = ReflectionUtils.newInstance(HBaseClusterManager.class,
110        new IntegrationTestingUtility().getConfiguration());
111  }
112
113  @Override
114  public void setConf(Configuration conf) {
115    super.setConf(conf);
116    if (conf == null) {
117      // Configured gets passed null before real conf. Why? I don't know.
118      return;
119    }
120    serverHostname = conf.get(REST_API_CLUSTER_MANAGER_HOSTNAME, DEFAULT_SERVER_HOSTNAME);
121    serverUsername = conf.get(REST_API_CLUSTER_MANAGER_USERNAME, DEFAULT_SERVER_USERNAME);
122    serverPassword = conf.get(REST_API_CLUSTER_MANAGER_PASSWORD, DEFAULT_SERVER_PASSWORD);
123    clusterName = conf.get(REST_API_CLUSTER_MANAGER_CLUSTER_NAME, DEFAULT_CLUSTER_NAME);
124
125    // Add filter to Client instance to enable server authentication.
126    client.register(HttpAuthenticationFeature.basic(serverUsername, serverPassword));
127  }
128
129  @Override
130  public void start(ServiceType service, String hostname, int port) throws IOException {
131    performClusterManagerCommand(service, hostname, RoleCommand.START);
132  }
133
134  @Override
135  public void stop(ServiceType service, String hostname, int port) throws IOException {
136    performClusterManagerCommand(service, hostname, RoleCommand.STOP);
137  }
138
139  @Override
140  public void restart(ServiceType service, String hostname, int port) throws IOException {
141    performClusterManagerCommand(service, hostname, RoleCommand.RESTART);
142  }
143
144  @Override
145  public boolean isRunning(ServiceType service, String hostname, int port) throws IOException {
146    String serviceName = getServiceName(roleServiceType.get(service));
147    String hostId = getHostId(hostname);
148    String roleState = getRoleState(serviceName, service.toString(), hostId);
149    String healthSummary = getHealthSummary(serviceName, service.toString(), hostId);
150    boolean isRunning = false;
151
152    // Use Yoda condition to prevent NullPointerException. roleState will be null if the "service
153    // type" does not exist on the specified hostname.
154    if ("STARTED".equals(roleState) && "GOOD".equals(healthSummary)) {
155      isRunning = true;
156    }
157
158    return isRunning;
159  }
160
161  @Override
162  public void kill(ServiceType service, String hostname, int port) throws IOException {
163    hBaseClusterManager.kill(service, hostname, port);
164  }
165
166  @Override
167  public void suspend(ServiceType service, String hostname, int port) throws IOException {
168    hBaseClusterManager.suspend(service, hostname, port);
169  }
170
171  @Override
172  public void resume(ServiceType service, String hostname, int port) throws IOException {
173    hBaseClusterManager.resume(service, hostname, port);
174  }
175
176
177  // Convenience method to execute command against role on hostname. Only graceful commands are
178  // supported since cluster management APIs don't tend to let you SIGKILL things.
179  private void performClusterManagerCommand(ServiceType role, String hostname, RoleCommand command)
180      throws IOException {
181    LOG.info("Performing " + command + " command against " + role + " on " + hostname + "...");
182    String serviceName = getServiceName(roleServiceType.get(role));
183    String hostId = getHostId(hostname);
184    String roleName = getRoleName(serviceName, role.toString(), hostId);
185    doRoleCommand(serviceName, roleName, command);
186  }
187
188  // Performing a command (e.g. starting or stopping a role) requires a POST instead of a GET.
189  private void doRoleCommand(String serviceName, String roleName, RoleCommand roleCommand) {
190    URI uri = UriBuilder.fromUri(serverHostname)
191        .path("api")
192        .path(API_VERSION)
193        .path("clusters")
194        .path(clusterName)
195        .path("services")
196        .path(serviceName)
197        .path("roleCommands")
198        .path(roleCommand.toString())
199        .build();
200    String body = "{ \"items\": [ \"" + roleName + "\" ] }";
201    LOG.info("Executing POST against " + uri + " with body " + body + "...");
202    WebTarget webTarget = client.target(uri);
203    Invocation.Builder invocationBuilder =  webTarget.request(MediaType.APPLICATION_JSON);
204    Response response = invocationBuilder.post(Entity.json(body));
205    int statusCode = response.getStatus();
206    if (statusCode != Response.Status.OK.getStatusCode()) {
207      throw new HTTPException(statusCode);
208    }
209  }
210
211  // Possible healthSummary values include "GOOD" and "BAD."
212  private String getHealthSummary(String serviceName, String roleType, String hostId)
213      throws IOException {
214    return getRolePropertyValue(serviceName, roleType, hostId, "healthSummary");
215  }
216
217  // This API uses a hostId to execute host-specific commands; get one from a hostname.
218  private String getHostId(String hostname) throws IOException {
219    String hostId = null;
220
221    URI uri =
222      UriBuilder.fromUri(serverHostname).path("api").path(API_VERSION).path("hosts").build();
223    JsonElement hosts = getJsonNodeFromURIGet(uri);
224    if (hosts != null) {
225      // Iterate through the list of hosts, stopping once you've reached the requested hostname.
226      for (JsonElement host : hosts.getAsJsonArray()) {
227        if (host.getAsJsonObject().get("hostname").getAsString().equals(hostname)) {
228          hostId = host.getAsJsonObject().get("hostId").getAsString();
229          break;
230        }
231      }
232    } else {
233      hostId = null;
234    }
235
236    return hostId;
237  }
238
239  // Execute GET against URI, returning a JsonNode object to be traversed.
240  private JsonElement getJsonNodeFromURIGet(URI uri) throws IOException {
241    LOG.info("Executing GET against " + uri + "...");
242    WebTarget webTarget = client.target(uri);
243    Invocation.Builder invocationBuilder = webTarget.request(MediaType.APPLICATION_JSON);
244    Response response = invocationBuilder.get();
245    int statusCode = response.getStatus();
246    if (statusCode != Response.Status.OK.getStatusCode()) {
247      throw new HTTPException(statusCode);
248    }
249    // This API folds information as the value to an "items" attribute.
250    return GSON.toJsonTree(response.readEntity(String.class)).getAsJsonObject().get("items");
251  }
252
253  // This API assigns a unique role name to each host's instance of a role.
254  private String getRoleName(String serviceName, String roleType, String hostId)
255      throws IOException {
256    return getRolePropertyValue(serviceName, roleType, hostId, "name");
257  }
258
259  // Get the value of a property from a role on a particular host.
260  private String getRolePropertyValue(String serviceName, String roleType, String hostId,
261      String property) throws IOException {
262    String roleValue = null;
263    URI uri = UriBuilder.fromUri(serverHostname).path("api").path(API_VERSION).path("clusters")
264      .path(clusterName).path("services").path(serviceName).path("roles").build();
265    JsonElement roles = getJsonNodeFromURIGet(uri);
266    if (roles != null) {
267      // Iterate through the list of roles, stopping once the requested one is found.
268      for (JsonElement role : roles.getAsJsonArray()) {
269        JsonObject roleObj = role.getAsJsonObject();
270        if (roleObj.get("hostRef").getAsJsonObject().get("hostId").getAsString().equals(hostId) &&
271          roleObj.get("type").getAsString().toLowerCase(Locale.ROOT)
272            .equals(roleType.toLowerCase(Locale.ROOT))) {
273          roleValue = roleObj.get(property).getAsString();
274          break;
275        }
276      }
277    }
278
279    return roleValue;
280  }
281
282  // Possible roleState values include "STARTED" and "STOPPED."
283  private String getRoleState(String serviceName, String roleType, String hostId)
284      throws IOException {
285    return getRolePropertyValue(serviceName, roleType, hostId, "roleState");
286  }
287
288  // Convert a service (e.g. "HBASE," "HDFS") into a service name (e.g. "HBASE-1," "HDFS-1").
289  private String getServiceName(Service service) throws IOException {
290    String serviceName = null;
291    URI uri = UriBuilder.fromUri(serverHostname).path("api").path(API_VERSION).path("clusters")
292      .path(clusterName).path("services").build();
293    JsonElement services = getJsonNodeFromURIGet(uri);
294    if (services != null) {
295      // Iterate through the list of services, stopping once the requested one is found.
296      for (JsonElement serviceEntry : services.getAsJsonArray()) {
297        if (serviceEntry.getAsJsonObject().get("type").getAsString().equals(service.toString())) {
298          serviceName = serviceEntry.getAsJsonObject().get("name").getAsString();
299          break;
300        }
301      }
302    }
303
304    return serviceName;
305  }
306
307  /*
308   * Some enums to guard against bad calls.
309   */
310
311  // The RoleCommand enum is used by the doRoleCommand method to guard against non-existent methods
312  // being invoked on a given role.
313  // TODO: Integrate zookeeper and hdfs related failure injections (Ref: HBASE-14261).
314  private enum RoleCommand {
315    START, STOP, RESTART;
316
317    // APIs tend to take commands in lowercase, so convert them to save the trouble later.
318    @Override
319    public String toString() {
320      return name().toLowerCase(Locale.ROOT);
321    }
322  }
323
324  // ClusterManager methods take a "ServiceType" object (e.g. "HBASE_MASTER," "HADOOP_NAMENODE").
325  // These "service types," which cluster managers call "roles" or "components," need to be mapped
326  // to their corresponding service (e.g. "HBase," "HDFS") in order to be controlled.
327  private static Map<ServiceType, Service> roleServiceType = new HashMap<>();
328  static {
329    roleServiceType.put(ServiceType.HADOOP_NAMENODE, Service.HDFS);
330    roleServiceType.put(ServiceType.HADOOP_DATANODE, Service.HDFS);
331    roleServiceType.put(ServiceType.HADOOP_JOBTRACKER, Service.MAPREDUCE);
332    roleServiceType.put(ServiceType.HADOOP_TASKTRACKER, Service.MAPREDUCE);
333    roleServiceType.put(ServiceType.HBASE_MASTER, Service.HBASE);
334    roleServiceType.put(ServiceType.HBASE_REGIONSERVER, Service.HBASE);
335  }
336
337  private enum Service {
338    HBASE, HDFS, MAPREDUCE
339  }
340}