mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 08:18:18 +00:00
e2f394a897
* helm chart: one data drive per node - fix1 * refactor(helm): prevent negative or 0 replicaCount Co-authored-by: Copilot <copilot@github.com> * refactor(helm): remove env REPLICA_COUNT * feat(helm): default no logs directory to force stdout Co-authored-by: Copilot <copilot@github.com> * feat(helm): add drivesPerNode * feat(helm): correct default parity with 4 nodes / 1 drive per node * conditional render of RUSTFS_OBS_LOG_DIRECTORY * feat(chart): add table doc for parity * fix(chart): handle invalid annotation objects * fix(chart): move logging and obsevability options together; default for kubernetes output to stdout * feat(chart): better table doc for parity * fix(chart): leave RUSTFS_OBS_LOG_DIRECTORY empty, or the defaults will attempt to write to readonly fs * fix(chart): chart defaults as the previous version: 4 nodes with 4 drives per node * feat(chart): render RUSTFS_STORAGE_CLASS_STANDARD in configmap * minor fix for pvcAnnotations Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Cristian Chiru <cristi.chiru@gmail.com> * fix(chart): better drivesPerNode handling * fix(chart): obs_log_directory default non empty again to preserve previous deployments compatibility * fix(chart): proper drivesPerNode impl * fix(chart): values clarification for empty vs unset `obs_log_directory` * feat(chart): add service externalIPs * feat(helm): add optional service labels * fix(helm): merge service labels * fix(helm): storageclass pvcAnnotations * fix(chart): move logging and obsevability options together; default for kubernetes output to stdout * fix(helm): remove duplicate keys * fix(helm): default drivesPerNode null, keep legacy chart behavior * feat(helm): add template test for externalIPs * fix(helm): per-pool drivesPerNode inference, restore regression tests Repairs the drivesPerNode feature from #2693 so it renders and stays upgrade-safe: - define the missing drives inference (rustfs.poolDrives) and compute it per pool inside rustfs.pools, so mixed 4x4 + 16x1 pool deployments keep their exact legacy volumeClaimTemplates when drivesPerNode is unset - restore the $poolsEnabled definition dropped in the rebase (chart failed to render at all) - fix .Values references inside the pool range (dot is the pool there) and keep per-pool storageclass pvcAnnotations overrides working - make rustfs.volumes derive the drive range from pool drives instead of the pod count, and relax the pools.list 4-or-16 restriction to >= 2 - reject drivesPerNode=0 explicitly instead of silently inferring - keep default rendering identical to main: storage_class_standard stays unrendered by default, obs_endpoint.use_stdout stays false, clusterDomain value restored - restore the clusterDomain/mTLS SAN/explicit-volumes regression tests that the branch deleted, keeping the new topology tests --------- Signed-off-by: Cristian Chiru <cristi.chiru@gmail.com> Co-authored-by: Cristian Chiru <cristi.chiru@gmail.com> Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
373 lines
15 KiB
Bash
Executable File
373 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
CHART_DIR="$ROOT_DIR/helm/rustfs"
|
|
|
|
render_chart() {
|
|
helm template rustfs "$CHART_DIR" \
|
|
--namespace rustfs \
|
|
--set mode.distributed.enabled=false \
|
|
--set mode.standalone.enabled=true \
|
|
--set secret.rustfs.access_key=test-access-key \
|
|
--set secret.rustfs.secret_key=test-secret-key \
|
|
"$@"
|
|
}
|
|
|
|
render_standalone_deployment() {
|
|
render_chart "$@" |
|
|
awk '
|
|
/^# Source: rustfs\/templates\/deployment.yaml$/ { in_deployment = 1 }
|
|
in_deployment && /^---$/ { exit }
|
|
in_deployment { print }
|
|
'
|
|
}
|
|
|
|
render_distributed_statefulset() {
|
|
helm template rustfs "$CHART_DIR" \
|
|
--namespace rustfs \
|
|
--set secret.rustfs.access_key=test-access-key \
|
|
--set secret.rustfs.secret_key=test-secret-key \
|
|
"$@" |
|
|
awk '
|
|
/^# Source: rustfs\/templates\/statefulset.yaml$/ { in_statefulset = 1 }
|
|
in_statefulset && /^---$/ { exit }
|
|
in_statefulset { print }
|
|
'
|
|
}
|
|
|
|
render_distributed_configmap() {
|
|
helm template rustfs "$CHART_DIR" \
|
|
--namespace rustfs \
|
|
--set secret.rustfs.access_key=test-access-key \
|
|
--set secret.rustfs.secret_key=test-secret-key \
|
|
"$@" |
|
|
awk '
|
|
/^# Source: rustfs\/templates\/configmap.yaml$/ { in_configmap = 1 }
|
|
in_configmap && /^---$/ { exit }
|
|
in_configmap { print }
|
|
'
|
|
}
|
|
|
|
render_server_cert() {
|
|
helm template rustfs "$CHART_DIR" \
|
|
--namespace rustfs \
|
|
--set secret.rustfs.access_key=test-access-key \
|
|
--set secret.rustfs.secret_key=test-secret-key \
|
|
--set mtls.enabled=true \
|
|
"$@" |
|
|
awk '
|
|
/^# Source: rustfs\/templates\/cert-manager-mtls\/04-server-cert.yaml$/ { in_cert = 1 }
|
|
in_cert && /^---$/ { exit }
|
|
in_cert { print }
|
|
'
|
|
}
|
|
|
|
recreate_output=$(render_standalone_deployment --set mode.standalone.strategy.type=Recreate)
|
|
grep -q "type: Recreate" <<<"$recreate_output"
|
|
if grep -q "rollingUpdate:" <<<"$recreate_output"; then
|
|
echo "Recreate strategy must not render rollingUpdate fields" >&2
|
|
exit 1
|
|
fi
|
|
|
|
rolling_output=$(render_standalone_deployment)
|
|
grep -q "type: RollingUpdate" <<<"$rolling_output"
|
|
grep -q "rollingUpdate:" <<<"$rolling_output"
|
|
grep -Eq '^[[:space:]]*replicas:[[:space:]]*1[[:space:]]*$' <<<"$rolling_output"
|
|
|
|
scaled_to_zero_output=$(render_standalone_deployment --set replicaCount=0)
|
|
grep -Eq '^[[:space:]]*replicas:[[:space:]]*0[[:space:]]*$' <<<"$scaled_to_zero_output"
|
|
|
|
scanner_config_output=$(render_chart \
|
|
--set config.rustfs.scanner.speed=slow \
|
|
--set config.rustfs.scanner.delay=30 \
|
|
--set config.rustfs.scanner.max_wait_secs=15 \
|
|
--set config.rustfs.scanner.cycle_secs=3600 \
|
|
--set config.rustfs.scanner.start_delay_secs=60 \
|
|
--set config.rustfs.scanner.cycle_max_duration_secs=1800 \
|
|
--set config.rustfs.scanner.cycle_max_objects=0 \
|
|
--set config.rustfs.scanner.cycle_max_directories=100000 \
|
|
--set config.rustfs.scanner.bitrot_cycle_secs=0 \
|
|
--set config.rustfs.scanner.idle_mode=false \
|
|
--set config.rustfs.scanner.cache_save_timeout_secs=30 \
|
|
--set config.rustfs.scanner.max_concurrent_set_scans=2 \
|
|
--set config.rustfs.scanner.max_concurrent_disk_scans=1 \
|
|
--set config.rustfs.scanner.yield_every_n_objects=128 \
|
|
--set config.rustfs.scanner.alert_excess_versions=100 \
|
|
--set config.rustfs.scanner.alert_excess_version_size=1099511627776 \
|
|
--set config.rustfs.scanner.alert_excess_folders=65538)
|
|
grep -q 'RUSTFS_SCANNER_SPEED: "slow"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_DELAY: "30"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_MAX_WAIT_SECS: "15"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_CYCLE: "3600"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_START_DELAY_SECS: "60"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS: "1800"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_CYCLE_MAX_OBJECTS: "0"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_CYCLE_MAX_DIRECTORIES: "100000"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_BITROT_CYCLE_SECS: "0"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_IDLE_MODE: "false"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS: "30"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_MAX_CONCURRENT_SET_SCANS: "2"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS: "1"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_YIELD_EVERY_N_OBJECTS: "128"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_ALERT_EXCESS_VERSIONS: "100"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_ALERT_EXCESS_VERSION_SIZE: "1099511627776"' <<<"$scanner_config_output"
|
|
grep -q 'RUSTFS_SCANNER_ALERT_EXCESS_FOLDERS: "65538"' <<<"$scanner_config_output"
|
|
|
|
# Fail-closed credential checks. Rendering must fail when no credentials,
|
|
# existingSecret, or allowInsecureDefaults override is supplied.
|
|
default_render_status=0
|
|
helm template rustfs "$CHART_DIR" \
|
|
--namespace rustfs \
|
|
--set mode.distributed.enabled=false \
|
|
--set mode.standalone.enabled=true \
|
|
>/dev/null 2>&1 || default_render_status=$?
|
|
if [[ $default_render_status -eq 0 ]]; then
|
|
echo "Default credentials must fail to render without an explicit override" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Rendering must also fail if someone re-supplies the well-known defaults.
|
|
default_creds_status=0
|
|
helm template rustfs "$CHART_DIR" \
|
|
--namespace rustfs \
|
|
--set mode.distributed.enabled=false \
|
|
--set mode.standalone.enabled=true \
|
|
--set secret.rustfs.access_key=rustfsadmin \
|
|
--set secret.rustfs.secret_key=rustfsadmin \
|
|
>/dev/null 2>&1 || default_creds_status=$?
|
|
if [[ $default_creds_status -eq 0 ]]; then
|
|
echo "Setting the well-known defaults must fail without allowInsecureDefaults" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# allowInsecureDefaults=true must succeed and emit the dev creds.
|
|
insecure_output=$(helm template rustfs "$CHART_DIR" \
|
|
--namespace rustfs \
|
|
--set mode.distributed.enabled=false \
|
|
--set mode.standalone.enabled=true \
|
|
--set secret.allowInsecureDefaults=true)
|
|
expected_b64=$(printf 'rustfsadmin' | base64)
|
|
if ! grep -q "RUSTFS_ACCESS_KEY: \"$expected_b64\"" <<<"$insecure_output"; then
|
|
echo "allowInsecureDefaults=true must emit the well-known dev access key" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# existingSecret must skip rendering the chart-managed Secret entirely.
|
|
existing_output=$(helm template rustfs "$CHART_DIR" \
|
|
--namespace rustfs \
|
|
--set mode.distributed.enabled=false \
|
|
--set mode.standalone.enabled=true \
|
|
--set secret.existingSecret=my-existing-secret)
|
|
if grep -q "RUSTFS_ACCESS_KEY:" <<<"$existing_output"; then
|
|
echo "existingSecret must suppress chart-managed Secret rendering" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Partial-default credentials (one key set to the well-known default) must
|
|
# fail rendering even when the other key is non-default.
|
|
partial_default_status=0
|
|
helm template rustfs "$CHART_DIR" \
|
|
--namespace rustfs \
|
|
--set mode.distributed.enabled=false \
|
|
--set mode.standalone.enabled=true \
|
|
--set secret.rustfs.access_key=rustfsadmin \
|
|
--set secret.rustfs.secret_key=some-other-secret \
|
|
>/dev/null 2>&1 || partial_default_status=$?
|
|
if [[ $partial_default_status -eq 0 ]]; then
|
|
echo "Partial-default credentials (access_key=rustfsadmin) must fail rendering" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Partial-empty credentials (only one of the two keys set) must fail rendering
|
|
# even when allowInsecureDefaults=true — never silently auto-fill a single
|
|
# missing key with the well-known default.
|
|
partial_empty_status=0
|
|
helm template rustfs "$CHART_DIR" \
|
|
--namespace rustfs \
|
|
--set mode.distributed.enabled=false \
|
|
--set mode.standalone.enabled=true \
|
|
--set secret.allowInsecureDefaults=true \
|
|
--set secret.rustfs.access_key=user-supplied-access-key \
|
|
>/dev/null 2>&1 || partial_empty_status=$?
|
|
if [[ $partial_empty_status -eq 0 ]]; then
|
|
echo "Partial-empty credentials (only access_key set) must fail rendering" >&2
|
|
exit 1
|
|
fi
|
|
|
|
command -v yq >/dev/null 2>&1 || { echo "yq is required for extra-volumes structural tests" >&2; exit 1; }
|
|
|
|
# Structural helpers: verify wiring at the right YAML paths, not just string presence.
|
|
assert_extra_volumes_wired() {
|
|
local output="$1" label="$2"
|
|
|
|
if ! yq eval '.spec.template.spec.volumes[].name' - <<<"$output" | grep -q "^ca-bundle$"; then
|
|
echo "ca-bundle not found in spec.template.spec.volumes of $label" >&2
|
|
exit 1
|
|
fi
|
|
|
|
local mount_path
|
|
mount_path=$(yq eval '.spec.template.spec.containers[0].volumeMounts[] | select(.name == "ca-bundle") | .mountPath' - <<<"$output")
|
|
if [[ "$mount_path" != "/etc/ssl/certs/ca.crt" ]]; then
|
|
echo "ca-bundle mountPath in containers[0] is '$mount_path', expected /etc/ssl/certs/ca.crt in $label" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if yq eval '.spec.template.spec.initContainers[].volumeMounts[].name' - <<<"$output" | grep -q "^ca-bundle$"; then
|
|
echo "ca-bundle must not appear in initContainers volumeMounts of $label" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
assert_extra_volumes_absent() {
|
|
local output="$1" label="$2"
|
|
if yq eval '.spec.template.spec.volumes[].name' - <<<"$output" | grep -q "^ca-bundle$"; then
|
|
echo "ca-bundle must not appear in spec.template.spec.volumes of $label with empty extraVolumes" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# extraVolumes/extraVolumeMounts: structural placement in standalone Deployment.
|
|
standalone_extra_output=$(render_standalone_deployment \
|
|
--set 'extraVolumes[0].name=ca-bundle' \
|
|
--set 'extraVolumes[0].configMap.name=ca-bundle' \
|
|
--set 'extraVolumeMounts[0].name=ca-bundle' \
|
|
--set 'extraVolumeMounts[0].mountPath=/etc/ssl/certs/ca.crt' \
|
|
--set 'extraVolumeMounts[0].subPath=ca.crt')
|
|
assert_extra_volumes_wired "$standalone_extra_output" "standalone Deployment"
|
|
|
|
# Empty extraVolumes must not inject ca-bundle into standalone Deployment volumes.
|
|
standalone_default_output=$(render_standalone_deployment)
|
|
assert_extra_volumes_absent "$standalone_default_output" "standalone Deployment"
|
|
|
|
# extraVolumes/extraVolumeMounts: structural placement in distributed StatefulSet.
|
|
distributed_extra_output=$(render_distributed_statefulset \
|
|
--set 'extraVolumes[0].name=ca-bundle' \
|
|
--set 'extraVolumes[0].configMap.name=ca-bundle' \
|
|
--set 'extraVolumeMounts[0].name=ca-bundle' \
|
|
--set 'extraVolumeMounts[0].mountPath=/etc/ssl/certs/ca.crt' \
|
|
--set 'extraVolumeMounts[0].subPath=ca.crt')
|
|
assert_extra_volumes_wired "$distributed_extra_output" "distributed StatefulSet"
|
|
|
|
# Empty extraVolumes must not inject ca-bundle into distributed StatefulSet volumes.
|
|
distributed_default_output=$(render_distributed_statefulset)
|
|
assert_extra_volumes_absent "$distributed_default_output" "distributed StatefulSet"
|
|
|
|
# clusterDomain (issue #3857): a custom Kubernetes cluster domain must flow into
|
|
# the RUSTFS_VOLUMES FQDN and mTLS server cert SANs, defaulting to cluster.local.
|
|
volumes_default=$(render_distributed_configmap | grep 'RUSTFS_VOLUMES:' || true)
|
|
if ! grep -q 'svc\.cluster\.local' <<<"$volumes_default"; then
|
|
echo "Default RUSTFS_VOLUMES must use svc.cluster.local" >&2
|
|
exit 1
|
|
fi
|
|
|
|
volumes_custom=$(render_distributed_configmap --set clusterDomain=cluster.internal | grep 'RUSTFS_VOLUMES:' || true)
|
|
if ! grep -q 'svc\.cluster\.internal' <<<"$volumes_custom"; then
|
|
echo "Custom clusterDomain must appear in the RUSTFS_VOLUMES FQDN" >&2
|
|
exit 1
|
|
fi
|
|
if grep -q 'cluster\.local' <<<"$volumes_custom"; then
|
|
echo "Custom clusterDomain must fully replace cluster.local in RUSTFS_VOLUMES" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# An explicit config.rustfs.volumes stays authoritative regardless of clusterDomain.
|
|
volumes_explicit=$(render_distributed_configmap \
|
|
--set config.rustfs.volumes=http://example.test/data \
|
|
--set clusterDomain=cluster.internal | grep 'RUSTFS_VOLUMES:' || true)
|
|
if ! grep -q 'RUSTFS_VOLUMES: "http://example.test/data"' <<<"$volumes_explicit"; then
|
|
echo "Explicit config.rustfs.volumes must remain authoritative regardless of clusterDomain" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# A dot-only clusterDomain must fall back to cluster.local instead of an empty domain.
|
|
volumes_dots=$(render_distributed_configmap --set clusterDomain=. | grep 'RUSTFS_VOLUMES:' || true)
|
|
if ! grep -q 'svc\.cluster\.local' <<<"$volumes_dots"; then
|
|
echo "Dot-only clusterDomain must fall back to cluster.local in RUSTFS_VOLUMES" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# mTLS server certificate SANs must honor clusterDomain too.
|
|
cert_default=$(render_server_cert)
|
|
if ! grep -q 'svc\.cluster\.local"' <<<"$cert_default"; then
|
|
echo "Default mTLS server cert SANs must use svc.cluster.local" >&2
|
|
exit 1
|
|
fi
|
|
|
|
cert_custom=$(render_server_cert --set clusterDomain=cluster.internal)
|
|
if ! grep -q 'svc\.cluster\.internal"' <<<"$cert_custom"; then
|
|
echo "Custom clusterDomain must appear in mTLS server cert SANs" >&2
|
|
exit 1
|
|
fi
|
|
if grep -q 'svc\.cluster\.local"' <<<"$cert_custom"; then
|
|
echo "Custom clusterDomain must fully replace cluster.local in mTLS server cert SANs" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Legacy topology compatibility: default replicaCount=4 (no drivesPerNode set)
|
|
# must render the old 4x4 PVC names (data-rustfs-0 .. data-rustfs-3).
|
|
legacy_four_by_four=$(render_distributed_statefulset)
|
|
for i in 0 1 2 3; do
|
|
if ! grep -q "name: data-rustfs-${i}" <<<"$legacy_four_by_four"; then
|
|
echo "Legacy 4x4 topology must contain PVC data-rustfs-${i}" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
if grep -q "name: data$" <<<"$legacy_four_by_four"; then
|
|
echo "Legacy 4x4 topology must NOT contain a single 'data' PVC" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Legacy topology compatibility: replicaCount=16 (no drivesPerNode set)
|
|
# must render a single 'data' PVC (old 16x1 behaviour).
|
|
legacy_sixteen_by_one=$(render_distributed_statefulset --set replicaCount=16)
|
|
if ! grep -q "name: data$" <<<"$legacy_sixteen_by_one"; then
|
|
echo "Legacy 16x1 topology must contain a single 'data' PVC" >&2
|
|
exit 1
|
|
fi
|
|
if grep -q "name: data-rustfs-" <<<"$legacy_sixteen_by_one"; then
|
|
echo "Legacy 16x1 topology must NOT contain data-rustfs-* PVCs" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Generic topology: explicit replicaCount=8 drivesPerNode=2 must render
|
|
# exactly two PVCs per pod.
|
|
generic_eight_by_two=$(render_distributed_statefulset --set replicaCount=8 --set drivesPerNode=2)
|
|
for i in 0 1; do
|
|
if ! grep -q "name: data-rustfs-${i}" <<<"$generic_eight_by_two"; then
|
|
echo "Generic 8x2 topology must contain PVC data-rustfs-${i}" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
if grep -q "name: data$" <<<"$generic_eight_by_two"; then
|
|
echo "Generic 8x2 topology must NOT contain a single 'data' PVC" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# volumeClaimTemplates must not contain empty annotations when pvcAnnotations are unset,
|
|
# because Kubernetes treats annotations: {} as a mutation of the immutable field.
|
|
no_ann_output=$(render_distributed_statefulset)
|
|
if grep -A1 'kind: PersistentVolumeClaim' <<<"$no_ann_output" | grep -q 'annotations:'; then
|
|
echo "Empty pvcAnnotations must not render an annotations key in volumeClaimTemplates" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Distributed mode with replicaCount < 2 must fail rendering.
|
|
low_replica_status=0
|
|
render_distributed_statefulset --set replicaCount=1 >/dev/null 2>&1 || low_replica_status=$?
|
|
if [[ $low_replica_status -eq 0 ]]; then
|
|
echo "Distributed mode with replicaCount=1 must fail rendering" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# service.externalIPs must render correctly when supplied.
|
|
external_ips_output=$(helm template rustfs "$CHART_DIR" \
|
|
--namespace rustfs \
|
|
--set secret.rustfs.access_key=test-access-key \
|
|
--set secret.rustfs.secret_key=test-secret-key \
|
|
--set 'service.externalIPs[0]=203.0.113.1')
|
|
if ! grep -A2 'externalIPs:' <<<"$external_ips_output" | grep -q '203.0.113.1'; then
|
|
echo "service.externalIPs must contain 203.0.113.1" >&2
|
|
exit 1
|
|
fi
|