HADOOP-17002. ABFS: Adding config to determine if the account is HNS enabled or not

Contributed by Bilahari T H.
This commit is contained in:
bilaharith 2020-04-24 06:16:18 +05:30 committed by GitHub
parent 9827ff2961
commit 30ef8d0f1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 440 additions and 23 deletions

View File

@ -47,6 +47,7 @@
import org.apache.hadoop.fs.azurebfs.diagnostics.IntegerConfigurationBasicValidator;
import org.apache.hadoop.fs.azurebfs.diagnostics.LongConfigurationBasicValidator;
import org.apache.hadoop.fs.azurebfs.diagnostics.StringConfigurationBasicValidator;
import org.apache.hadoop.fs.azurebfs.enums.Trilean;
import org.apache.hadoop.fs.azurebfs.extensions.CustomTokenProviderAdaptee;
import org.apache.hadoop.fs.azurebfs.extensions.SASTokenProvider;
import org.apache.hadoop.fs.azurebfs.oauth2.AccessTokenProvider;
@ -81,6 +82,10 @@ public class AbfsConfiguration{
private final boolean isSecure;
private static final Logger LOG = LoggerFactory.getLogger(AbfsConfiguration.class);
@StringConfigurationValidatorAnnotation(ConfigurationKey = FS_AZURE_ACCOUNT_IS_HNS_ENABLED,
DefaultValue = DEFAULT_FS_AZURE_ACCOUNT_IS_HNS_ENABLED)
private String isNamespaceEnabledAccount;
@IntegerConfigurationValidatorAnnotation(ConfigurationKey = AZURE_WRITE_BUFFER_SIZE,
MinValue = MIN_BUFFER_SIZE,
MaxValue = MAX_BUFFER_SIZE,
@ -232,6 +237,10 @@ public AbfsConfiguration(final Configuration rawConfig, String accountName)
}
}
public Trilean getIsNamespaceEnabledAccount() {
return Trilean.getTrilean(isNamespaceEnabledAccount);
}
/**
* Gets the Azure Storage account name corresponding to this instance of configuration.
* @return the Azure Storage account name
@ -746,6 +755,11 @@ public void setMaxIoRetries(int maxIoRetries) {
this.maxIoRetries = maxIoRetries;
}
@VisibleForTesting
void setIsNamespaceEnabledAccount(String isNamespaceEnabledAccount) {
this.isNamespaceEnabledAccount = isNamespaceEnabledAccount;
}
private String getTrimmedPasswordString(String key, String defaultValue) throws IOException {
String value = getPasswordString(key);
if (StringUtils.isBlank(value)) {

View File

@ -73,6 +73,8 @@
import org.apache.hadoop.fs.azurebfs.contracts.services.AzureServiceErrorCode;
import org.apache.hadoop.fs.azurebfs.contracts.services.ListResultEntrySchema;
import org.apache.hadoop.fs.azurebfs.contracts.services.ListResultSchema;
import org.apache.hadoop.fs.azurebfs.contracts.exceptions.TrileanConversionException;
import org.apache.hadoop.fs.azurebfs.enums.Trilean;
import org.apache.hadoop.fs.azurebfs.extensions.SASTokenProvider;
import org.apache.hadoop.fs.azurebfs.extensions.ExtensionHelper;
import org.apache.hadoop.fs.azurebfs.oauth2.AccessTokenProvider;
@ -133,8 +135,7 @@ public class AzureBlobFileSystemStore implements Closeable {
private final AbfsConfiguration abfsConfiguration;
private final Set<String> azureAtomicRenameDirSet;
private boolean isNamespaceEnabledSet;
private boolean isNamespaceEnabled;
private Trilean isNamespaceEnabled;
private final AuthType authType;
private final UserGroupInformation userGroupInformation;
private final IdentityTransformer identityTransformer;
@ -155,6 +156,8 @@ public AzureBlobFileSystemStore(URI uri, boolean isSecureScheme, Configuration c
LOG.trace("AbfsConfiguration init complete");
this.isNamespaceEnabled = abfsConfiguration.getIsNamespaceEnabledAccount();
this.userGroupInformation = UserGroupInformation.getCurrentUser();
this.userName = userGroupInformation.getShortUserName();
LOG.trace("UGI init complete");
@ -234,26 +237,33 @@ private String[] authorityParts(URI uri) throws InvalidUriAuthorityException, In
}
public boolean getIsNamespaceEnabled() throws AzureBlobFileSystemException {
if (!isNamespaceEnabledSet) {
LOG.debug("Get root ACL status");
try (AbfsPerfInfo perfInfo = startTracking("getIsNamespaceEnabled", "getAclStatus")) {
AbfsRestOperation op = client.getAclStatus(AbfsHttpConstants.FORWARD_SLASH + AbfsHttpConstants.ROOT_PATH);
perfInfo.registerResult(op.getResult());
isNamespaceEnabled = true;
perfInfo.registerSuccess(true);
} catch (AbfsRestOperationException ex) {
// Get ACL status is a HEAD request, its response doesn't contain errorCode
// So can only rely on its status code to determine its account type.
if (HttpURLConnection.HTTP_BAD_REQUEST != ex.getStatusCode()) {
throw ex;
}
isNamespaceEnabled = false;
}
isNamespaceEnabledSet = true;
try {
return this.isNamespaceEnabled.toBoolean();
} catch (TrileanConversionException e) {
LOG.debug("isNamespaceEnabled is UNKNOWN; fall back and determine through"
+ " getAcl server call", e);
}
return isNamespaceEnabled;
LOG.debug("Get root ACL status");
try (AbfsPerfInfo perfInfo = startTracking("getIsNamespaceEnabled",
"getAclStatus")) {
AbfsRestOperation op = client.getAclStatus(
AbfsHttpConstants.FORWARD_SLASH + AbfsHttpConstants.ROOT_PATH);
perfInfo.registerResult(op.getResult());
isNamespaceEnabled = Trilean.getTrilean(true);
perfInfo.registerSuccess(true);
} catch (AbfsRestOperationException ex) {
// Get ACL status is a HEAD request, its response doesn't contain
// errorCode
// So can only rely on its status code to determine its account type.
if (HttpURLConnection.HTTP_BAD_REQUEST != ex.getStatusCode()) {
throw ex;
}
isNamespaceEnabled = Trilean.getTrilean(false);
}
return isNamespaceEnabled.toBoolean();
}
@VisibleForTesting
@ -1406,4 +1416,15 @@ public String toString() {
AbfsClient getClient() {
return this.client;
}
}
@VisibleForTesting
void setClient(AbfsClient client) {
this.client = client;
}
@VisibleForTesting
void setNamespaceEnabled(Trilean isNamespaceEnabled){
this.isNamespaceEnabled = isNamespaceEnabled;
}
}

View File

@ -27,6 +27,13 @@
@InterfaceAudience.Public
@InterfaceStability.Evolving
public final class ConfigurationKeys {
/**
* Config to specify if the configured account is HNS enabled or not. If
* this config is not set, getacl call is made on account filesystem root
* path to determine HNS status.
*/
public static final String FS_AZURE_ACCOUNT_IS_HNS_ENABLED = "fs.azure.account.hns.enabled";
public static final String FS_AZURE_ACCOUNT_KEY_PROPERTY_NAME = "fs.azure.account.key";
public static final String FS_AZURE_ACCOUNT_KEY_PROPERTY_NAME_REGX = "fs\\.azure\\.account\\.key\\.(.*)";
public static final String FS_AZURE_SECURE_MODE = "fs.azure.secure.mode";

