Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions documentation/staging/content/security/openshift.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,11 @@ see [OpenShift]({{<relref "/userguide/platforms/environments#openshift">}}).
#### Using a dedicated namespace

When the user that installs an individual instance of the operator does not have the required privileges to create resources at the Kubernetes cluster level, a dedicated namespace can be used for the operator instance and all the WebLogic domains that it manages. For more details about the `dedicated` setting, please refer to [Operator Helm configuration values]({{< relref "/userguide/managing-operators/using-helm#operator-helm-configuration-values" >}}).

#### Set the Helm chart property `kubernetesPlatorm` to `Openshift`
Beginning with operator version 4.0, you should specify the `kubernetesPlatorm` Helm chart property and set its value to `Openshift` when installing the operator in Openshift. With this setting, the operator:
- Configures the correct file permissions for WebLogic Server to work in Openshift for [Model in Image]({{< relref "/samples/domains/model-in-image/_index.md" >}}), and [Domain home in Image]({{< relref "/samples/domains/domain-home-in-image/_index.md" >}}) domains.
- Sets `weblogic.SecureMode.WarnOnInsecureFileSystem` Java system property to `false` on each target WebLogic Server instance. This flag suppresses the insecure file system warnings in the WebLogic Server console in production mode. These warnings result from setting the correct file permissions to work with restricted security context constraints on Openshift.

