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 */
018package org.apache.hadoop.hbase.security.access;
019
020import static org.junit.jupiter.api.Assertions.assertEquals;
021import static org.junit.jupiter.api.Assertions.assertFalse;
022import static org.junit.jupiter.api.Assertions.assertNotNull;
023import static org.junit.jupiter.api.Assertions.assertThrows;
024import static org.junit.jupiter.api.Assertions.assertTrue;
025
026import java.io.IOException;
027import java.util.List;
028import org.apache.hadoop.conf.Configuration;
029import org.apache.hadoop.fs.FSDataInputStream;
030import org.apache.hadoop.fs.FSDataOutputStream;
031import org.apache.hadoop.fs.FileSystem;
032import org.apache.hadoop.fs.Path;
033import org.apache.hadoop.hbase.ActiveClusterSuffix;
034import org.apache.hadoop.hbase.HBaseTestingUtil;
035import org.apache.hadoop.hbase.HConstants;
036import org.apache.hadoop.hbase.ReadOnlyTransitionException;
037import org.apache.hadoop.hbase.TableName;
038import org.apache.hadoop.hbase.client.ColumnFamilyDescriptorBuilder;
039import org.apache.hadoop.hbase.client.Put;
040import org.apache.hadoop.hbase.client.Table;
041import org.apache.hadoop.hbase.client.TableDescriptor;
042import org.apache.hadoop.hbase.client.TableDescriptorBuilder;
043import org.apache.hadoop.hbase.coprocessor.CoprocessorHost;
044import org.apache.hadoop.hbase.coprocessor.SimpleRegionObserver;
045import org.apache.hadoop.hbase.master.HMaster;
046import org.apache.hadoop.hbase.master.MasterFileSystem;
047import org.apache.hadoop.hbase.regionserver.HRegion;
048import org.apache.hadoop.hbase.regionserver.HRegionServer;
049import org.apache.hadoop.hbase.regionserver.NoOpScanPolicyObserver;
050import org.apache.hadoop.hbase.regionserver.RegionCoprocessorHost;
051import org.apache.hadoop.hbase.testclassification.MediumTests;
052import org.apache.hadoop.hbase.testclassification.SecurityTests;
053import org.apache.hadoop.hbase.util.Bytes;
054import org.apache.hadoop.hbase.util.CoprocessorConfigurationUtil;
055import org.junit.jupiter.api.AfterEach;
056import org.junit.jupiter.api.BeforeEach;
057import org.junit.jupiter.api.Tag;
058import org.junit.jupiter.api.Test;
059import org.slf4j.Logger;
060import org.slf4j.LoggerFactory;
061
062@Tag(SecurityTests.TAG)
063@Tag(MediumTests.TAG)
064public class TestReadOnlyManageActiveClusterFile {
065
066  private static final Logger LOG =
067    LoggerFactory.getLogger(TestReadOnlyManageActiveClusterFile.class);
068  private final HBaseTestingUtil TEST_UTIL = new HBaseTestingUtil();
069  private static final int NUM_RS = 1;
070  private static Configuration conf;
071  HMaster master;
072  HRegionServer regionServer;
073
074  MasterFileSystem mfs;
075  Path rootDir;
076  FileSystem fs;
077  Path activeClusterFile;
078
079  @BeforeEach
080  public void setup() throws Exception {
081    conf = TEST_UTIL.getConfiguration();
082
083    // Set up test class with Read-Only mode disabled so a table can be created
084    conf.setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY, false);
085    // Start the test cluster
086    TEST_UTIL.startMiniCluster(NUM_RS);
087    master = TEST_UTIL.getMiniHBaseCluster().getMaster();
088    regionServer = TEST_UTIL.getMiniHBaseCluster().getRegionServer(0);
089
090    mfs = master.getMasterFileSystem();
091    rootDir = mfs.getRootDir();
092    fs = mfs.getFileSystem();
093    activeClusterFile = new Path(rootDir, HConstants.ACTIVE_CLUSTER_SUFFIX_FILE_NAME);
094  }
095
096  @AfterEach
097  public void tearDown() throws Exception {
098    TEST_UTIL.shutdownMiniCluster();
099  }
100
101  private void setReadOnlyMode(boolean enabled) {
102    conf.setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY, enabled);
103    master.getConfigurationManager().notifyAllObservers(conf);
104    regionServer.getConfigurationManager().notifyAllObservers(conf);
105  }
106
107  private void restartCluster() throws IOException, InterruptedException {
108    TEST_UTIL.getMiniHBaseCluster().shutdown();
109    TEST_UTIL.restartHBaseCluster(NUM_RS);
110    TEST_UTIL.waitUntilNoRegionsInTransition();
111
112    master = TEST_UTIL.getMiniHBaseCluster().getMaster();
113    regionServer = TEST_UTIL.getMiniHBaseCluster().getRegionServer(0);
114
115    MasterFileSystem mfs = master.getMasterFileSystem();
116    fs = mfs.getFileSystem();
117    activeClusterFile = new Path(mfs.getRootDir(), HConstants.ACTIVE_CLUSTER_SUFFIX_FILE_NAME);
118  }
119
120  private void overwriteExistingFile() throws IOException {
121    try (FSDataOutputStream out = fs.create(activeClusterFile, true)) {
122      out.writeBytes("newClusterId");
123    }
124  }
125
126  private boolean activeClusterIdFileExists() throws IOException {
127    return fs.exists(activeClusterFile);
128  }
129
130  @Test
131  public void testActiveClusterIdFileCreationWhenReadOnlyDisabled()
132    throws IOException, InterruptedException {
133    setReadOnlyMode(false);
134    assertTrue(activeClusterIdFileExists());
135  }
136
137  @Test
138  public void testActiveClusterIdFileDeletionWhenReadOnlyEnabled()
139    throws IOException, InterruptedException {
140    setReadOnlyMode(true);
141    assertFalse(activeClusterIdFileExists());
142  }
143
144  @Test
145  public void testDeleteActiveClusterIdFileWhenSwitchingToReadOnlyIfOwnedByCluster()
146    throws IOException, InterruptedException {
147    // At the start cluster is in active mode hence set readonly mode and restart
148    conf.setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY, true);
149    // Restart the cluster to trigger the deletion of the active cluster ID file
150    restartCluster();
151    // Should delete the active cluster ID file since it is owned by the cluster
152    assertFalse(activeClusterIdFileExists());
153  }
154
155  @Test
156  public void testDoNotDeleteActiveClusterIdFileWhenSwitchingToReadOnlyIfNotOwnedByCluster()
157    throws IOException, InterruptedException {
158    // Change the content of Active Cluster file to simulate the scenario where the file is not
159    // owned by the cluster and then set readonly mode and restart
160    overwriteExistingFile();
161    // At the start cluster is in active mode hence set readonly mode and restart
162    conf.setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY, true);
163    // Restart the cluster to trigger the deletion of the active cluster ID file
164    restartCluster();
165    // As Active cluster file is not owned by the cluster, it should not be deleted even when
166    // switching to readonly mode
167    assertTrue(activeClusterIdFileExists());
168  }
169
170  @Test
171  public void testCannotDisableReadOnlyWhenAnotherClusterIsActive() throws Exception {
172    // First enable read-only mode (simulating a replica cluster)
173    setReadOnlyMode(true);
174    assertFalse(activeClusterIdFileExists());
175
176    // Now write an active cluster file with a DIFFERENT cluster's data (simulating another active
177    // cluster owning the storage)
178    overwriteExistingFile();
179    assertTrue(activeClusterIdFileExists());
180
181    // Attempt to disable read-only mode, but get an exception because another active cluster
182    // already exists.
183    master.getConfiguration().setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY, false);
184
185    // Master's updateConfiguration should throw because another cluster is active
186    assertThrows(ReadOnlyTransitionException.class, () -> master.updateConfiguration());
187
188    // Verify read-only coprocessors are still loaded on the master
189    assertTrue(CoprocessorConfigurationUtil.areReadOnlyCoprocessorsLoaded(master.getConfiguration(),
190      CoprocessorHost.MASTER_COPROCESSOR_CONF_KEY));
191
192    // Verify read-only coprocessors are still loaded on the region server
193    assertTrue(CoprocessorConfigurationUtil.areReadOnlyCoprocessorsLoaded(
194      regionServer.getConfiguration(), CoprocessorHost.REGIONSERVER_COPROCESSOR_CONF_KEY));
195  }
196
197  @Test
198  public void testCanDisableReadOnlyWhenOwnClusterIsActive() throws Exception {
199    // Enable read-only mode
200    setReadOnlyMode(true);
201    assertFalse(activeClusterIdFileExists());
202
203    // Verify read-only coprocessors are loaded
204    assertTrue(CoprocessorConfigurationUtil.areReadOnlyCoprocessorsLoaded(master.getConfiguration(),
205      CoprocessorHost.MASTER_COPROCESSOR_CONF_KEY));
206
207    // Disable read-only mode (our own cluster, no conflicting active cluster file)
208    setReadOnlyMode(false);
209
210    // Active cluster file should be recreated
211    assertTrue(activeClusterIdFileExists());
212
213    // Verify read-only coprocessors are removed
214    assertFalse(CoprocessorConfigurationUtil.areReadOnlyCoprocessorsLoaded(
215      master.getConfiguration(), CoprocessorHost.MASTER_COPROCESSOR_CONF_KEY));
216  }
217
218  @Test
219  public void testRegionCoprocessorsStillLoadWhenReadOnlyTransitionBlocked() throws Exception {
220    // Create a table so we have a region to work with
221    TableName tableName = TableName.valueOf("testCoprocessorLoadTable");
222    TableDescriptor desc = TableDescriptorBuilder.newBuilder(tableName)
223      .setColumnFamily(ColumnFamilyDescriptorBuilder.of("cf")).build();
224    TEST_UTIL.getAdmin().createTable(desc);
225    List<HRegion> regions = regionServer.getRegions(tableName);
226    assertFalse(regions.isEmpty());
227    HRegion region = regions.get(0);
228
229    // Enable read-only mode to load ReadOnly coprocessors on the region
230    Configuration readOnlyConf = new Configuration(conf);
231    readOnlyConf.setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY, true);
232    region.onConfigurationChange(readOnlyConf);
233
234    // Verify ReadOnly coprocessors are loaded
235    RegionCoprocessorHost regionCPHost = region.getCoprocessorHost();
236    assertNotNull(regionCPHost.findCoprocessor(RegionReadOnlyController.class.getName()),
237      "RegionReadOnlyController should be loaded after enabling read-only mode");
238
239    // Simulate another active cluster by writing a foreign cluster ID to the active cluster file
240    overwriteExistingFile();
241    assertTrue(activeClusterIdFileExists());
242
243    // Build a config that attempts to disable read-only AND adds new coprocessors
244    Configuration newConf = new Configuration(conf);
245    newConf.setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY, false);
246
247    // Add a system and user region coprocessors
248    newConf.set(CoprocessorHost.REGION_COPROCESSOR_CONF_KEY, SimpleRegionObserver.class.getName());
249    newConf.set(CoprocessorHost.USER_REGION_COPROCESSOR_CONF_KEY,
250      NoOpScanPolicyObserver.class.getName());
251
252    // Trigger dynamic configuration change on the region
253    region.onConfigurationChange(newConf);
254
255    // Verify ReadOnly coprocessors are still loaded since the read-only transition was blocked
256    regionCPHost = region.getCoprocessorHost();
257    assertTrue(
258      CoprocessorConfigurationUtil.areReadOnlyCoprocessorsLoaded(region.getConfiguration(),
259        CoprocessorHost.REGION_COPROCESSOR_CONF_KEY),
260      "ReadOnly coprocessors should remain in the region configuration");
261
262    // Verify new system and user coprocessors were loaded despite the blocked read-only transition
263    assertNotNull(regionCPHost.findCoprocessor(SimpleRegionObserver.class.getName()),
264      "SimpleRegionObserver should be loaded even when read-only transition is blocked");
265    assertNotNull(regionCPHost.findCoprocessor(NoOpScanPolicyObserver.class.getName()),
266      "NoOpScanPolicyObserver should be loaded even when read-only transition is blocked");
267  }
268
269  @Test
270  public void testRegionServerCannotDisableReadOnlyWhenAnotherClusterIsActive() throws Exception {
271    // First enable read-only mode
272    setReadOnlyMode(true);
273    assertFalse(activeClusterIdFileExists());
274
275    // Write an active cluster file with a different cluster's data
276    overwriteExistingFile();
277    assertTrue(activeClusterIdFileExists());
278
279    // Attempt to disable read-only mode, but get an exception because another active cluster
280    // already exists
281    regionServer.getConfiguration().setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY, false);
282
283    // RegionServer's updateConfiguration should throw because another cluster is active
284    assertThrows(ReadOnlyTransitionException.class, () -> regionServer.updateConfiguration());
285
286    // Verify read-only coprocessors are still loaded on the region server
287    assertTrue(CoprocessorConfigurationUtil.areReadOnlyCoprocessorsLoaded(
288      regionServer.getConfiguration(), CoprocessorHost.REGIONSERVER_COPROCESSOR_CONF_KEY));
289  }
290
291  @Test
292  public void testBlockedThenSuccessfulReadOnlyTransition() throws Exception {
293    // Put cluster in read-only mode with a foreign active cluster file
294    setReadOnlyMode(true);
295    assertFalse(activeClusterIdFileExists());
296    overwriteExistingFile();
297    assertTrue(activeClusterIdFileExists());
298
299    // Attempt to disable read-only mode. This should fail because another cluster is active.
300    // We need to run updateConfiguration() to ensure the readOnlyTransitionBlocked boolean in
301    // HBaseServerBase gets set.
302    master.getConfiguration().setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY, false);
303    assertThrows(ReadOnlyTransitionException.class, () -> master.updateConfiguration());
304    regionServer.getConfiguration().setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY, false);
305    assertThrows(ReadOnlyTransitionException.class, () -> regionServer.updateConfiguration());
306
307    // Try to create a table. This should fail because the cluster is still in read-only mode
308    TableName tableName = TableName.valueOf("testBlockedTransitionTable");
309    TableDescriptor desc = TableDescriptorBuilder.newBuilder(tableName)
310      .setColumnFamily(ColumnFamilyDescriptorBuilder.of("cf")).build();
311    assertThrows(IOException.class, () -> TEST_UTIL.getAdmin().createTable(desc));
312
313    // Delete the foreign active cluster file
314    fs.delete(activeClusterFile, false);
315    assertFalse(activeClusterIdFileExists());
316
317    // Disable read-only mode again. This should succeed now that no foreign active cluster file
318    // exists. We need to run updateConfiguration() to ensure the readOnlyTransitionBlocked boolean
319    // in HBaseServerBase was reset.
320    master.getConfiguration().setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY, false);
321    master.updateConfiguration();
322    regionServer.getConfiguration().setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY, false);
323    regionServer.updateConfiguration();
324
325    // Verify the active.cluster.suffix.id file contains this cluster's ID
326    assertTrue(activeClusterIdFileExists());
327    try (FSDataInputStream in = fs.open(activeClusterFile)) {
328      ActiveClusterSuffix actual = ActiveClusterSuffix.parseFrom(in.readAllBytes());
329      ActiveClusterSuffix expected = ActiveClusterSuffix.fromConfig(conf, mfs.getClusterId());
330      assertEquals(expected, actual);
331    }
332
333    // Create a table and add a row to verify read-only mode has been disabled
334    TEST_UTIL.getAdmin().createTable(desc);
335    try (Table table = TEST_UTIL.getConnection().getTable(tableName)) {
336      Put put = new Put(Bytes.toBytes("row1"));
337      put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("q1"), Bytes.toBytes("value1"));
338      table.put(put);
339    }
340  }
341}