View File

@ -30,6 +30,9 @@
@InterfaceAudience.Public
@InterfaceStability.Evolving
public final class FileSystemConfigurations {
public static final String DEFAULT_FS_AZURE_ACCOUNT_IS_HNS_ENABLED = "";
public static final String USER_HOME_DIRECTORY_PREFIX = "/user";
// Retry parameter defaults.

View File

@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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 org.apache.hadoop.fs.azurebfs.contracts.exceptions;
import org.apache.hadoop.classification.InterfaceAudience;
import org.apache.hadoop.classification.InterfaceStability;
/**
* Thrown when tried to convert Trilean.UNKNOWN to boolean. Only Trilean.TRUE
* and Trilean.FALSE can be converted to boolean.
*/
@InterfaceAudience.Public
@InterfaceStability.Stable
public final class TrileanConversionException
extends AzureBlobFileSystemException {
public TrileanConversionException() {
super("Cannot convert Trilean.UNKNOWN to boolean");
}
}

View File

@ -0,0 +1,80 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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 org.apache.hadoop.fs.azurebfs.enums;
import org.apache.hadoop.fs.azurebfs.contracts.exceptions.TrileanConversionException;
/**
* Enum to represent 3 values, TRUE, FALSE and UNKNOWN. Can be used where
* boolean is not enough to hold the information.
*/
public enum Trilean {
FALSE, TRUE, UNKNOWN;
private static final String TRUE_STR = "true";
private static final String FALSE_STR = "false";
/**
* Converts boolean to Trilean.
*
* @param isTrue the boolean to convert.
* @return the corresponding Trilean for the passed boolean isTrue.
*/
public static Trilean getTrilean(final boolean isTrue) {
if (isTrue) {
return Trilean.TRUE;
}
return Trilean.FALSE;
}
/**
* Converts String to Trilean.
*
* @param str the string to convert.
* @return the corresponding Trilean for the passed string str.
*/
public static Trilean getTrilean(String str) {
if (TRUE_STR.equalsIgnoreCase(str)) {
return Trilean.TRUE;
}
if (FALSE_STR.equalsIgnoreCase(str)) {
return Trilean.FALSE;
}
return Trilean.UNKNOWN;
}
/**
* Converts the Trilean enum to boolean.
*
* @return the corresponding boolean.
* @throws TrileanConversionException when tried to convert Trilean.UNKNOWN.
*/
public boolean toBoolean() throws TrileanConversionException {
if (this == Trilean.UNKNOWN) {
throw new TrileanConversionException();
}
return Boolean.valueOf(this.name());
}
}

View File

@ -0,0 +1,22 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
@InterfaceAudience.Public
@InterfaceStability.Evolving
package org.apache.hadoop.fs.azurebfs.enums;
import org.apache.hadoop.classification.InterfaceAudience;
import org.apache.hadoop.classification.InterfaceStability;

View File

@ -663,6 +663,11 @@ Hflush() being the only documented API that can provide persistent data
transfer, Flush() also attempting to persist buffered data will lead to
performance issues.
### <a name="hnscheckconfigoptions"></a> HNS Check Options
Config `fs.azure.account.hns.enabled` provides an option to specify whether
the storage account is HNS enabled or not. In case the config is not provided,
a server call is made to check the same.
### <a name="flushconfigoptions"></a> Access Options
Config `fs.azure.enable.check.access` needs to be set true to enable
the AzureBlobFileSystem.access().

View File

@ -21,15 +21,31 @@
import java.io.IOException;
import java.util.UUID;
import org.apache.hadoop.fs.azurebfs.enums.Trilean;
import org.junit.Assume;
import org.junit.Test;
import org.assertj.core.api.Assertions;
import org.apache.hadoop.fs.CommonConfigurationKeysPublic;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.azurebfs.services.AbfsClient;
import org.apache.hadoop.fs.azurebfs.services.AbfsRestOperation;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.fs.azurebfs.contracts.exceptions.AbfsRestOperationException;
import org.apache.hadoop.fs.azurebfs.services.AuthType;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.apache.hadoop.fs.azurebfs.constants.FileSystemConfigurations.DEFAULT_FS_AZURE_ACCOUNT_IS_HNS_ENABLED;
import static org.apache.hadoop.fs.azurebfs.constants.TestConfigurationKeys.TEST_CONFIGURATION_FILE_NAME;
import static org.apache.hadoop.fs.azurebfs.constants.ConfigurationKeys.AZURE_CREATE_REMOTE_FILESYSTEM_DURING_INITIALIZATION;
import static org.apache.hadoop.fs.azurebfs.constants.ConfigurationKeys.FS_AZURE_ACCOUNT_IS_HNS_ENABLED;
import static org.apache.hadoop.fs.azurebfs.constants.ConfigurationKeys.FS_AZURE_ACCOUNT_KEY_PROPERTY_NAME;
import static org.apache.hadoop.fs.azurebfs.constants.TestConfigurationKeys.FS_AZURE_TEST_NAMESPACE_ENABLED_ACCOUNT;
import static org.apache.hadoop.test.LambdaTestUtils.intercept;
@ -39,6 +55,9 @@
*/
public class ITestGetNameSpaceEnabled extends AbstractAbfsIntegrationTest {
private static final String TRUE_STR = "true";
private static final String FALSE_STR = "false";
private boolean isUsingXNSAccount;
public ITestGetNameSpaceEnabled() throws Exception {
isUsingXNSAccount = getConfiguration().getBoolean(FS_AZURE_TEST_NAMESPACE_ENABLED_ACCOUNT, false);
@ -57,7 +76,57 @@ public void testNonXNSAccount() throws IOException {
Assume.assumeFalse("Skip this test because the account being used for test is a XNS account",
isUsingXNSAccount);
assertFalse("Expecting getIsNamespaceEnabled() return false",
getFileSystem().getIsNamespaceEnabled());
getFileSystem().getIsNamespaceEnabled());
}
@Test
public void testGetIsNamespaceEnabledWhenConfigIsTrue() throws Exception {
AzureBlobFileSystem fs = getNewFSWithHnsConf(TRUE_STR);
Assertions.assertThat(fs.getIsNamespaceEnabled()).describedAs(
"getIsNamespaceEnabled should return true when the "
+ "config is set as true").isTrue();
fs.getAbfsStore().deleteFilesystem();
unsetAndAssert();
}
@Test
public void testGetIsNamespaceEnabledWhenConfigIsFalse() throws Exception {
AzureBlobFileSystem fs = getNewFSWithHnsConf(FALSE_STR);
Assertions.assertThat(fs.getIsNamespaceEnabled()).describedAs(
"getIsNamespaceEnabled should return false when the "
+ "config is set as false").isFalse();
fs.getAbfsStore().deleteFilesystem();
unsetAndAssert();
}
private void unsetAndAssert() throws Exception {
AzureBlobFileSystem fs = getNewFSWithHnsConf(
DEFAULT_FS_AZURE_ACCOUNT_IS_HNS_ENABLED);
boolean expectedValue = this.getConfiguration()
.getBoolean(FS_AZURE_TEST_NAMESPACE_ENABLED_ACCOUNT, false);
Assertions.assertThat(fs.getIsNamespaceEnabled()).describedAs(
"getIsNamespaceEnabled should return the value "
+ "configured for fs.azure.test.namespace.enabled")
.isEqualTo(expectedValue);
fs.getAbfsStore().deleteFilesystem();
}
private AzureBlobFileSystem getNewFSWithHnsConf(
String isNamespaceEnabledAccount) throws Exception {
Configuration rawConfig = new Configuration();
rawConfig.addResource(TEST_CONFIGURATION_FILE_NAME);
rawConfig.set(FS_AZURE_ACCOUNT_IS_HNS_ENABLED, isNamespaceEnabledAccount);
rawConfig
.setBoolean(AZURE_CREATE_REMOTE_FILESYSTEM_DURING_INITIALIZATION, true);
rawConfig.set(CommonConfigurationKeysPublic.FS_DEFAULT_NAME_KEY,
getNonExistingUrl());
return (AzureBlobFileSystem) FileSystem.get(rawConfig);
}
private String getNonExistingUrl() {
String testUri = this.getTestUrl();
return getAbfsScheme() + "://" + UUID.randomUUID() + testUri
.substring(testUri.indexOf("@"));
}
@Test
@ -95,4 +164,72 @@ public void testFailedRequestWhenCredentialsNotCorrect() throws Exception {
fs.getIsNamespaceEnabled();
});
}
}
@Test
public void testEnsureGetAclCallIsMadeOnceWhenConfigIsInvalid()
throws Exception {
unsetConfAndEnsureGetAclCallIsMadeOnce();
ensureGetAclCallIsMadeOnceForInvalidConf(" ");
unsetConfAndEnsureGetAclCallIsMadeOnce();
ensureGetAclCallIsMadeOnceForInvalidConf("Invalid conf");
unsetConfAndEnsureGetAclCallIsMadeOnce();
}
@Test
public void testEnsureGetAclCallIsNeverMadeWhenConfigIsValid()
throws Exception {
unsetConfAndEnsureGetAclCallIsMadeOnce();
ensureGetAclCallIsNeverMadeForValidConf(FALSE_STR.toLowerCase());
unsetConfAndEnsureGetAclCallIsMadeOnce();
ensureGetAclCallIsNeverMadeForValidConf(FALSE_STR.toUpperCase());
unsetConfAndEnsureGetAclCallIsMadeOnce();
ensureGetAclCallIsNeverMadeForValidConf(TRUE_STR.toLowerCase());
unsetConfAndEnsureGetAclCallIsMadeOnce();
ensureGetAclCallIsNeverMadeForValidConf(TRUE_STR.toUpperCase());
unsetConfAndEnsureGetAclCallIsMadeOnce();
}
@Test
public void testEnsureGetAclCallIsMadeOnceWhenConfigIsNotPresent()
throws IOException {
unsetConfAndEnsureGetAclCallIsMadeOnce();
}
private void ensureGetAclCallIsMadeOnceForInvalidConf(String invalidConf)
throws Exception {
this.getFileSystem().getAbfsStore()
.setNamespaceEnabled(Trilean.getTrilean(invalidConf));
AbfsClient mockClient =
callAbfsGetIsNamespaceEnabledAndReturnMockAbfsClient();
verify(mockClient, times(1)).getAclStatus(anyString());
}
private void ensureGetAclCallIsNeverMadeForValidConf(String validConf)
throws Exception {
this.getFileSystem().getAbfsStore()
.setNamespaceEnabled(Trilean.getTrilean(validConf));
AbfsClient mockClient =
callAbfsGetIsNamespaceEnabledAndReturnMockAbfsClient();
verify(mockClient, never()).getAclStatus(anyString());
}
private void unsetConfAndEnsureGetAclCallIsMadeOnce() throws IOException {
this.getFileSystem().getAbfsStore().setNamespaceEnabled(Trilean.UNKNOWN);
AbfsClient mockClient =
callAbfsGetIsNamespaceEnabledAndReturnMockAbfsClient();
verify(mockClient, times(1)).getAclStatus(anyString());
}
private AbfsClient callAbfsGetIsNamespaceEnabledAndReturnMockAbfsClient()
throws IOException {
final AzureBlobFileSystem abfs = this.getFileSystem();
final AzureBlobFileSystemStore abfsStore = abfs.getAbfsStore();
final AbfsClient mockClient = mock(AbfsClient.class);
doReturn(mock(AbfsRestOperation.class)).when(mockClient)
.getAclStatus(anyString());
abfsStore.setClient(mockClient);
abfs.getIsNamespaceEnabled();
return mockClient;
}
}