For more information about the Helm chart, see the
[Operator Helm configuration values]({{<relref "/userguide/managing-operators/using-helm#operator-helm-configuration-values">}}).
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ Example:
javaLoggingLevel: "FINE"
```

##### `kubernetesPlatform`
Allows you to set the Kubernetes platform on which the operator is running. The value is case-insensitive.

This flag is helpful when using the operator in OpenShift because of the security requirements to run the WebLogic Server in OpenShift. See [Security requirements to run WebLogic in OpenShift]({{<relref "/security/openshift#security-requirements-to-run-weblogic-in-openshift">}}) for more details. When you set the `kubernetesPlatform` value to `Openshift`, the operator:
- Configures the correct file permissions for WebLogic Server to work in Openshift for [Model in Image]({{< relref "/samples/domains/model-in-image/_index.md" >}}), and [Domain home in Image]({{< relref "/samples/domains/domain-home-in-image/_index.md" >}}) domains.
- Sets `weblogic.SecureMode.WarnOnInsecureFileSystem` Java system property to `false` on each target WebLogic Server instance. This flag suppresses the insecure file system warnings in the WebLogic Server console in production mode. These warnings result from setting the correct file permissions to work with restricted security context constraints on Openshift.

Example:
```yaml
kubernetesPlatform: Openshift
```

#### Creating the operator pod

##### `image`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ Operator 2.5.0+ is certified for use on OpenShift Container Platform 4.3.0+ with
When using the operator in OpenShift, a security context constraint is required to ensure that WebLogic containers run with a UNIX UID that has the correct permissions on the domain file system.
This could be either the `anyuid` SCC or a custom one that you define for user/group `1000`. For more information, see [OpenShift]({{<relref "/security/openshift.md">}}) in the Security section.

Beginning with operator version 4.0, you should specify the `kubernetesPlatorm` Helm chart property and set its value to `Openshift` when installing the operator in Openshift. With this setting, the operator:
- Configures the correct file permissions for WebLogic Server to work in Openshift for [Model in Image]({{< relref "/samples/domains/model-in-image/_index.md" >}}), and [Domain home in Image]({{< relref "/samples/domains/domain-home-in-image/_index.md" >}}) domains.
- Sets `weblogic.SecureMode.WarnOnInsecureFileSystem` Java system property to `false` on each target WebLogic Server instance. This flag suppresses the insecure file system warnings in the WebLogic Server console in production mode. These warnings result from setting the correct file permissions to work with restricted security context constraints on Openshift.
For more information about the Helm chart, see the
[Operator Helm configuration values]({{<relref "/userguide/managing-operators/using-helm#operator-helm-configuration-values">}}).

### Important note about development-focused Kubernetes distributions

There are a number of development-focused distributions of Kubernetes, like kind, Minikube, Minishift, and so on.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ data:
{{- if .tokenReviewAuthentication }}
tokenReviewAuthentication: {{ .tokenReviewAuthentication | quote }}
{{- end }}
{{- if .kubernetesPlatform }}
kubernetesPlatform: {{ .kubernetesPlatform | quote }}
{{- end }}
kind: "ConfigMap"
metadata:
labels:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ spec:
value: "false"
- name: "JAVA_LOGGING_LEVEL"
value: {{ .javaLoggingLevel | quote }}
- name: "KUBERNETES_PLATFORM"
value: {{ .kubernetesPlatform | quote }}
- name: "JAVA_LOGGING_MAXSIZE"
value: {{ .javaLoggingFileSizeLimit | default 20000000 | quote }}
- name: "JAVA_LOGGING_COUNT"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ static TuningParameters getInstance() {

FeatureGates getFeatureGates();

String getKubernetesPlatform();

class MainTuning {
public final int initializationRetryDelaySeconds;
public final int domainPresenceFailureRetrySeconds;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import oracle.kubernetes.operator.logging.LoggingFactory;
import oracle.kubernetes.operator.logging.MessageKeys;

import static oracle.kubernetes.operator.helpers.BasePodStepContext.KUBERNETES_PLATFORM_HELM_VARIABLE;

public class TuningParametersImpl extends ConfigMapConsumer implements TuningParameters {
public static final int DEFAULT_CALL_LIMIT = 50;

Expand All @@ -28,6 +30,7 @@ public class TuningParametersImpl extends ConfigMapConsumer implements TuningPar
private WatchTuning watch = null;
private PodTuning pod = null;
private FeatureGates featureGates = null;
private String kubernetesPlatform = null;

private TuningParametersImpl(ScheduledExecutorService executorService) {
super(executorService);
Expand Down Expand Up @@ -95,6 +98,8 @@ private void update() {
FeatureGates featureGates =
new FeatureGates(generateFeatureGates(get("featureGates")));

String kubernetesPlatform = get(KUBERNETES_PLATFORM_HELM_VARIABLE);

lock.writeLock().lock();
try {
if (!main.equals(this.main)
Expand All @@ -109,6 +114,7 @@ private void update() {
this.watch = watch;
this.pod = pod;
this.featureGates = featureGates;
this.kubernetesPlatform = kubernetesPlatform;
} finally {
lock.writeLock().unlock();
}
Expand Down Expand Up @@ -175,4 +181,14 @@ public FeatureGates getFeatureGates() {
lock.readLock().unlock();
}
}

@Override
public String getKubernetesPlatform() {
lock.readLock().lock();
try {
return kubernetesPlatform;
} finally {
lock.readLock().unlock();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@

public abstract class BasePodStepContext extends StepContextBase {

public static final String KUBERNETES_PLATFORM_HELM_VARIABLE = "kubernetesPlatform";

BasePodStepContext(DomainPresenceInfo info) {
super(info);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,9 @@ List<V1EnvVar> getConfiguredEnvVars(TuningParameters tuningParameters) {
addEnvVar(vars, IntrospectorJobEnvVars.ISTIO_ENABLED, Boolean.toString(isIstioEnabled()));
addEnvVar(vars, IntrospectorJobEnvVars.ADMIN_CHANNEL_PORT_FORWARDING_ENABLED,
Boolean.toString(isAdminChannelPortForwardingEnabled(getDomain().getSpec())));
Optional.ofNullable(getKubernetesPlatform(tuningParameters))
.ifPresent(v -> addEnvVar(vars, ServerEnvVars.KUBERNETES_PLATFORM, v));

addEnvVar(vars, IntrospectorJobEnvVars.ISTIO_READINESS_PORT, Integer.toString(getIstioReadinessPort()));
addEnvVar(vars, IntrospectorJobEnvVars.ISTIO_POD_NAMESPACE, getNamespace());
if (isUseOnlineUpdate()) {
Expand Down Expand Up @@ -391,6 +394,10 @@ List<V1EnvVar> getConfiguredEnvVars(TuningParameters tuningParameters) {
return vars;
}

private String getKubernetesPlatform(TuningParameters tuningParameters) {
return tuningParameters.getKubernetesPlatform();
}

}

static class DomainIntrospectorJobStep extends Step {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ protected V1PodSpec createSpec(TuningParameters tuningParameters) {
@Override
List<V1EnvVar> getConfiguredEnvVars(TuningParameters tuningParameters) {
List<V1EnvVar> vars = createCopy(getServerSpec().getEnvironmentVariables());
addStartupEnvVars(vars);
addStartupEnvVars(vars, tuningParameters);
return vars;
}

Expand Down Expand Up @@ -526,7 +526,7 @@ List<V1EnvVar> getConfiguredEnvVars(TuningParameters tuningParameters) {
List<V1EnvVar> envVars = createCopy((List<V1EnvVar>) packet.get(ProcessingConstants.ENVVARS));

List<V1EnvVar> vars = new ArrayList<>(envVars);
addStartupEnvVars(vars);
addStartupEnvVars(vars, tuningParameters);
return vars;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -912,7 +912,7 @@ private V1SecretVolumeSource getRuntimeEncryptionSecretVolumeSource(String name)
* Sets the environment variables used by operator/src/main/resources/scripts/startServer.sh
* @param vars a list to which new variables are to be added
*/
void addStartupEnvVars(List<V1EnvVar> vars) {
void addStartupEnvVars(List<V1EnvVar> vars, TuningParameters tuningParameters) {
addEnvVar(vars, ServerEnvVars.DOMAIN_NAME, getDomainName());
addEnvVar(vars, ServerEnvVars.DOMAIN_HOME, getDomainHome());
addEnvVar(vars, ServerEnvVars.ADMIN_NAME, getAsName());
Expand All @@ -929,6 +929,8 @@ void addStartupEnvVars(List<V1EnvVar> vars) {
Optional.ofNullable(getDataHome()).ifPresent(v -> addEnvVar(vars, ServerEnvVars.DATA_HOME, v));
Optional.ofNullable(getServerSpec().getAuxiliaryImages()).ifPresent(cm -> addAuxiliaryImageEnv(cm, vars));
addEnvVarIfTrue(mockWls(), vars, "MOCK_WLS");
Optional.ofNullable(getKubernetesPlatform(tuningParameters)).ifPresent(v ->
addEnvVar(vars, ServerEnvVars.KUBERNETES_PLATFORM, v));
}

protected void addAuxiliaryImageEnv(List<AuxiliaryImage> auxiliaryImageList, List<V1EnvVar> vars) {
Expand Down Expand Up @@ -1079,6 +1081,10 @@ private boolean mockWls() {
return Boolean.getBoolean("mockWLS");
}

private String getKubernetesPlatform(TuningParameters tuningParameters) {
return tuningParameters.getKubernetesPlatform();
}

private abstract class BaseStep extends Step {
BaseStep() {
this(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,13 @@ public class ServerEnvVars {
/** If present, pod scripts will watch for changes to override configurations and move them into place. */
public static final String DYNAMIC_CONFIG_OVERRIDE = "DYNAMIC_CONFIG_OVERRIDE";

public static final String KUBERNETES_PLATFORM = "KUBERNETES_PLATFORM";

private static final List<String> RESERVED_NAMES = Arrays.asList(
DOMAIN_UID, DOMAIN_NAME, DOMAIN_HOME, NODEMGR_HOME, SERVER_NAME, SERVICE_NAME,
ADMIN_NAME, AS_SERVICE_NAME, ADMIN_PORT, ADMIN_PORT_SECURE, ADMIN_SERVER_PORT_SECURE,
LOG_HOME, SERVER_OUT_IN_POD_LOG, DATA_HOME, ACCESS_LOG_IN_LOG_HOME, DYNAMIC_CONFIG_OVERRIDE);
LOG_HOME, SERVER_OUT_IN_POD_LOG, DATA_HOME, ACCESS_LOG_IN_LOG_HOME, DYNAMIC_CONFIG_OVERRIDE,
KUBERNETES_PLATFORM);

static boolean isReserved(String name) {
return RESERVED_NAMES.contains(name);
Expand Down
4 changes: 4 additions & 0 deletions operator/src/main/resources/scripts/introspectDomain.py
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,10 @@ def generate(self):
self.close()

def addDomainConfig(self):
kubernetes_platform = self.env.getEnvOrDef("KUBERNETES_PLATFORM", "")
if (str(kubernetes_platform).upper() == 'OPENSHIFT'):
os.system("chmod -R g=u %s" % self.domain_home)

# Note: only config type is needed fmwconfig, security is excluded because it's in the primordial and contain
# all the many policies files
packcmd = "tar -pczf /tmp/domain.tar.gz %s/config/config.xml %s/config/jdbc/ %s/config/jms %s/config/coherence " \
Expand Down
12 changes: 9 additions & 3 deletions operator/src/main/resources/scripts/modelInImage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,7 @@ function createModelDomain() {
trace "Using newly created domain"
elif [ -f ${PRIMORDIAL_DOMAIN_ZIPPED} ] ; then
trace "Using existing primordial domain"
cd / && base64 -d ${PRIMORDIAL_DOMAIN_ZIPPED} > ${LOCAL_PRIM_DOMAIN_ZIP} && tar -xzf ${LOCAL_PRIM_DOMAIN_ZIP}
cd / && base64 -d ${PRIMORDIAL_DOMAIN_ZIPPED} > ${LOCAL_PRIM_DOMAIN_ZIP} && tar -pxzf ${LOCAL_PRIM_DOMAIN_ZIP}
# create empty lib since we don't archive it in primordial zip and WDT will fail without it
mkdir ${DOMAIN_HOME}/lib
# Since the SerializedSystem ini is encrypted, restore it first
Expand All @@ -564,7 +564,7 @@ function createModelDomain() {
function restoreDomainConfig() {
restoreEncodedTar "domainzip.secure" || return 1

chmod +x ${DOMAIN_HOME}/bin/*.sh ${DOMAIN_HOME}/*.sh || return 1
chmod u+x ${DOMAIN_HOME}/bin/*.sh ${DOMAIN_HOME}/*.sh || return 1
}

# Expands into the root directory the MII primordial domain, stored in one or more config maps
Expand All @@ -580,7 +580,7 @@ function restoreEncodedTar() {
cat $(ls ${OPERATOR_ROOT}/introspector*/${1} | sort -t- -k3) > /tmp/domain.secure || return 1
base64 -d "/tmp/domain.secure" > /tmp/domain.tar.gz || return 1

tar -xzf /tmp/domain.tar.gz || return 1
tar -pxzf /tmp/domain.tar.gz || return 1
}

# This is before WDT compareModel implementation
Expand Down Expand Up @@ -785,6 +785,12 @@ function createPrimordialDomain() {
local MII_PASSPHRASE=$(cat ${RUNTIME_ENCRYPTION_SECRET_PASSWORD})
encrypt_decrypt_domain_secret "encrypt" ${DOMAIN_HOME} ${MII_PASSPHRASE}

if [[ "${KUBERNETES_PLATFORM^^}" == "OPENSHIFT" ]]; then
# Operator running on Openshift platform - change file permissions in the DOMAIN_HOME dir to give
# group same permissions as user .
chmod -R g=u ${DOMAIN_HOME} || return 1
fi

tar -pczf ${LOCAL_PRIM_DOMAIN_ZIP} --exclude ${DOMAIN_HOME}/wlsdeploy --exclude ${DOMAIN_HOME}/sysman/log \
--exclude ${DOMAIN_HOME}/lib --exclude ${DOMAIN_HOME}/backup_config ${empath} ${DOMAIN_HOME}/*

Expand Down
14 changes: 14 additions & 0 deletions operator/src/main/resources/scripts/startServer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ createFolder ${DOMAIN_HOME}/servers/${SERVER_NAME}/security
copyIfChanged /weblogic-operator/introspector/boot.properties \
${DOMAIN_HOME}/servers/${SERVER_NAME}/security/boot.properties

# remove write and execute permissions for group to prevent insecure file system warnings.
chmod g-wx ${DOMAIN_HOME}/servers/${SERVER_NAME}/security/boot.properties


if [ ${DOMAIN_SOURCE_TYPE} != "FromModel" ]; then
trace "Copying situational configuration files from operator cm to ${DOMAIN_HOME}/optconfig directory"
copySitCfgWhileBooting /weblogic-operator/introspector ${DOMAIN_HOME}/optconfig 'Sit-Cfg-CFG--'
Expand All @@ -298,6 +302,16 @@ if [ ${DOMAIN_SOURCE_TYPE} != "FromModel" ]; then
copySitCfgWhileBooting /weblogic-operator/introspector ${DOMAIN_HOME}/optconfig/diagnostics 'Sit-Cfg-WLDF--'
fi

if [[ "${KUBERNETES_PLATFORM^^}" == "OPENSHIFT" ]]; then
# When the Operator is running on Openshift platform, disable insecure file system warnings.
export JAVA_OPTIONS="-Dweblogic.SecureMode.WarnOnInsecureFileSystem=false $JAVA_OPTIONS"
if [[ "${DOMAIN_SOURCE_TYPE}" == "Image" ]]; then
# Change the file permissions in the DOMAIN_HOME dir to give group same permissions as user .
chmod -R g=u ${DOMAIN_HOME} || return 1
fi

fi

#
# Start WLS
#
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
import static oracle.kubernetes.operator.DomainProcessorTestSetup.createTestDomain;
import static oracle.kubernetes.operator.ProcessingConstants.DOMAIN_TOPOLOGY;
import static oracle.kubernetes.operator.ProcessingConstants.JOBWATCHER_COMPONENT_NAME;
import static oracle.kubernetes.operator.helpers.BasePodStepContext.KUBERNETES_PLATFORM_HELM_VARIABLE;
import static oracle.kubernetes.operator.helpers.Matchers.hasConfigMapVolume;
import static oracle.kubernetes.operator.helpers.Matchers.hasContainer;
import static oracle.kubernetes.operator.helpers.Matchers.hasEnvVar;
Expand Down Expand Up @@ -985,6 +986,25 @@ void whenNotConfigured_introspectorPodSpec_hasTrueAccessLogInLogHomeEnvVar() {
);
}

@Test
void whenOperatorHasKubernetesPlatformConfigured_introspectorPodSpecHasKubernetesPlatformEnvVariable() {
TuningParametersStub.setParameter(KUBERNETES_PLATFORM_HELM_VARIABLE, "Openshift");
V1JobSpec jobSpec = createJobSpec();

assertThat(getMatchingContainerEnv(domainPresenceInfo, jobSpec),
hasEnvVar(ServerEnvVars.KUBERNETES_PLATFORM, "Openshift")
);
}

@Test
void whenNotConfigured_KubernetesPlatform_introspectorPodSpecHasNoKubernetesPlatformEnvVariable() {
V1JobSpec jobSpec = createJobSpec();

assertThat(getMatchingContainerEnv(domainPresenceInfo, jobSpec),
not(hasEnvVar(ServerEnvVars.KUBERNETES_PLATFORM, "Openshift"))
);
}

@Test
void whenNoExistingTopologyRunIntrospector() {
runCreateJob();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
import static oracle.kubernetes.operator.ProcessingConstants.MII_DYNAMIC_UPDATE_SUCCESS;
import static oracle.kubernetes.operator.ProcessingConstants.SERVER_SCAN;
import static oracle.kubernetes.operator.helpers.AnnotationHelper.SHA256_ANNOTATION;
import static oracle.kubernetes.operator.helpers.BasePodStepContext.KUBERNETES_PLATFORM_HELM_VARIABLE;
import static oracle.kubernetes.operator.helpers.DomainIntrospectorJobTest.TEST_VOLUME_NAME;
import static oracle.kubernetes.operator.helpers.DomainStatusMatcher.hasStatus;
import static oracle.kubernetes.operator.helpers.EventHelper.EventItem.DOMAIN_ROLL_STARTING;
Expand Down Expand Up @@ -1077,6 +1078,21 @@ void whenPodCreated_withLivenessCustomScriptSpecified_hasEnvVariable() {
assertThat(getCreatedPodSpecContainer().getEnv(), hasEnvVar("LIVENESS_PROBE_CUSTOM_SCRIPT", customScript));
}

@Test
void whenOperatorHasKubernetesPlatformConfigured_createdPodSpecContainerHasKubernetesPlatformEnvVariable() {
TuningParametersStub.setParameter(KUBERNETES_PLATFORM_HELM_VARIABLE, "Openshift");
assertThat(getCreatedPodSpecContainer().getEnv(),
hasEnvVar(ServerEnvVars.KUBERNETES_PLATFORM, "Openshift")
);
}

@Test
void whenNotConfigured_KubernetesPlatform_createdPodSpecContainerHasNoKubernetesPlatformEnvVariable() {
assertThat(getCreatedPodSpecContainer().getEnv(),
not(hasEnvVar(ServerEnvVars.KUBERNETES_PLATFORM, "Openshift"))
);
}

private static final String OVERRIDE_DATA_DIR = "/u01/data";
private static final String OVERRIDE_DATA_HOME = OVERRIDE_DATA_DIR + File.separator + UID;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,9 @@ public Set<Entry<String, String>> entrySet() {
public FeatureGates getFeatureGates() {
return new FeatureGates(Collections.singletonList(ENABLED_FEATURE));
}

@Override
public String getKubernetesPlatform() {
return namedParameters.get("kubernetesPlatform");
}
}
8 changes: 4 additions & 4 deletions operator/src/test/sh/modelInImageTest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,13 @@ testOnRestoreDomainConfig_base64DecodeZip() {
testOnRestoreDomainConfig_unTarDomain() {
restoreDomainConfig

assertEquals "TAR command arguments" "-xzf /tmp/domain.tar.gz" "$TAR_ARGS"
assertEquals "TAR command arguments" "-pxzf /tmp/domain.tar.gz" "$TAR_ARGS"
}

testOnRestoreDomainConfig_makeScriptsExecutable() {
restoreDomainConfig

assertEquals "CD command arguments" "+x ${DOMAIN_HOME}/bin/*.sh ${DOMAIN_HOME}/*.sh" "$CHMOD_ARGS"
assertEquals "CD command arguments" "u+x ${DOMAIN_HOME}/bin/*.sh ${DOMAIN_HOME}/*.sh" "$CHMOD_ARGS"
}

testOnRestorePrimordialDomain_useRootDirectory() {
Expand Down Expand Up @@ -119,7 +119,7 @@ testOnRestoreDomainConfig_whenNoIndexesDefinedCatSingleFile() {
testOnRestorePrimordialDomain_unTarDomain() {
restorePrimordialDomain

assertEquals "TAR command arguments" "-xzf /tmp/domain.tar.gz" "$TAR_ARGS"
assertEquals "TAR command arguments" "-pxzf /tmp/domain.tar.gz" "$TAR_ARGS"
}

######################### Mocks for the tests ###############
Expand Down Expand Up @@ -168,4 +168,4 @@ chmod() {
. ${SCRIPTPATH}/modelInImage.sh

# shellcheck source=target/classes/shunit/shunit2
. ${SHUNIT2_PATH}
. ${SHUNIT2_PATH}