View File

@ -0,0 +1,92 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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 org.apache.hadoop.fs.azurebfs;
import org.junit.Test;
import org.apache.hadoop.fs.azurebfs.contracts.exceptions.TrileanConversionException;
import org.apache.hadoop.fs.azurebfs.enums.Trilean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
/**
* Tests for the enum Trilean.
*/
public class TrileanTests {
private static final String TRUE_STR = "true";
private static final String FALSE_STR = "false";
@Test
public void testGetTrileanForBoolean() {
assertThat(Trilean.getTrilean(true)).describedAs(
"getTrilean should return Trilean.TRUE when true is passed")
.isEqualTo(Trilean.TRUE);
assertThat(Trilean.getTrilean(false)).describedAs(
"getTrilean should return Trilean.FALSE when false is passed")
.isEqualTo(Trilean.FALSE);
}
@Test
public void testGetTrileanForString() {
assertThat(Trilean.getTrilean(TRUE_STR.toLowerCase())).describedAs(
"getTrilean should return Trilean.TRUE when true is passed")
.isEqualTo(Trilean.TRUE);
assertThat(Trilean.getTrilean(TRUE_STR.toUpperCase())).describedAs(
"getTrilean should return Trilean.TRUE when TRUE is passed")
.isEqualTo(Trilean.TRUE);
assertThat(Trilean.getTrilean(FALSE_STR.toLowerCase())).describedAs(
"getTrilean should return Trilean.FALSE when false is passed")
.isEqualTo(Trilean.FALSE);
assertThat(Trilean.getTrilean(FALSE_STR.toUpperCase())).describedAs(
"getTrilean should return Trilean.FALSE when FALSE is passed")
.isEqualTo(Trilean.FALSE);
testInvalidString(null);
testInvalidString(" ");
testInvalidString("invalid");
testInvalidString("truee");
testInvalidString("falsee");
}
private void testInvalidString(String invalidString) {
assertThat(Trilean.getTrilean(invalidString)).describedAs(
"getTrilean should return Trilean.UNKNOWN for anything not true/false")
.isEqualTo(Trilean.UNKNOWN);
}
@Test
public void testToBoolean() throws TrileanConversionException {
assertThat(Trilean.TRUE.toBoolean())
.describedAs("toBoolean should return true for Trilean.TRUE").isTrue();
assertThat(Trilean.FALSE.toBoolean())
.describedAs("toBoolean should return false for Trilean.FALSE")
.isFalse();
assertThat(catchThrowable(() -> Trilean.UNKNOWN.toBoolean())).describedAs(
"toBoolean on Trilean.UNKNOWN results in TrileanConversionException")
.isInstanceOf(TrileanConversionException.class).describedAs(
"Exception message should be: catchThrowable(()->Trilean.UNKNOWN"
+ ".toBoolean())")
.hasMessage("Cannot convert Trilean.UNKNOWN to boolean");
}
}