mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 23:50:43 +02:00
Bumps [helm/kind-action](https://github.com/helm/kind-action) from 1.14.0 to 1.15.0. - [Release notes](https://github.com/helm/kind-action/releases) - [Commits](https://github.com/helm/kind-action/compare/v1.14.0...v1.15.0) --- updated-dependencies: - dependency-name: helm/kind-action dependency-version: 1.15.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1745 lines
90 KiB
YAML
1745 lines
90 KiB
YAML
name: "helm: lint and test charts"
|
|
|
|
on:
|
|
push:
|
|
branches: [ master ]
|
|
paths: ['k8s/**', '.github/workflows/helm_ci.yml']
|
|
pull_request:
|
|
branches: [ master ]
|
|
paths: ['k8s/**', '.github/workflows/helm_ci.yml']
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
lint-test:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Set up Helm
|
|
uses: azure/setup-helm@v5
|
|
with:
|
|
version: v3.18.4
|
|
|
|
- uses: actions/setup-python@v7
|
|
with:
|
|
python-version: '3.10'
|
|
check-latest: true
|
|
|
|
- name: Set up chart-testing
|
|
uses: helm/chart-testing-action@v2.8.0
|
|
|
|
- name: Run chart-testing (list-changed)
|
|
id: list-changed
|
|
run: |
|
|
changed=$(ct list-changed --target-branch ${{ github.event.repository.default_branch }} --chart-dirs k8s/charts)
|
|
if [[ -n "$changed" ]]; then
|
|
echo "::set-output name=changed::true"
|
|
fi
|
|
|
|
- name: Run chart-testing (lint)
|
|
run: ct lint --target-branch ${{ github.event.repository.default_branch }} --all --validate-maintainers=false --chart-dirs k8s/charts
|
|
|
|
- name: Verify template rendering
|
|
run: |
|
|
set -e
|
|
CHART_DIR="k8s/charts/seaweedfs"
|
|
|
|
echo "=== Testing default configuration ==="
|
|
helm template test $CHART_DIR > /tmp/default.yaml
|
|
echo "Default configuration renders successfully"
|
|
|
|
echo "=== Testing with S3 enabled ==="
|
|
helm template test $CHART_DIR --set s3.enabled=true > /tmp/s3.yaml
|
|
grep -q "kind: Deployment" /tmp/s3.yaml && grep -q "seaweedfs-s3" /tmp/s3.yaml
|
|
echo "S3 deployment renders correctly"
|
|
|
|
echo "=== Testing S3 credentials from an existing secret ==="
|
|
credential_args=(
|
|
--set s3.credentials.admin.existingSecret=minio-root
|
|
--set s3.credentials.admin.accessKeyKey=root-user
|
|
--set s3.credentials.admin.secretKeyKey=root-password
|
|
--set s3.credentials.read.existingSecret=minio-root
|
|
)
|
|
for workload in \
|
|
"s3.enabled=true,s3.enableAuth=true" \
|
|
"filer.s3.enabled=true,filer.s3.enableAuth=true" \
|
|
"allInOne.enabled=true,allInOne.s3.enabled=true,allInOne.s3.enableAuth=true"
|
|
do
|
|
helm template test $CHART_DIR --set "$workload" "${credential_args[@]}" > /tmp/s3-existing-credentials.yaml
|
|
# The identities file names the variables, and each is bound to the key it was pointed at.
|
|
grep -q 'accessKey":"${SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID}' /tmp/s3-existing-credentials.yaml
|
|
grep -q 'secretKey":"${SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY}' /tmp/s3-existing-credentials.yaml
|
|
grep -q 'accessKey":"${SEAWEEDFS_S3_READ_ACCESS_KEY_ID}' /tmp/s3-existing-credentials.yaml
|
|
for pair in \
|
|
"SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID root-user" \
|
|
"SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY root-password" \
|
|
"SEAWEEDFS_S3_READ_ACCESS_KEY_ID read_access_key_id" \
|
|
"SEAWEEDFS_S3_READ_SECRET_ACCESS_KEY read_secret_access_key"
|
|
do
|
|
set -- $pair
|
|
grep -A 4 -- "- name: $1\$" /tmp/s3-existing-credentials.yaml | grep -q "name: \"minio-root\""
|
|
grep -A 4 -- "- name: $1\$" /tmp/s3-existing-credentials.yaml | grep -q "key: \"$2\""
|
|
done
|
|
# The keys stay in the user's secret rather than being copied into the chart's.
|
|
! grep -qE "^ (admin|read)_(access_key_id|secret_access_key):" /tmp/s3-existing-credentials.yaml
|
|
echo "S3 credentials reference the existing secret for $workload"
|
|
done
|
|
|
|
echo "=== Testing ingress labels ==="
|
|
helm template test $CHART_DIR --set s3.enabled=true,s3.ingress.enabled=true \
|
|
--set s3.ingress.labels.external-dns=s3 > /tmp/s3-ingress-labels.yaml
|
|
grep -A 12 "^kind: Ingress" /tmp/s3-ingress-labels.yaml | grep -q "^ external-dns: s3"
|
|
echo "S3 ingress renders custom labels"
|
|
|
|
echo "=== Testing with all-in-one mode ==="
|
|
helm template test $CHART_DIR --set allInOne.enabled=true > /tmp/allinone.yaml
|
|
grep -q "seaweedfs-all-in-one" /tmp/allinone.yaml
|
|
echo "All-in-one deployment renders correctly"
|
|
|
|
echo "=== Testing the all-in-one s3 secret is created by every flag that mounts it ==="
|
|
for auth in allInOne.s3.enableAuth s3.enableAuth filer.s3.enableAuth; do
|
|
helm template test $CHART_DIR \
|
|
--set allInOne.enabled=true --set allInOne.s3.enabled=true --set "$auth=true" \
|
|
> /tmp/allinone-s3-auth.yaml
|
|
grep -q "secretName: test-seaweedfs-s3-secret" /tmp/allinone-s3-auth.yaml
|
|
grep -q "name: test-seaweedfs-s3-secret" /tmp/allinone-s3-auth.yaml
|
|
echo "All-in-one s3 secret renders for $auth"
|
|
done
|
|
|
|
echo "=== Testing with security enabled ==="
|
|
helm template test $CHART_DIR --set global.seaweedfs.enableSecurity=true > /tmp/security.yaml
|
|
grep -q "security-config" /tmp/security.yaml
|
|
echo "Security configuration renders correctly"
|
|
|
|
echo ""
|
|
echo "=== Testing JWT expiration overrides ==="
|
|
helm template test $CHART_DIR \
|
|
--set global.seaweedfs.securityConfig.jwtSigning.expiresAfterSeconds.volumeWrite=11 \
|
|
> /tmp/jwt-volume-write-expiration.yaml
|
|
grep -q "security-config" /tmp/jwt-volume-write-expiration.yaml
|
|
grep -q "expires_after_seconds = 11" /tmp/jwt-volume-write-expiration.yaml
|
|
|
|
helm template test $CHART_DIR \
|
|
--set global.seaweedfs.securityConfig.jwtSigning.volumeRead=true \
|
|
--set global.seaweedfs.securityConfig.jwtSigning.filerWrite=true \
|
|
--set global.seaweedfs.securityConfig.jwtSigning.filerRead=true \
|
|
--set global.seaweedfs.securityConfig.jwtSigning.expiresAfterSeconds.volumeWrite=11 \
|
|
--set global.seaweedfs.securityConfig.jwtSigning.expiresAfterSeconds.volumeRead=22 \
|
|
--set global.seaweedfs.securityConfig.jwtSigning.expiresAfterSeconds.filerWrite=33 \
|
|
--set global.seaweedfs.securityConfig.jwtSigning.expiresAfterSeconds.filerRead=44 \
|
|
> /tmp/jwt-expiration.yaml
|
|
|
|
assert_jwt_expiration() {
|
|
local section="$1"
|
|
local seconds="$2"
|
|
awk -v section="[$section]" -v seconds="$seconds" '
|
|
/^[[:space:]]*\[.*\][[:space:]]*$/ {
|
|
in_section = index($0, section) > 0
|
|
}
|
|
in_section && $0 ~ "^[[:space:]]*expires_after_seconds = " seconds "$" {
|
|
found = 1
|
|
}
|
|
END { exit !found }
|
|
' /tmp/jwt-expiration.yaml
|
|
}
|
|
|
|
assert_jwt_expiration jwt.signing 11
|
|
assert_jwt_expiration jwt.signing.read 22
|
|
assert_jwt_expiration jwt.filer_signing 33
|
|
assert_jwt_expiration jwt.filer_signing.read 44
|
|
|
|
helm template test $CHART_DIR \
|
|
--set global.seaweedfs.enableSecurity=true \
|
|
--set global.seaweedfs.securityConfig.jwtSigning.volumeRead=true \
|
|
--set global.seaweedfs.securityConfig.jwtSigning.filerWrite=true \
|
|
--set global.seaweedfs.securityConfig.jwtSigning.filerRead=true \
|
|
> /tmp/jwt-default-expiration.yaml
|
|
if grep -q "expires_after_seconds =" /tmp/jwt-default-expiration.yaml; then
|
|
echo "FAIL: zero JWT expiration values should preserve runtime defaults"
|
|
exit 1
|
|
fi
|
|
echo "JWT expiration overrides render correctly"
|
|
|
|
echo ""
|
|
echo "=== Testing IAM gRPC opt-in path ==="
|
|
# Regression test: the filer registers the IAM gRPC service the
|
|
# Admin UI Users tab calls only when jwt.filer_signing.key is in
|
|
# security.toml. Operators must be able to enable that without
|
|
# the cert-manager mTLS bundle.
|
|
# Install PyYAML explicitly: this block runs before the later
|
|
# security+S3 block that does the same install, and we don't
|
|
# want to rely on the runner image shipping it.
|
|
pip install pyyaml -q
|
|
python3 - "$CHART_DIR" <<'PYEOF'
|
|
import subprocess, sys, yaml
|
|
chart = sys.argv[1]
|
|
|
|
def render(values):
|
|
args = ["helm", "template", "test", chart]
|
|
for k, v in values.items():
|
|
args += ["--set", f"{k}={v}"]
|
|
return subprocess.check_output(args, text=True)
|
|
|
|
def docs(manifest):
|
|
return [d for d in yaml.safe_load_all(manifest) if d]
|
|
|
|
def configmap(manifest, name):
|
|
for d in docs(manifest):
|
|
if d.get("kind") == "ConfigMap" and d["metadata"]["name"] == name:
|
|
return d
|
|
return None
|
|
|
|
def workload_mounts(manifest, name):
|
|
for d in docs(manifest):
|
|
if d.get("kind") not in ("Deployment", "StatefulSet"):
|
|
continue
|
|
if d["metadata"]["name"] != name:
|
|
continue
|
|
pod = d["spec"]["template"]["spec"]
|
|
vols = {v["name"] for v in pod.get("volumes", [])}
|
|
mounts = set()
|
|
for c in pod.get("containers", []):
|
|
for vm in c.get("volumeMounts", []):
|
|
mounts.add(vm["name"])
|
|
return vols, mounts
|
|
return None, None
|
|
|
|
failed = []
|
|
|
|
# Case 1: defaults. The chart historically rendered nothing
|
|
# security-related; preserve that so this PR is non-breaking on
|
|
# existing installs.
|
|
out = render({})
|
|
if configmap(out, "test-seaweedfs-security-config") is not None:
|
|
failed.append("defaults: security ConfigMap should not render")
|
|
else:
|
|
print("defaults: no security-config ConfigMap (unchanged)")
|
|
|
|
# Case 2: filerWrite=true alone is the documented opt-in for
|
|
# the Admin UI Users tab. Configmap must render with
|
|
# [jwt.filer_signing] and NO [grpc.*] sections (cert paths
|
|
# only exist with mTLS).
|
|
out = render({
|
|
"global.seaweedfs.securityConfig.jwtSigning.filerWrite": "true",
|
|
"admin.enabled": "true",
|
|
})
|
|
cm = configmap(out, "test-seaweedfs-security-config")
|
|
if cm is None:
|
|
failed.append("filerWrite=true: security ConfigMap missing")
|
|
else:
|
|
toml = cm["data"]["security.toml"]
|
|
if "[jwt.filer_signing]" not in toml:
|
|
failed.append("filerWrite=true: security.toml missing [jwt.filer_signing]")
|
|
if "[grpc" in toml:
|
|
failed.append("filerWrite=true: security.toml unexpectedly has [grpc.*] (would need cert mounts)")
|
|
if "[jwt.filer_signing]" in toml and "[grpc" not in toml:
|
|
print("filerWrite=true: security.toml has [jwt.filer_signing], no [grpc.*]")
|
|
|
|
# Case 3: filer + admin pods must MOUNT the security ConfigMap
|
|
# under filerWrite=true so the JWT key reaches both processes.
|
|
# Cert volumes must NOT be present (no mTLS).
|
|
for wl in ("test-seaweedfs-filer", "test-seaweedfs-admin"):
|
|
vols, mounts = workload_mounts(out, wl)
|
|
if vols is None:
|
|
failed.append(f"filerWrite=true: workload {wl} not found")
|
|
continue
|
|
if "security-config" not in vols or "security-config" not in mounts:
|
|
failed.append(f"filerWrite=true: {wl} does not mount security-config (IAM gRPC would still fail)")
|
|
else:
|
|
print(f"filerWrite=true: {wl} mounts security-config")
|
|
cert_vols = {v for v in vols if v.endswith("-cert")}
|
|
if cert_vols:
|
|
failed.append(f"filerWrite=true: {wl} unexpectedly has cert volumes {sorted(cert_vols)}")
|
|
|
|
# Case 4: enableSecurity=true must still render the full toml
|
|
# with both [jwt.signing] and [grpc.*]. Guards against the
|
|
# decoupling change accidentally regressing the mTLS path.
|
|
out = render({"global.seaweedfs.enableSecurity": "true"})
|
|
cm = configmap(out, "test-seaweedfs-security-config")
|
|
if cm is None:
|
|
failed.append("enableSecurity=true: security ConfigMap missing")
|
|
else:
|
|
toml = cm["data"]["security.toml"]
|
|
missing = [s for s in ("[jwt.signing]", "[grpc.master]") if s not in toml]
|
|
if missing:
|
|
failed.append(f"enableSecurity=true: security.toml missing {missing}")
|
|
else:
|
|
print("enableSecurity=true: security.toml has [jwt.signing] + [grpc.*] preserved")
|
|
|
|
# Case 5: helper must tolerate explicit nulls (gemini-code-assist
|
|
# PR review). securityConfig=null was the parens-pattern crash
|
|
# the helper review caught.
|
|
for null_path in ("global.seaweedfs.securityConfig",
|
|
"global.seaweedfs.securityConfig.jwtSigning"):
|
|
try:
|
|
out = render({null_path: "null"})
|
|
except subprocess.CalledProcessError as e:
|
|
failed.append(f"{null_path}=null: render failed: {e.output[:200] if e.output else e}")
|
|
continue
|
|
if configmap(out, "test-seaweedfs-security-config") is not None:
|
|
failed.append(f"{null_path}=null: should not render configmap")
|
|
else:
|
|
print(f"{null_path}=null: render tolerates explicit null")
|
|
|
|
if failed:
|
|
print("\nFAIL:", file=sys.stderr)
|
|
for f in failed:
|
|
print(f" - {f}", file=sys.stderr)
|
|
sys.exit(1)
|
|
PYEOF
|
|
echo "IAM gRPC decoupling tests passed"
|
|
|
|
echo ""
|
|
echo "=== Testing bucket hook credentials ==="
|
|
# The bucket hook pipes s3.configure into `weed shell`, which needs
|
|
# the filer JWT signing key once jwtSigning.filerWrite is on: the
|
|
# filer rejects unsigned IAM gRPC calls, and `weed shell` still
|
|
# exits 0, so the Job goes green while anonymousRead is dropped.
|
|
# The key reaches the hook either through security.toml or through
|
|
# an environment override in extraEnvironmentVars.
|
|
python3 - "$CHART_DIR" <<'PYEOF'
|
|
import re, subprocess, sys, yaml
|
|
|
|
chart = sys.argv[1]
|
|
|
|
def render(values):
|
|
args = ["helm", "template", "test", chart]
|
|
for k, v in values.items():
|
|
args += ["--set", f"{k}={v}"]
|
|
return subprocess.check_output(args, text=True)
|
|
|
|
def hook_pod(manifest):
|
|
for d in yaml.safe_load_all(manifest):
|
|
if d and d.get("kind") == "Job" and d["metadata"]["name"].endswith("-bucket-hook"):
|
|
return d["spec"]["template"]["spec"]
|
|
return None
|
|
|
|
def cluster_endpoints(manifest):
|
|
"""Where the chart's own Services put master and filer.
|
|
|
|
The hook's WEED_CLUSTER_* values have to match these: a defined
|
|
name pointing anywhere else fails the readiness wait exactly as
|
|
an undefined one does.
|
|
"""
|
|
wanted = {"-master": "swfs-master", "-filer-client": "swfs-filer"}
|
|
found = {}
|
|
for d in yaml.safe_load_all(manifest):
|
|
if not d or d.get("kind") != "Service":
|
|
continue
|
|
for suffix, portName in wanted.items():
|
|
if not d["metadata"]["name"].endswith(suffix):
|
|
continue
|
|
port = next((p["port"] for p in d["spec"]["ports"] if p.get("name") == portName), None)
|
|
if port:
|
|
found[suffix] = f"{d['metadata']['name']}.{d['metadata']['namespace']}:{port}"
|
|
return found.get("-master"), found.get("-filer-client")
|
|
|
|
buckets = {
|
|
"s3.enabled": "true",
|
|
"s3.createBuckets[0].name": "data",
|
|
"s3.createBuckets[0].anonymousRead": "true",
|
|
}
|
|
jwt = dict(buckets, **{"global.seaweedfs.securityConfig.jwtSigning.filerWrite": "true"})
|
|
|
|
failed = []
|
|
|
|
# The script in the hook addresses master and filer through
|
|
# WEED_CLUSTER_*, now rendered next to extraEnvironmentVars.
|
|
# Duplicate names would be a rendering bug.
|
|
out = render(buckets)
|
|
pod = hook_pod(out)
|
|
if pod is None:
|
|
failed.append("createBuckets: bucket hook Job missing")
|
|
else:
|
|
env = pod["containers"][0]["env"]
|
|
names = [e["name"] for e in env]
|
|
dupes = sorted({n for n in names if names.count(n) > 1})
|
|
if dupes:
|
|
failed.append(f"createBuckets: duplicate env entries {dupes}")
|
|
values = {e["name"]: e.get("value") for e in env}
|
|
if not values.get("WEED_CLUSTER_DEFAULT"):
|
|
failed.append("createBuckets: hook env WEED_CLUSTER_DEFAULT is empty")
|
|
rendered = (values.get("WEED_CLUSTER_SW_MASTER"), values.get("WEED_CLUSTER_SW_FILER"))
|
|
expected = cluster_endpoints(out)
|
|
if not all(expected):
|
|
failed.append(f"createBuckets: no master/filer-client Service to compare against, found {expected}")
|
|
elif rendered != expected:
|
|
failed.append(f"createBuckets: hook env holds {rendered}, the services are at {expected}")
|
|
elif not dupes:
|
|
print("createBuckets: hook env resolves the cluster addresses once")
|
|
if any(v["name"] == "security-config" for v in pod.get("volumes", [])):
|
|
failed.append("createBuckets: hook mounts security-config without JWT signing")
|
|
|
|
# filerWrite=true: the hook needs the same security.toml the filer
|
|
# gets, otherwise s3.configure fails with "missing authorization
|
|
# metadata".
|
|
pod = hook_pod(render(jwt))
|
|
if pod is None:
|
|
failed.append("filerWrite=true: bucket hook Job missing")
|
|
else:
|
|
vol = next((v for v in pod.get("volumes", []) if v["name"] == "security-config"), None)
|
|
mount = next((m for m in pod["containers"][0].get("volumeMounts", []) if m["name"] == "security-config"), None)
|
|
# The file has to land on one of weed's config search paths, so
|
|
# the target matters as much as the volume itself - and a volume
|
|
# of that name backed by anything but the chart's ConfigMap
|
|
# leaves `weed shell` without the signing key just the same.
|
|
target = ("/etc/seaweedfs/security.toml", "security.toml")
|
|
configmap = "test-seaweedfs-security-config"
|
|
if vol is None or mount is None:
|
|
failed.append("filerWrite=true: bucket hook does not mount security-config (s3.configure would fail)")
|
|
elif (mount.get("mountPath"), mount.get("subPath")) != target:
|
|
failed.append(f"filerWrite=true: bucket hook mounts security-config as {mount}")
|
|
elif vol.get("configMap", {}).get("name") != configmap:
|
|
failed.append(f"filerWrite=true: security-config is not backed by the {configmap} ConfigMap: {vol}")
|
|
else:
|
|
print("filerWrite=true: bucket hook mounts security-config at /etc/seaweedfs/security.toml")
|
|
|
|
# The readiness waits dereference the cluster env names, so renaming
|
|
# the cluster alias has to rename them too: an undefined name leaves
|
|
# the hook polling "http://" for five minutes and then failing the
|
|
# release. The endpoints stay chart-computed, so an address kept in
|
|
# extraEnvironmentVars is replaced, not rendered twice.
|
|
out = render(dict(buckets, **{
|
|
"global.seaweedfs.extraEnvironmentVars.WEED_CLUSTER_DEFAULT": "prod",
|
|
"global.seaweedfs.extraEnvironmentVars.WEED_CLUSTER_PROD_MASTER": "stale:9333",
|
|
"global.seaweedfs.extraEnvironmentVars.WEED_CLUSTER_SW_MASTER": "null",
|
|
"global.seaweedfs.extraEnvironmentVars.WEED_CLUSTER_SW_FILER": "null",
|
|
}))
|
|
pod = hook_pod(out)
|
|
if pod is None:
|
|
failed.append("cluster alias: bucket hook Job missing")
|
|
else:
|
|
container = pod["containers"][0]
|
|
env = {e["name"]: e.get("value") for e in container["env"]}
|
|
waited = re.findall(r'wait_for_service "http://\$(\w+)', container["command"][2])
|
|
undefined = sorted(n for n in set(waited) if not env.get(n))
|
|
renamed = (env.get("WEED_CLUSTER_PROD_MASTER"), env.get("WEED_CLUSTER_PROD_FILER"))
|
|
expected = cluster_endpoints(out)
|
|
if not waited:
|
|
failed.append("cluster alias: hook script has no readiness waits")
|
|
elif undefined:
|
|
failed.append(f"cluster alias: hook waits on undefined env {undefined}")
|
|
elif not all(expected):
|
|
failed.append(f"cluster alias: no master/filer-client Service to compare against, found {expected}")
|
|
elif renamed != expected:
|
|
# Both addresses under the renamed alias, not just the one
|
|
# the values tried to keep: the stale one would fail the
|
|
# release, and a missing filer address just as much.
|
|
failed.append(f"cluster alias: hook waits on {renamed}, the services are at {expected}")
|
|
else:
|
|
print("cluster alias: hook waits on the renamed names and the chart's addresses")
|
|
|
|
# Keys kept in a Secret are referenced, not inlined, so the hook
|
|
# must render valueFrom for non-string extraEnvironmentVars.
|
|
out = render(dict(jwt, **{
|
|
"global.seaweedfs.extraEnvironmentVars.WEED_JWT_FILER_SIGNING_KEY.secretKeyRef.name": "signing-keys",
|
|
"global.seaweedfs.extraEnvironmentVars.WEED_JWT_FILER_SIGNING_KEY.secretKeyRef.key": "filerWrite",
|
|
}))
|
|
pod = hook_pod(out)
|
|
if pod is None:
|
|
failed.append("secretKeyRef: bucket hook Job missing")
|
|
else:
|
|
entry = next((e for e in pod["containers"][0]["env"] if e["name"] == "WEED_JWT_FILER_SIGNING_KEY"), None)
|
|
if entry is None:
|
|
failed.append("secretKeyRef: hook env WEED_JWT_FILER_SIGNING_KEY missing")
|
|
elif entry.get("valueFrom", {}).get("secretKeyRef") != {"name": "signing-keys", "key": "filerWrite"}:
|
|
failed.append(f"secretKeyRef: hook env rendered as {entry}")
|
|
else:
|
|
print("secretKeyRef: hook env keeps the secret reference")
|
|
|
|
if failed:
|
|
print("\nFAIL:", file=sys.stderr)
|
|
for f in failed:
|
|
print(f" - {f}", file=sys.stderr)
|
|
sys.exit(1)
|
|
PYEOF
|
|
echo "Bucket hook credential tests passed"
|
|
|
|
echo "=== Testing with monitoring enabled ==="
|
|
helm template test $CHART_DIR \
|
|
--set global.seaweedfs.monitoring.enabled=true \
|
|
--set global.seaweedfs.monitoring.gatewayHost=prometheus \
|
|
--set global.seaweedfs.monitoring.gatewayPort=9091 > /tmp/monitoring.yaml
|
|
echo "Monitoring configuration renders correctly"
|
|
|
|
echo "=== Testing with PVC storage ==="
|
|
helm template test $CHART_DIR \
|
|
--set master.data.type=persistentVolumeClaim \
|
|
--set master.data.size=10Gi \
|
|
--set master.data.storageClass=standard > /tmp/pvc.yaml
|
|
grep -q "PersistentVolumeClaim" /tmp/pvc.yaml
|
|
echo "PVC configuration renders correctly"
|
|
|
|
echo "=== Testing with custom replicas ==="
|
|
helm template test $CHART_DIR \
|
|
--set master.replicas=3 \
|
|
--set filer.replicas=2 \
|
|
--set volume.replicas=3 > /tmp/replicas.yaml
|
|
echo "Custom replicas configuration renders correctly"
|
|
|
|
echo "=== Testing filer with S3 gateway ==="
|
|
helm template test $CHART_DIR \
|
|
--set filer.s3.enabled=true \
|
|
--set filer.s3.enableAuth=true > /tmp/filer-s3.yaml
|
|
echo "Filer S3 gateway renders correctly"
|
|
|
|
echo "=== Testing the mysql filer store gates its secret and env ==="
|
|
helm template test $CHART_DIR \
|
|
--set-string filer.extraEnvironmentVars.WEED_MONGODB_ENABLED=true \
|
|
--set-string filer.extraEnvironmentVars.WEED_LEVELDB2_ENABLED=false > /tmp/filer-mongodb.yaml
|
|
! grep -q "db-secret" /tmp/filer-mongodb.yaml
|
|
! grep -q "WEED_MYSQL" /tmp/filer-mongodb.yaml
|
|
grep -q "name: WEED_MONGODB_ENABLED" /tmp/filer-mongodb.yaml
|
|
helm template test $CHART_DIR \
|
|
--set-string filer.extraEnvironmentVars.WEED_MYSQL_ENABLED=true > /tmp/filer-mysql.yaml
|
|
grep -q "name: test-seaweedfs-db-secret" /tmp/filer-mysql.yaml
|
|
grep -q "name: WEED_MYSQL_USERNAME" /tmp/filer-mysql.yaml
|
|
grep -q "name: WEED_MYSQL_PASSWORD" /tmp/filer-mysql.yaml
|
|
grep -q "name: WEED_MYSQL_HOSTNAME" /tmp/filer-mysql.yaml
|
|
# Secret-backed keys follow the same rule as the plain ones.
|
|
helm template test $CHART_DIR \
|
|
--set filer.secretExtraEnvironmentVars.WEED_MYSQL_PASSWORD.secretKeyRef.name=db \
|
|
--set filer.secretExtraEnvironmentVars.WEED_MYSQL_PASSWORD.secretKeyRef.key=password > /tmp/filer-mysql-off-secret.yaml
|
|
! grep -q "WEED_MYSQL" /tmp/filer-mysql-off-secret.yaml
|
|
helm template test $CHART_DIR \
|
|
--set-string filer.extraEnvironmentVars.WEED_MYSQL_ENABLED=true \
|
|
--set filer.secretExtraEnvironmentVars.WEED_MYSQL_PASSWORD.secretKeyRef.name=db \
|
|
--set filer.secretExtraEnvironmentVars.WEED_MYSQL_PASSWORD.secretKeyRef.key=password > /tmp/filer-mysql-on-secret.yaml
|
|
grep -A 4 -- "- name: WEED_MYSQL_PASSWORD$" /tmp/filer-mysql-on-secret.yaml | grep -q "name: db"
|
|
# A flag the chart cannot read counts as selected, not as off.
|
|
helm template test $CHART_DIR \
|
|
--set filer.secretExtraEnvironmentVars.WEED_MYSQL_ENABLED.secretKeyRef.name=store \
|
|
--set filer.secretExtraEnvironmentVars.WEED_MYSQL_ENABLED.secretKeyRef.key=enabled > /tmp/filer-mysql-secret.yaml
|
|
grep -q "name: test-seaweedfs-db-secret" /tmp/filer-mysql-secret.yaml
|
|
grep -q "name: WEED_MYSQL_HOSTNAME" /tmp/filer-mysql-secret.yaml
|
|
helm template test $CHART_DIR \
|
|
--set-string filer.extraEnvironmentVars.WEED_MYSQL2_HOSTNAME=other > /tmp/filer-mysql2.yaml
|
|
grep -q "name: WEED_MYSQL2_HOSTNAME" /tmp/filer-mysql2.yaml
|
|
! grep -q "WEED_MYSQL_" /tmp/filer-mysql2.yaml
|
|
echo "The mysql secret and env follow the selected filer store"
|
|
|
|
echo "=== Testing SFTP enabled ==="
|
|
helm template test $CHART_DIR --set sftp.enabled=true > /tmp/sftp.yaml
|
|
grep -q "seaweedfs-sftp" /tmp/sftp.yaml
|
|
echo "SFTP deployment renders correctly"
|
|
|
|
echo ""
|
|
echo "=== Testing SFTP host key generation ==="
|
|
# The chart must not ship host key material: keys are generated
|
|
# per install, land in the secret the deployments mount at
|
|
# sftp.hostKeysFolder, and must be PKCS#8 ed25519 private keys,
|
|
# the shape the server's host key loader parses; it fails to
|
|
# start otherwise.
|
|
pip install pyyaml -q
|
|
python3 - "$CHART_DIR" <<'PYEOF'
|
|
import base64, re, subprocess, sys, yaml
|
|
|
|
chart = sys.argv[1]
|
|
|
|
def render(values):
|
|
args = ["helm", "template", "test", chart]
|
|
for k, v in values.items():
|
|
args += ["--set", f"{k}={v}"]
|
|
return subprocess.check_output(args, text=True)
|
|
|
|
def docs(manifest):
|
|
return [d for d in yaml.safe_load_all(manifest) if d]
|
|
|
|
def secret(manifest, name):
|
|
for d in docs(manifest):
|
|
if d.get("kind") == "Secret" and d["metadata"]["name"] == name:
|
|
return d
|
|
return None
|
|
|
|
def pod_spec(manifest, name):
|
|
for d in docs(manifest):
|
|
if d.get("kind") in ("Deployment", "StatefulSet") and d["metadata"]["name"] == name:
|
|
return d["spec"]["template"]["spec"]
|
|
return None
|
|
|
|
def script_of(spec):
|
|
for c in spec["containers"]:
|
|
cmd = c.get("command", [])
|
|
if len(cmd) >= 3 and cmd[0] == "/bin/sh":
|
|
return cmd[2]
|
|
raise AssertionError("no shell command block found")
|
|
|
|
def volume_secret(spec, volname):
|
|
for v in spec.get("volumes", []):
|
|
if v["name"] == volname:
|
|
return v["secret"]["secretName"]
|
|
return None
|
|
|
|
def mount_path(spec, volname):
|
|
for c in spec["containers"]:
|
|
for vm in c.get("volumeMounts", []):
|
|
if vm["name"] == volname:
|
|
return vm["mountPath"]
|
|
return None
|
|
|
|
def parse_ed25519(pem):
|
|
m = re.match(r"-----BEGIN PRIVATE KEY-----\n(.+?)-----END PRIVATE KEY-----", pem.strip(), re.S)
|
|
if not m:
|
|
raise AssertionError("not a PKCS#8 PEM private key")
|
|
der = base64.b64decode(m.group(1))
|
|
# RFC 8410: fixed PKCS#8 prefix, then the 32-byte seed
|
|
prefix = bytes.fromhex("302e020100300506032b657004220420")
|
|
if len(der) != 48 or not der.startswith(prefix):
|
|
raise AssertionError("not an ed25519 PKCS#8 key")
|
|
|
|
failed = []
|
|
# public-key material of the key the chart used to bundle
|
|
BUNDLED = "H4McwcDphteXVullu6q7ephEN1N60z"
|
|
|
|
out1 = render({"sftp.enabled": "true"})
|
|
out2 = render({"sftp.enabled": "true"})
|
|
|
|
for label, out in (("first", out1), ("second", out2)):
|
|
if BUNDLED in out:
|
|
failed.append(f"{label} render still contains the formerly bundled host key")
|
|
|
|
def folder_key(out):
|
|
s = secret(out, "test-seaweedfs-sftp-ssh-secret")
|
|
if s is None:
|
|
return None
|
|
return base64.b64decode(s["data"]["ssh_host_ed25519_key"]).decode()
|
|
|
|
k1, k2 = folder_key(out1), folder_key(out2)
|
|
if k1 is None or k2 is None:
|
|
failed.append("sftp-ssh-secret not rendered with sftp.enabled=true")
|
|
else:
|
|
try:
|
|
parse_ed25519(k1)
|
|
print("generated host key parses as ed25519")
|
|
except Exception as e:
|
|
failed.append(f"generated host key does not parse: {e}")
|
|
if k1 == k2:
|
|
failed.append("two renders produced the same host key (key is not generated per install)")
|
|
else:
|
|
print("host key differs between installs")
|
|
|
|
legacy1 = secret(out1, "test-seaweedfs-sftp-secret")["stringData"]["seaweedfs_sftp_ssh_private_key"]
|
|
legacy2 = secret(out2, "test-seaweedfs-sftp-secret")["stringData"]["seaweedfs_sftp_ssh_private_key"]
|
|
try:
|
|
parse_ed25519(legacy1)
|
|
except Exception as e:
|
|
failed.append(f"sftp-secret ssh key does not parse: {e}")
|
|
if legacy1 == legacy2:
|
|
failed.append("sftp-secret ssh key identical across renders")
|
|
else:
|
|
print("sftp-secret ssh key is generated per install")
|
|
|
|
spec = pod_spec(out1, "test-seaweedfs-sftp")
|
|
script = script_of(spec)
|
|
if "-sshPrivateKey" in script:
|
|
failed.append("sftp deployment passes -sshPrivateKey by default; the file only exists "
|
|
"when enableAuth mounts /etc/sw and a missing key file is fatal")
|
|
if "-hostKeysFolder=/etc/sw/ssh" not in script:
|
|
failed.append("sftp deployment missing -hostKeysFolder=/etc/sw/ssh")
|
|
if volume_secret(spec, "config-ssh") != "test-seaweedfs-sftp-ssh-secret":
|
|
failed.append("sftp config-ssh volume does not reference the generated secret")
|
|
else:
|
|
print("sftp deployment mounts the generated secret at the host keys folder")
|
|
|
|
out = render({"sftp.enabled": "true", "sftp.existingSshConfigSecret": "my-keys"})
|
|
if secret(out, "test-seaweedfs-sftp-ssh-secret") is not None:
|
|
failed.append("existingSshConfigSecret set but the default ssh secret still renders")
|
|
if volume_secret(pod_spec(out, "test-seaweedfs-sftp"), "config-ssh") != "my-keys":
|
|
failed.append("existingSshConfigSecret is not the config-ssh volume source")
|
|
else:
|
|
print("existingSshConfigSecret replaces the generated secret")
|
|
|
|
out = render({"allInOne.enabled": "true", "allInOne.sftp.enabled": "true"})
|
|
spec = pod_spec(out, "test-seaweedfs-all-in-one")
|
|
if secret(out, "test-seaweedfs-sftp-ssh-secret") is None:
|
|
failed.append("all-in-one: ssh secret not rendered")
|
|
if "-sftp.hostKeysFolder=/etc/sw/ssh" not in script_of(spec):
|
|
failed.append("all-in-one: missing -sftp.hostKeysFolder=/etc/sw/ssh")
|
|
if volume_secret(spec, "config-ssh") != "test-seaweedfs-sftp-ssh-secret":
|
|
failed.append("all-in-one: config-ssh volume does not reference the generated secret")
|
|
else:
|
|
print("all-in-one mounts the generated secret")
|
|
|
|
out = render({"sftp.enabled": "true", "sftp.hostKeysFolder": "/keys"})
|
|
spec = pod_spec(out, "test-seaweedfs-sftp")
|
|
if "-hostKeysFolder=/keys" not in script_of(spec) or mount_path(spec, "config-ssh") != "/keys":
|
|
failed.append("custom hostKeysFolder: flag and secret mount do not agree")
|
|
else:
|
|
print("custom hostKeysFolder keeps flag and mount aligned")
|
|
|
|
out = render({"allInOne.enabled": "true", "allInOne.sftp.enabled": "true",
|
|
"allInOne.sftp.hostKeysFolder": "/keys"})
|
|
spec = pod_spec(out, "test-seaweedfs-all-in-one")
|
|
if "-sftp.hostKeysFolder=/keys" not in script_of(spec) or mount_path(spec, "config-ssh") != "/keys":
|
|
failed.append("all-in-one custom hostKeysFolder: flag and secret mount do not agree")
|
|
else:
|
|
print("all-in-one custom hostKeysFolder keeps flag and mount aligned")
|
|
|
|
if failed:
|
|
print("\nFAIL:", file=sys.stderr)
|
|
for f in failed:
|
|
print(f" - {f}", file=sys.stderr)
|
|
sys.exit(1)
|
|
PYEOF
|
|
echo "SFTP host key generation tests passed"
|
|
|
|
echo "=== Testing ingress configurations ==="
|
|
helm template test $CHART_DIR \
|
|
--set master.ingress.enabled=true \
|
|
--set filer.ingress.enabled=true \
|
|
--set s3.enabled=true \
|
|
--set s3.ingress.enabled=true > /tmp/ingress.yaml
|
|
grep -q "kind: Ingress" /tmp/ingress.yaml
|
|
echo "Ingress configurations render correctly"
|
|
|
|
echo "=== Testing COSI driver ==="
|
|
helm template test $CHART_DIR --set cosi.enabled=true > /tmp/cosi.yaml
|
|
grep -q "seaweedfs-cosi" /tmp/cosi.yaml
|
|
echo "COSI driver renders correctly"
|
|
|
|
echo ""
|
|
echo "=== Testing long release name: service names match DNS references ==="
|
|
# Use a release name that, combined with chart name "seaweedfs", exceeds 63 chars.
|
|
# fullname = "my-very-long-release-name-that-will-cause-truncation-seaweedfs" (65 chars before trunc)
|
|
LONG_RELEASE="my-very-long-release-name-that-will-cause-truncation"
|
|
|
|
# --- Normal mode: master + filer-client services vs helper-produced addresses ---
|
|
helm template "$LONG_RELEASE" $CHART_DIR \
|
|
--set s3.enabled=true \
|
|
--set global.seaweedfs.createBuckets[0].name=test > /tmp/longname.yaml
|
|
|
|
# Extract Service names from metadata
|
|
MASTER_SVC=$(awk '/kind: Service/{found=1} found && /^ *name:/{print $2; found=0}' /tmp/longname.yaml \
|
|
| grep -- '-master$')
|
|
FILER_CLIENT_SVC=$(awk '/kind: Service/{found=1} found && /^ *name:/{print $2; found=0}' /tmp/longname.yaml \
|
|
| grep -- '-filer-client$')
|
|
|
|
# Extract the hostname from WEED_CLUSTER_SW_MASTER in post-install-bucket-hook
|
|
MASTER_ADDR=$(grep 'WEED_CLUSTER_SW_MASTER' -A1 /tmp/longname.yaml \
|
|
| grep 'value:' | head -1 | sed 's/.*value: *"\{0,1\}\([^":]*\).*/\1/')
|
|
FILER_ADDR=$(grep 'WEED_CLUSTER_SW_FILER' -A1 /tmp/longname.yaml \
|
|
| grep 'value:' | head -1 | sed 's/.*value: *"\{0,1\}\([^":]*\).*/\1/')
|
|
|
|
# Extract the hostname from S3 deployment -filer= argument
|
|
S3_FILER_HOST=$(grep '\-filer=' /tmp/longname.yaml \
|
|
| head -1 | sed 's/.*-filer=\([^:]*\).*/\1/')
|
|
|
|
# The address helpers produce "<svc>.<namespace>:<port>"; extract just the svc name
|
|
MASTER_ADDR_SVC=$(echo "$MASTER_ADDR" | cut -d. -f1)
|
|
FILER_ADDR_SVC=$(echo "$FILER_ADDR" | cut -d. -f1)
|
|
S3_FILER_SVC=$(echo "$S3_FILER_HOST" | cut -d. -f1)
|
|
|
|
echo " master Service.name: $MASTER_SVC"
|
|
echo " cluster.masterAddress svc: $MASTER_ADDR_SVC"
|
|
echo " filer-client Service.name: $FILER_CLIENT_SVC"
|
|
echo " cluster.filerAddress svc: $FILER_ADDR_SVC"
|
|
echo " S3 -filer= svc: $S3_FILER_SVC"
|
|
|
|
[ "$MASTER_SVC" = "$MASTER_ADDR_SVC" ] || { echo "FAIL: master service name mismatch"; exit 1; }
|
|
[ "$FILER_CLIENT_SVC" = "$FILER_ADDR_SVC" ] || { echo "FAIL: filer-client service name mismatch"; exit 1; }
|
|
[ "$FILER_CLIENT_SVC" = "$S3_FILER_SVC" ] || { echo "FAIL: S3 -filer= does not match filer-client service"; exit 1; }
|
|
echo "Normal mode: service names match DNS references with long release name"
|
|
|
|
# --- All-in-one mode: all-in-one service vs both helper addresses ---
|
|
helm template "$LONG_RELEASE" $CHART_DIR \
|
|
--set allInOne.enabled=true \
|
|
--set global.seaweedfs.createBuckets[0].name=test > /tmp/longname-aio.yaml
|
|
|
|
AIO_SVC=$(awk '/kind: Service/{found=1} found && /^ *name:/{print $2; found=0}' /tmp/longname-aio.yaml \
|
|
| grep -- '-all-in-one$')
|
|
AIO_MASTER_ADDR_SVC=$(grep 'WEED_CLUSTER_SW_MASTER' -A1 /tmp/longname-aio.yaml \
|
|
| grep 'value:' | head -1 | sed 's/.*value: *"\{0,1\}\([^":]*\).*/\1/' | cut -d. -f1)
|
|
AIO_FILER_ADDR_SVC=$(grep 'WEED_CLUSTER_SW_FILER' -A1 /tmp/longname-aio.yaml \
|
|
| grep 'value:' | head -1 | sed 's/.*value: *"\{0,1\}\([^":]*\).*/\1/' | cut -d. -f1)
|
|
|
|
echo " all-in-one Service.name: $AIO_SVC"
|
|
echo " cluster.masterAddress svc: $AIO_MASTER_ADDR_SVC"
|
|
echo " cluster.filerAddress svc: $AIO_FILER_ADDR_SVC"
|
|
|
|
[ "$AIO_SVC" = "$AIO_MASTER_ADDR_SVC" ] || { echo "FAIL: all-in-one master address mismatch"; exit 1; }
|
|
[ "$AIO_SVC" = "$AIO_FILER_ADDR_SVC" ] || { echo "FAIL: all-in-one filer address mismatch"; exit 1; }
|
|
echo "All-in-one mode: service names match DNS references with long release name"
|
|
|
|
echo ""
|
|
echo "=== Testing security+S3: no blank lines in shell command blocks ==="
|
|
# Render the three manifests that include seaweedfs.s3.tlsArgs:
|
|
# filer-statefulset, s3-deployment, all-in-one-deployment
|
|
helm template test $CHART_DIR \
|
|
--set global.seaweedfs.enableSecurity=true \
|
|
--set filer.s3.enabled=true \
|
|
--set s3.enabled=true > /tmp/security-s3.yaml
|
|
helm template test $CHART_DIR \
|
|
--set global.seaweedfs.enableSecurity=true \
|
|
--set allInOne.enabled=true \
|
|
--set allInOne.s3.enabled=true > /tmp/security-aio.yaml
|
|
|
|
pip install pyyaml -q
|
|
python3 - /tmp/security-s3.yaml /tmp/security-aio.yaml <<'PYEOF'
|
|
import yaml, sys
|
|
errors = []
|
|
for path in sys.argv[1:]:
|
|
with open(path) as f:
|
|
docs = list(yaml.safe_load_all(f))
|
|
for doc in docs:
|
|
if not doc or doc.get("kind") not in ("Deployment", "StatefulSet"):
|
|
continue
|
|
name = doc["metadata"]["name"]
|
|
for c in doc["spec"]["template"]["spec"].get("containers", []):
|
|
cmd = c.get("command", [])
|
|
if len(cmd) >= 3 and cmd[0] == "/bin/sh" and cmd[1] == "-ec":
|
|
script = cmd[2]
|
|
for i, line in enumerate(script.splitlines(), 1):
|
|
if line.strip() == "":
|
|
errors.append(f"{path}: {name}/{c['name']} has blank line at script line {i}")
|
|
if errors:
|
|
for e in errors:
|
|
print(f"FAIL: {e}", file=sys.stderr)
|
|
print("Rendered with: global.seaweedfs.enableSecurity=true, filer.s3.enabled=true, s3.enabled=true, allInOne.enabled=true", file=sys.stderr)
|
|
sys.exit(1)
|
|
print("No blank lines in security+S3 command blocks")
|
|
PYEOF
|
|
|
|
echo ""
|
|
echo "=== Testing security+S3: -cert.file/-key.file gated on httpsPort (issue #9202) ==="
|
|
# Regression test: when enableSecurity=true but *.httpsPort is 0 (the default),
|
|
# the chart must NOT emit -cert.file / -key.file to the S3 frontend. Passing
|
|
# them promotes weed s3's main -port to HTTPS (see weed/command/s3.go), which
|
|
# makes the HTTP readinessProbe spam "TLS handshake error ... client sent an
|
|
# HTTP request to an HTTPS server" into the pod log.
|
|
#
|
|
# When *.httpsPort > 0, both -port.https and cert/key args MUST be emitted
|
|
# together so the opt-in HTTPS listener actually has credentials.
|
|
python3 - "$CHART_DIR" <<'PYEOF'
|
|
import subprocess, sys, yaml
|
|
chart = sys.argv[1]
|
|
|
|
def render(values):
|
|
args = ["helm", "template", "test", chart]
|
|
for k, v in values.items():
|
|
args += ["--set", f"{k}={v}"]
|
|
return subprocess.check_output(args, text=True)
|
|
|
|
def script_of(manifest, kind_name):
|
|
for doc in yaml.safe_load_all(manifest):
|
|
if not doc or doc.get("kind") not in ("Deployment", "StatefulSet"):
|
|
continue
|
|
if doc["metadata"]["name"] != kind_name:
|
|
continue
|
|
for c in doc["spec"]["template"]["spec"]["containers"]:
|
|
cmd = c.get("command", [])
|
|
if len(cmd) >= 3 and cmd[0] == "/bin/sh" and cmd[1] == "-ec":
|
|
return cmd[2]
|
|
raise AssertionError(f"no container script for {kind_name}")
|
|
|
|
cases = [
|
|
# (values, workload-name, httpsPort-set?, arg-prefix)
|
|
({"global.seaweedfs.enableSecurity": "true",
|
|
"s3.enabled": "true"},
|
|
"test-seaweedfs-s3", False, ""),
|
|
({"global.seaweedfs.enableSecurity": "true",
|
|
"s3.enabled": "true",
|
|
"s3.httpsPort": "8443"},
|
|
"test-seaweedfs-s3", True, ""),
|
|
({"global.seaweedfs.enableSecurity": "true",
|
|
"filer.s3.enabled": "true"},
|
|
"test-seaweedfs-filer", False, "s3."),
|
|
({"global.seaweedfs.enableSecurity": "true",
|
|
"filer.s3.enabled": "true",
|
|
"filer.s3.httpsPort": "8444"},
|
|
"test-seaweedfs-filer", True, "s3."),
|
|
({"global.seaweedfs.enableSecurity": "true",
|
|
"allInOne.enabled": "true",
|
|
"allInOne.s3.enabled": "true"},
|
|
"test-seaweedfs-all-in-one", False, "s3."),
|
|
({"global.seaweedfs.enableSecurity": "true",
|
|
"allInOne.enabled": "true",
|
|
"allInOne.s3.enabled": "true",
|
|
"allInOne.s3.httpsPort": "8445"},
|
|
"test-seaweedfs-all-in-one", True, "s3."),
|
|
]
|
|
|
|
failed = False
|
|
for values, name, https_on, prefix in cases:
|
|
script = script_of(render(values), name)
|
|
cert_flag = f"-{prefix}cert.file="
|
|
key_flag = f"-{prefix}key.file="
|
|
https_flag = f"-{prefix}port.https="
|
|
has_cert = cert_flag in script
|
|
has_key = key_flag in script
|
|
has_https = https_flag in script
|
|
label = f"{name} (httpsPort {'set' if https_on else 'unset'})"
|
|
if https_on:
|
|
if not (has_cert and has_key and has_https):
|
|
print(f"FAIL: {label}: expected {cert_flag}, {key_flag}, {https_flag} all present "
|
|
f"(got cert={has_cert} key={has_key} https={has_https})", file=sys.stderr)
|
|
failed = True
|
|
else:
|
|
print(f"{label}: cert/key/https args emitted together")
|
|
else:
|
|
if has_cert or has_key or has_https:
|
|
print(f"FAIL: {label}: expected none of {cert_flag}/{key_flag}/{https_flag}; "
|
|
f"main S3 -port would silently become HTTPS and break HTTP probes "
|
|
f"(got cert={has_cert} key={has_key} https={has_https})", file=sys.stderr)
|
|
failed = True
|
|
else:
|
|
print(f"{label}: no TLS args emitted, main -port stays HTTP")
|
|
|
|
# bash -n: pin down that the rendered script parses. Guards against
|
|
# a future helper change that leaves a dangling `\` with nothing
|
|
# after it (every current caller already exits cleanly because
|
|
# bash treats trailing `\<newline><EOF>` as line-continuation to
|
|
# an empty line — but keep the contract explicit).
|
|
parse = subprocess.run(["bash", "-n"], input=script, text=True,
|
|
capture_output=True)
|
|
if parse.returncode != 0:
|
|
print(f"FAIL: {label}: bash -n rejected rendered script: {parse.stderr.strip()}",
|
|
file=sys.stderr)
|
|
failed = True
|
|
|
|
sys.exit(1 if failed else 0)
|
|
PYEOF
|
|
|
|
echo ""
|
|
echo "=== Testing all-in-one env: a key in both global and component renders once ==="
|
|
# Regression: all-in-one looped global and component extraEnvironmentVars
|
|
# in two separate ranges, emitting duplicate env entries for any key set
|
|
# in both maps. Render a shared key and assert it appears exactly once in
|
|
# the all-in-one container, with the component value winning (consistent
|
|
# with the merge helper the other components already use). pyyaml is
|
|
# installed by the earlier IAM gRPC block in this same step.
|
|
helm template test $CHART_DIR \
|
|
--set allInOne.enabled=true \
|
|
--set global.seaweedfs.extraEnvironmentVars.WEED_SHARED=fromGlobal \
|
|
--set allInOne.extraEnvironmentVars.WEED_SHARED=fromComponent > /tmp/aio-env.yaml
|
|
python3 - /tmp/aio-env.yaml <<'PYEOF'
|
|
import sys, yaml
|
|
from collections import Counter
|
|
docs = [d for d in yaml.safe_load_all(open(sys.argv[1])) if d]
|
|
dep = next(d for d in docs if d.get("kind") == "Deployment"
|
|
and d["metadata"]["name"].endswith("all-in-one"))
|
|
envs = [e["name"] for c in dep["spec"]["template"]["spec"]["containers"]
|
|
for e in c.get("env", [])]
|
|
dups = {k: v for k, v in Counter(envs).items() if v > 1}
|
|
if dups:
|
|
print(f"FAIL: duplicate env entries in all-in-one container: {dups}", file=sys.stderr)
|
|
sys.exit(1)
|
|
val = next(e.get("value") for c in dep["spec"]["template"]["spec"]["containers"]
|
|
for e in c.get("env", []) if e["name"] == "WEED_SHARED")
|
|
if val != "fromComponent":
|
|
print(f"FAIL: WEED_SHARED should take the component value 'fromComponent', got '{val}'",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
print("all-in-one env: shared key renders once, component value wins")
|
|
PYEOF
|
|
|
|
echo "=== Testing bucket versioning: YAML bool false suspends like string \"false\" ==="
|
|
# bool false used to be a silent no-op while string "false" suspended.
|
|
BOOL_FALSE=$(helm template test $CHART_DIR \
|
|
--set s3.enabled=true \
|
|
--set s3.createBuckets[0].name=verbucket \
|
|
--set s3.createBuckets[0].versioning=false | grep 's3.bucket.versioning -name verbucket' || true)
|
|
echo "$BOOL_FALSE" | grep -q -- '-status Suspended' || { echo "FAIL: bool false versioning did not Suspend the bucket"; exit 1; }
|
|
echo "Bucket versioning: YAML bool false suspends consistently with string \"false\""
|
|
|
|
echo ""
|
|
echo "=== Testing hook Job labels ==="
|
|
# The hook Jobs were the only pods in the chart without the standard
|
|
# app.kubernetes.io label set, so nothing label-based could target
|
|
# them - NetworkPolicy podSelectors, monitoring, kubectl -l. Assert
|
|
# both the Job and its pod template carry the same name/instance/
|
|
# component triple the other components use, and that the triple is
|
|
# the hook's own so a per-component selector cannot match it too.
|
|
#
|
|
# Only the bucket hook is covered: the volume resize hook is gated on
|
|
# lookup finding a StatefulSet with a smaller PVC than requested, and
|
|
# lookup returns nothing under helm template, so it never renders here.
|
|
python3 - "$CHART_DIR" <<'PYEOF'
|
|
import subprocess, sys, yaml
|
|
|
|
chart = sys.argv[1]
|
|
# The three a selector keys on, and the full set the other workloads
|
|
# carry - a missing chart or managed-by label is not a selector
|
|
# problem, but it does leave the hook Jobs looking unlike everything
|
|
# else the release owns.
|
|
TRIPLE = ("app.kubernetes.io/name", "app.kubernetes.io/instance",
|
|
"app.kubernetes.io/component")
|
|
# The labels that identify the release rather than the workload, and
|
|
# so have to hold the same values everywhere. They are compared
|
|
# against a workload that already renders them instead of being
|
|
# spelled out here, which keeps the chart version out of the test.
|
|
# managed-by is not among them on purpose: the chart puts it on
|
|
# workload metadata but not on pod templates, so it cannot be part of
|
|
# a cross-workload comparison. Its presence is still checked below.
|
|
RELEASE = ("app.kubernetes.io/name", "app.kubernetes.io/instance",
|
|
"helm.sh/chart")
|
|
STANDARD = TRIPLE + ("helm.sh/chart", "app.kubernetes.io/managed-by")
|
|
|
|
def render(values):
|
|
args = ["helm", "template", "test", chart]
|
|
for k, v in values.items():
|
|
args += ["--set", f"{k}={v}"]
|
|
return subprocess.check_output(args, text=True)
|
|
|
|
def docs(manifest):
|
|
return [d for d in yaml.safe_load_all(manifest) if d]
|
|
|
|
def hook_job(manifest):
|
|
for d in docs(manifest):
|
|
if d.get("kind") == "Job" and d["metadata"]["name"].endswith("-bucket-hook"):
|
|
return d
|
|
return None
|
|
|
|
def triple(labels):
|
|
return {k: labels.get(k) for k in TRIPLE}
|
|
|
|
def release(labels):
|
|
return {k: labels.get(k) for k in RELEASE}
|
|
|
|
def reference(manifest):
|
|
"""Any long-running workload; they all carry the release labels."""
|
|
for d in docs(manifest):
|
|
if d.get("kind") in ("Deployment", "StatefulSet"):
|
|
return d
|
|
return None
|
|
|
|
modes = {
|
|
"s3": {"s3.enabled": "true",
|
|
"s3.createBuckets[0].name": "b"},
|
|
"filer.s3": {"filer.s3.enabled": "true",
|
|
"filer.s3.createBuckets[0].name": "b"},
|
|
"allInOne": {"allInOne.enabled": "true",
|
|
"allInOne.s3.enabled": "true",
|
|
"allInOne.s3.createBuckets[0].name": "b"},
|
|
}
|
|
|
|
failed = []
|
|
for mode, values in modes.items():
|
|
before = len(failed)
|
|
out = render(values)
|
|
job = hook_job(out)
|
|
if job is None:
|
|
failed.append(f"{mode}: bucket hook Job not rendered")
|
|
continue
|
|
job_labels = job["metadata"].get("labels", {})
|
|
pod_labels = job["spec"]["template"]["metadata"].get("labels", {})
|
|
ref = reference(out)
|
|
if ref is None:
|
|
failed.append(f"{mode}: no workload to compare the release labels against")
|
|
continue
|
|
ref_labels = release(ref["spec"]["template"]["metadata"].get("labels", {}))
|
|
for where, labels in (("Job", job_labels), ("pod", pod_labels)):
|
|
missing = [k for k in STANDARD if not labels.get(k)]
|
|
if missing:
|
|
failed.append(f"{mode}: bucket hook {where} has no {missing}, "
|
|
"nothing can select it")
|
|
component = labels.get("app.kubernetes.io/component")
|
|
if component != "bucket-hook":
|
|
failed.append(f"{mode}: bucket hook {where} component is "
|
|
f"{component!r}, expected 'bucket-hook'")
|
|
# Present is not enough: the values have to be the release's
|
|
# own, or a selector written for this release misses the hook.
|
|
if release(labels) != ref_labels:
|
|
failed.append(f"{mode}: bucket hook {where} release labels "
|
|
f"{release(labels)} differ from "
|
|
f"{ref['metadata']['name']}'s {ref_labels}")
|
|
# A selector written against the Job has to find its pods.
|
|
if triple(job_labels) != triple(pod_labels):
|
|
failed.append(f"{mode}: bucket hook Job and pod disagree: "
|
|
f"{triple(job_labels)} vs {triple(pod_labels)}")
|
|
# The triple must not also match another component's pods, or a
|
|
# selector meant for that component would pull the hook pod in.
|
|
for d in docs(out):
|
|
if d.get("kind") not in ("Deployment", "StatefulSet"):
|
|
continue
|
|
other = d["spec"]["template"]["metadata"].get("labels", {})
|
|
if triple(other) == triple(pod_labels):
|
|
failed.append(f"{mode}: bucket hook pod shares its label triple "
|
|
f"with {d['metadata']['name']}")
|
|
if len(failed) == before:
|
|
print(f"{mode}: bucket hook Job and pod carry a distinct label triple")
|
|
|
|
if failed:
|
|
print("\nFAIL:", file=sys.stderr)
|
|
for f in failed:
|
|
print(f" - {f}", file=sys.stderr)
|
|
sys.exit(1)
|
|
PYEOF
|
|
echo "Hook Job label tests passed"
|
|
|
|
echo ""
|
|
echo "=== Testing NetworkPolicy rendering ==="
|
|
# The policies are only exercised for real by the networkpolicy-install
|
|
# job below. These assertions cover what template rendering can see:
|
|
# that the flag stays off by default, that every deployed component has
|
|
# exactly one policy, that each policy allows every port its workload
|
|
# declares, and that egress stays a separate opt-in.
|
|
python3 - "$CHART_DIR" <<'PYEOF'
|
|
import subprocess, sys, yaml
|
|
|
|
chart = sys.argv[1]
|
|
|
|
def render(values, json_values=None):
|
|
args = ["helm", "template", "test", chart]
|
|
for k, v in values.items():
|
|
args += ["--set", f"{k}={v}"]
|
|
for k, v in (json_values or {}).items():
|
|
args += ["--set-json", f"{k}={v}"]
|
|
return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT)
|
|
|
|
def expect_failure(values, needle, label, json_values=None):
|
|
try:
|
|
render(values, json_values)
|
|
except subprocess.CalledProcessError as e:
|
|
if needle not in (e.output or ""):
|
|
failed.append(f"{label}: unexpected error: {(e.output or '')[:200]}")
|
|
else:
|
|
print(label)
|
|
return
|
|
failed.append(f"{label}: render should have failed")
|
|
|
|
def docs(manifest):
|
|
return [d for d in yaml.safe_load_all(manifest) if d]
|
|
|
|
def component(labels):
|
|
return labels.get("app.kubernetes.io/component")
|
|
|
|
def policies(manifest):
|
|
out = {}
|
|
for d in docs(manifest):
|
|
if d.get("kind") != "NetworkPolicy":
|
|
continue
|
|
out[component(d["spec"]["podSelector"]["matchLabels"])] = d
|
|
return out
|
|
|
|
def workloads(manifest, kinds=("Deployment", "StatefulSet")):
|
|
out = {}
|
|
for d in docs(manifest):
|
|
if d.get("kind") in kinds:
|
|
out[component(d["spec"]["template"]["metadata"]["labels"])] = d
|
|
return out
|
|
|
|
def allowed_ports(policy):
|
|
ports = set()
|
|
for rule in policy["spec"].get("ingress") or []:
|
|
for p in rule.get("ports") or []:
|
|
ports.add(p["port"])
|
|
return ports
|
|
|
|
def container_ports(workload):
|
|
ports = set()
|
|
for c in workload["spec"]["template"]["spec"].get("containers", []):
|
|
for p in c.get("ports") or []:
|
|
ports.add(p["containerPort"])
|
|
return ports
|
|
|
|
EVERYTHING = {
|
|
"s3.enabled": "true",
|
|
"sftp.enabled": "true",
|
|
"admin.enabled": "true",
|
|
"worker.enabled": "true",
|
|
"cosi.enabled": "true",
|
|
"s3.createBuckets[0].name": "b",
|
|
"volumes.ssd.port": "8081",
|
|
"global.seaweedfs.monitoring.enabled": "true",
|
|
}
|
|
|
|
failed = []
|
|
|
|
# Off by default: the chart has never shipped a NetworkPolicy and must not
|
|
# start now, or every existing release in a default-deny namespace changes
|
|
# behaviour on the next upgrade.
|
|
if policies(render(EVERYTHING)):
|
|
failed.append("networkPolicy.enabled unset: policies rendered anyway")
|
|
else:
|
|
print("networkPolicy off by default: no policies rendered")
|
|
|
|
on = dict(EVERYTHING, **{"networkPolicy.enabled": "true"})
|
|
out = render(on)
|
|
pols = policies(out)
|
|
wls = workloads(out)
|
|
|
|
# Every workload gets exactly one policy, and every policy has a workload.
|
|
# A component with no policy is wide open under default-deny; a policy with
|
|
# no component is dead weight that hides a renamed label.
|
|
for comp in wls:
|
|
if comp not in pols:
|
|
failed.append(f"{comp}: workload has no NetworkPolicy")
|
|
for comp in pols:
|
|
# The bucket hook Job is covered too, and a Job is not a workload here.
|
|
if comp not in wls and comp != "bucket-hook":
|
|
failed.append(f"{comp}: NetworkPolicy selects a component that is not deployed")
|
|
|
|
# The ports a component listens on come from the same values as its
|
|
# containerPorts, so the two must agree. This is what catches a port added to
|
|
# a workload and forgotten in the policy - the failure mode that only shows up
|
|
# once someone turns the flag on.
|
|
for comp, wl in wls.items():
|
|
if comp not in pols:
|
|
continue
|
|
declared = container_ports(wl)
|
|
allowed = allowed_ports(pols[comp])
|
|
missing = sorted(declared - allowed)
|
|
if missing:
|
|
failed.append(f"{comp}: listens on {missing} but its policy does not allow it")
|
|
if not failed:
|
|
print("every workload has a policy covering all of its containerPorts")
|
|
|
|
# The bucket hook Job and its policy are gated on one shared helper, and
|
|
# this pins that down: a Job without its policy hangs under default-deny,
|
|
# a policy without its Job is noise. The combinations cover the modes that
|
|
# decide it, including the ones where a bucket list is ignored.
|
|
hook_cases = {
|
|
"nothing": {},
|
|
"s3 without buckets": {"s3.enabled": "true"},
|
|
"s3 with buckets": {"s3.enabled": "true", "s3.createBuckets[0].name": "b"},
|
|
"filer.s3 without buckets": {"filer.s3.enabled": "true"},
|
|
"filer.s3 with buckets": {"filer.s3.enabled": "true",
|
|
"filer.s3.createBuckets[0].name": "b"},
|
|
"s3 gateway, buckets on filer.s3": {"s3.enabled": "true",
|
|
"filer.s3.createBuckets[0].name": "b"},
|
|
"allInOne without buckets": {"allInOne.enabled": "true",
|
|
"allInOne.s3.enabled": "true"},
|
|
"allInOne with buckets": {"allInOne.enabled": "true",
|
|
"allInOne.s3.enabled": "true",
|
|
"allInOne.s3.createBuckets[0].name": "b"},
|
|
# allInOne reads only its own bucket list, so s3.createBuckets is
|
|
# not enough to produce the Job.
|
|
"allInOne, buckets on s3": {"allInOne.enabled": "true",
|
|
"allInOne.s3.enabled": "true",
|
|
"s3.createBuckets[0].name": "b"},
|
|
"master off": {"master.enabled": "false", "s3.enabled": "true",
|
|
"s3.createBuckets[0].name": "b"},
|
|
"buckets but no S3 endpoint": {"s3.createBuckets[0].name": "b"},
|
|
}
|
|
for label, values in hook_cases.items():
|
|
out = render(dict(values, **{"networkPolicy.enabled": "true"}))
|
|
job = any(d.get("kind") == "Job"
|
|
and d["metadata"]["name"].endswith("-bucket-hook")
|
|
for d in docs(out))
|
|
policy = "bucket-hook" in policies(out)
|
|
if job != policy:
|
|
failed.append(f"{label}: bucket hook Job={job} but its policy={policy}; "
|
|
"the two are gated on seaweedfs.bucketHookEnabled and "
|
|
"have to appear together")
|
|
if not failed:
|
|
print(f"bucket hook Job and policy agree across {len(hook_cases)} modes")
|
|
|
|
# Egress is its own opt-in: with it off the policies must not constrain
|
|
# outbound traffic at all, or enabling networkPolicy alone would cut the filer
|
|
# off from its store.
|
|
for comp, p in pols.items():
|
|
if "Egress" in p["spec"]["policyTypes"]:
|
|
failed.append(f"{comp}: Egress in policyTypes while networkPolicy.egress.enabled is false")
|
|
if not any("Egress" in p["spec"]["policyTypes"] for p in pols.values()):
|
|
print("egress off by default: policies are ingress-only")
|
|
|
|
# Components that need the API server must not silently lose it: rendering
|
|
# fails with a pointer to the value instead.
|
|
expect_failure(dict(on, **{"networkPolicy.egress.enabled": "true"}),
|
|
"kubeApiServer.cidrs is empty",
|
|
"empty kubeApiServer.cidrs fails the render with a pointer to the value")
|
|
|
|
egress_on = dict(on, **{
|
|
"networkPolicy.egress.enabled": "true",
|
|
"networkPolicy.egress.kubeApiServer.cidrs[0]": "10.96.0.1/32",
|
|
})
|
|
|
|
# An empty port or peer list is "everything" in a NetworkPolicy, not
|
|
# "nothing", so the two places that could be emptied have to be refused
|
|
# rather than quietly widened.
|
|
expect_failure(egress_on, "kubeApiServer.ports is empty",
|
|
"empty kubeApiServer.ports is refused instead of allowing every port",
|
|
json_values={"networkPolicy.egress.kubeApiServer.ports": "[]"})
|
|
expect_failure(egress_on, "allowDNS is on but both",
|
|
"DNS with no selector is refused instead of allowing every pod",
|
|
json_values={"networkPolicy.egress.dnsNamespaceSelector": "null",
|
|
"networkPolicy.egress.dnsPodSelector": "null"})
|
|
|
|
# Dropping one of the two DNS selectors must leave the key out, not
|
|
# render it as null - null namespaceSelector means this namespace and
|
|
# null podSelector means every pod in the peer namespace.
|
|
for dropped in ("dnsNamespaceSelector", "dnsPodSelector"):
|
|
out = render(egress_on, {f"networkPolicy.egress.{dropped}": "null"})
|
|
nulls = [(c, k) for c, p in policies(out).items()
|
|
for r in p["spec"]["egress"]
|
|
for t in r.get("to") or []
|
|
for k, v in t.items() if v is None]
|
|
nulls += [(c, "ports") for c, p in policies(out).items()
|
|
for r in p["spec"]["egress"] if "ports" in r and r["ports"] is None]
|
|
if nulls:
|
|
failed.append(f"{dropped}=null: rendered null keys {sorted(set(nulls))}")
|
|
else:
|
|
print(f"{dropped}=null: the key is left out rather than rendered as null")
|
|
|
|
# Helm allows a release named "123", and an unquoted label value then
|
|
# renders as a YAML integer. Label values are strings in the API, so the
|
|
# whole object gets rejected - a policy that never applies, silently.
|
|
# Every label the policies emit, in metadata and in every selector, has
|
|
# to survive that release name as a string.
|
|
numeric = subprocess.check_output(
|
|
["helm", "template", "123", chart,
|
|
"--set", "networkPolicy.enabled=true",
|
|
"--set", "networkPolicy.egress.enabled=true",
|
|
"--set", "networkPolicy.egress.kubeApiServer.cidrs[0]=10.96.0.1/32",
|
|
"--set", "s3.enabled=true", "--set", "s3.createBuckets[0].name=b"],
|
|
text=True, stderr=subprocess.STDOUT)
|
|
|
|
def label_maps(policy):
|
|
yield "metadata", policy["metadata"].get("labels", {})
|
|
yield "podSelector", policy["spec"]["podSelector"]["matchLabels"]
|
|
for direction in ("ingress", "egress"):
|
|
for i, rule in enumerate(policy["spec"].get(direction) or []):
|
|
for peer in rule.get("to") or rule.get("from") or []:
|
|
for key in ("podSelector", "namespaceSelector"):
|
|
sel = peer.get(key) or {}
|
|
yield f"{direction}[{i}].{key}", sel.get("matchLabels", {})
|
|
|
|
coerced = [(comp, where, k, v)
|
|
for comp, p in policies(numeric).items()
|
|
for where, labels in label_maps(p)
|
|
for k, v in labels.items() if not isinstance(v, str)]
|
|
if coerced:
|
|
failed.append(f"release name 123: label values are not strings, so the API "
|
|
f"server rejects the policy: {coerced}")
|
|
else:
|
|
print("a numeric release name keeps every policy label a string")
|
|
|
|
out = render(egress_on)
|
|
pols = policies(out)
|
|
|
|
# DNS for everyone: every component addresses its peers by service name.
|
|
for comp, p in pols.items():
|
|
dns = [r for r in p["spec"]["egress"]
|
|
if {x["port"] for x in r.get("ports") or []} == {53}]
|
|
if not dns:
|
|
failed.append(f"{comp}: egress on but no DNS rule")
|
|
if not failed:
|
|
print("every policy allows DNS when egress is on")
|
|
|
|
# The API server rule goes only to what talks to it, which is the COSI
|
|
# sidecar and, on an upgrade that grows a PVC, the resize hook. No
|
|
# seaweedfs binary does - there is no client-go in go.mod - so admin
|
|
# must not be in here: it would force everyone running admin to name an
|
|
# API server address for a connection that is never made.
|
|
apiserver = {c for c, p in pols.items()
|
|
if any("ipBlock" in t for r in p["spec"]["egress"] for t in r.get("to") or [])}
|
|
expected = {"objectstorage-provisioner"}
|
|
if apiserver != expected:
|
|
failed.append(f"API server egress granted to {sorted(apiserver)}, expected {sorted(expected)}")
|
|
else:
|
|
print(f"API server egress limited to {sorted(expected)}")
|
|
|
|
# Which means egress on its own must render for a release that runs
|
|
# neither COSI nor a resize: no component of it reaches the API server,
|
|
# so nothing may demand a CIDR for one.
|
|
for label, values in {"defaults": {}, "admin": {"admin.enabled": "true"}}.items():
|
|
try:
|
|
render(dict(values, **{"networkPolicy.enabled": "true",
|
|
"networkPolicy.egress.enabled": "true"}))
|
|
print(f"egress on {label} renders without kubeApiServer.cidrs")
|
|
except subprocess.CalledProcessError as e:
|
|
failed.append(f"{label}: egress needs kubeApiServer.cidrs but nothing "
|
|
f"in the release talks to the API server: {(e.output or '')[:200]}")
|
|
|
|
# The resize hook's policy is gated on the same lookup as its Job, so
|
|
# neither is ever in a rendered set - a policy on its own would be an
|
|
# orphaned hook resource on every install, since Helm does not collect
|
|
# those. The hook annotations it carries when the lookup does hit are
|
|
# only reachable against a live cluster.
|
|
if "volume-resize-hook" in pols:
|
|
failed.append("volume-resize-hook policy rendered without its Job; the two "
|
|
"are gated on seaweedfs.volumeResizeHookCommands and have to "
|
|
"appear together")
|
|
else:
|
|
print("volume-resize-hook policy tracks its Job rather than rendering always")
|
|
|
|
# A components key that names nothing reads as "these rules are applied"
|
|
# and silently does not apply them.
|
|
expect_failure(dict(on, **{"networkPolicy.components.filerr.extraIngress[0].ports[0].port": "1"}),
|
|
'"filerr" is not a component of this chart',
|
|
"a misspelled networkPolicy.components key fails the render")
|
|
|
|
# The bucket hook is post-install, so the release manifest is already applied;
|
|
# its policy must be a plain resource that uninstall cleans up.
|
|
if "helm.sh/hook" in (pols["bucket-hook"]["metadata"].get("annotations") or {}):
|
|
failed.append("bucket-hook policy is a hook resource; post-install runs after the manifest is applied")
|
|
else:
|
|
print("bucket-hook policy is a plain release resource")
|
|
|
|
if failed:
|
|
print("\nFAIL:", file=sys.stderr)
|
|
for f in failed:
|
|
print(f" - {f}", file=sys.stderr)
|
|
sys.exit(1)
|
|
PYEOF
|
|
echo "NetworkPolicy rendering tests passed"
|
|
|
|
echo ""
|
|
echo "=== Testing enterprise license mount + master persistence ==="
|
|
python3 - "$CHART_DIR" <<'PYEOF'
|
|
import subprocess, sys, yaml
|
|
chart = sys.argv[1]
|
|
|
|
def render(values):
|
|
args = ["helm", "template", "test", chart]
|
|
for k, v in values.items():
|
|
args += ["--set", f"{k}={v}"]
|
|
return subprocess.check_output(args, text=True)
|
|
|
|
def workloads(manifest):
|
|
for d in yaml.safe_load_all(manifest):
|
|
if d and d.get("kind") in ("Deployment", "StatefulSet"):
|
|
yield d
|
|
|
|
ALL_ON = {
|
|
"admin.enabled": "true",
|
|
"s3.enabled": "true",
|
|
"sftp.enabled": "true",
|
|
"worker.enabled": "true",
|
|
}
|
|
failed = []
|
|
|
|
# Case 1: unconfigured renders nothing, so existing installs are unaffected.
|
|
out = render(ALL_ON)
|
|
if "SEAWEED_LICENSE" in out or "seaweedfs-license" in out:
|
|
failed.append("defaults: license plumbing should not render")
|
|
else:
|
|
print("defaults: no license volume/env (unchanged)")
|
|
|
|
# Case 2: only the workloads that run a master carry the license.
|
|
LICENSE_PATH = "/etc/seaweedfs/license/seaweed-license.json"
|
|
out = render(dict(ALL_ON, **{
|
|
"global.seaweedfs.license.existingSecret": "lic",
|
|
}))
|
|
carrying = set()
|
|
for d in workloads(out):
|
|
name = d["metadata"]["name"]
|
|
pod = d["spec"]["template"]["spec"]
|
|
vols = [v for v in pod.get("volumes", []) if v.get("name") == "seaweedfs-license"]
|
|
if not vols:
|
|
continue
|
|
carrying.add(name)
|
|
secret = vols[0].get("secret", {})
|
|
if secret.get("secretName") != "lic":
|
|
failed.append(f"{name}: license volume points at {secret.get('secretName')!r}, not the configured Secret")
|
|
items = [i.get("key") for i in secret.get("items", [])]
|
|
if items != ["seaweed-license.json"]:
|
|
failed.append(f"{name}: license volume projects {items}, expected only the license key")
|
|
for c in pod.get("containers", []):
|
|
mounts = [m for m in c.get("volumeMounts", []) if m["name"] == "seaweedfs-license"]
|
|
if not mounts:
|
|
failed.append(f"{name}/{c['name']}: license volume not mounted")
|
|
continue
|
|
# subPath would freeze the file at container start.
|
|
if any("subPath" in m for m in mounts):
|
|
failed.append(f"{name}/{c['name']}: license mount uses subPath (renewals would not propagate)")
|
|
if not all(m.get("readOnly") for m in mounts):
|
|
failed.append(f"{name}/{c['name']}: license mount is not readOnly")
|
|
env = {e["name"]: e.get("value") for e in c.get("env", [])}
|
|
if env.get("SEAWEED_LICENSE") != LICENSE_PATH:
|
|
failed.append(f"{name}/{c['name']}: SEAWEED_LICENSE not set to the mounted file")
|
|
if carrying != {"test-seaweedfs-master"}:
|
|
failed.append(f"license: expected only the master to carry it, got {sorted(carrying)}")
|
|
else:
|
|
print("license: master only, key-scoped, readOnly, no subPath, SEAWEED_LICENSE set")
|
|
|
|
# all-in-one runs `weed server -master`, so it needs the license too.
|
|
out = render({
|
|
"allInOne.enabled": "true",
|
|
"master.enabled": "false",
|
|
"volume.enabled": "false",
|
|
"filer.enabled": "false",
|
|
"global.seaweedfs.license.existingSecret": "lic",
|
|
})
|
|
aio = [d for d in workloads(out) if d["metadata"]["name"].endswith("-all-in-one")]
|
|
if len(aio) != 1:
|
|
failed.append(f"all-in-one: expected 1 workload, got {len(aio)}")
|
|
else:
|
|
pod = aio[0]["spec"]["template"]["spec"]
|
|
if not any(v.get("name") == "seaweedfs-license" for v in pod.get("volumes", [])):
|
|
failed.append("all-in-one: missing the license volume")
|
|
else:
|
|
print("all-in-one: carries the license")
|
|
|
|
# SEAWEED_LICENSE is reserved on both env paths.
|
|
out = render({
|
|
"global.seaweedfs.license.existingSecret": "lic",
|
|
"global.seaweedfs.extraEnvironmentVars.SEAWEED_LICENSE": "/tmp/bogus.json",
|
|
})
|
|
for d in workloads(out):
|
|
if not d["metadata"]["name"].endswith("-master"):
|
|
continue
|
|
for c in d["spec"]["template"]["spec"].get("containers", []):
|
|
names = [e["name"] for e in c.get("env", [])]
|
|
if names.count("SEAWEED_LICENSE") != 1:
|
|
failed.append(f"SEAWEED_LICENSE rendered {names.count('SEAWEED_LICENSE')} times")
|
|
else:
|
|
value = next(e.get("value") for e in c["env"] if e["name"] == "SEAWEED_LICENSE")
|
|
if value != LICENSE_PATH:
|
|
failed.append(f"SEAWEED_LICENSE overridden to {value!r} by extraEnvironmentVars")
|
|
else:
|
|
print("SEAWEED_LICENSE: reserved, rendered once, points at the mount")
|
|
|
|
# secretExtraEnvironmentVars is rendered outside the merge helper.
|
|
out = render({
|
|
"allInOne.enabled": "true",
|
|
"master.enabled": "false",
|
|
"volume.enabled": "false",
|
|
"filer.enabled": "false",
|
|
"global.seaweedfs.license.existingSecret": "lic",
|
|
"allInOne.secretExtraEnvironmentVars.SEAWEED_LICENSE.secretKeyRef.name": "x",
|
|
"allInOne.secretExtraEnvironmentVars.SEAWEED_LICENSE.secretKeyRef.key": "y",
|
|
})
|
|
for d in workloads(out):
|
|
if not d["metadata"]["name"].endswith("-all-in-one"):
|
|
continue
|
|
for c in d["spec"]["template"]["spec"].get("containers", []):
|
|
names = [e["name"] for e in c.get("env", [])]
|
|
if names.count("SEAWEED_LICENSE") != 1:
|
|
failed.append(f"all-in-one: SEAWEED_LICENSE rendered {names.count('SEAWEED_LICENSE')} times via secretExtraEnvironmentVars")
|
|
elif next(e.get("value") for e in c["env"] if e["name"] == "SEAWEED_LICENSE") != LICENSE_PATH:
|
|
failed.append("all-in-one: secretExtraEnvironmentVars overrode SEAWEED_LICENSE")
|
|
else:
|
|
print("all-in-one: SEAWEED_LICENSE reserved on the secret env path too")
|
|
|
|
# Case 3: the default stays hostPath. volumeClaimTemplates is immutable,
|
|
# so growing one here would break helm upgrade on existing releases.
|
|
masters = [d for d in workloads(render({})) if d["metadata"]["name"].endswith("-master")]
|
|
if len(masters) != 1:
|
|
failed.append(f"master: expected 1 StatefulSet by default, got {len(masters)}")
|
|
else:
|
|
d = masters[0]
|
|
claims = [c["metadata"]["name"] for c in d["spec"].get("volumeClaimTemplates", [])]
|
|
if any(c.startswith("data-") for c in claims):
|
|
failed.append(f"master: default grew a data volumeClaimTemplate ({claims}); "
|
|
"that breaks helm upgrade on existing releases")
|
|
data_host = [v["name"] for v in d["spec"]["template"]["spec"].get("volumes", [])
|
|
if v.get("hostPath") and v["name"].startswith("data-")]
|
|
if not data_host:
|
|
failed.append("master: default no longer renders a hostPath data volume")
|
|
else:
|
|
print("master: default still hostPath (upgrade-compatible)")
|
|
|
|
# Case 4: opting into a claim renders one, with the requested size.
|
|
masters = [d for d in workloads(render({"master.data.type": "persistentVolumeClaim"}))
|
|
if d["metadata"]["name"].endswith("-master")]
|
|
if len(masters) != 1:
|
|
failed.append(f"master.data.type=persistentVolumeClaim: expected 1 StatefulSet, got {len(masters)}")
|
|
else:
|
|
d = masters[0]
|
|
claims = {c["metadata"]["name"]: c["spec"]["resources"]["requests"]["storage"]
|
|
for c in d["spec"].get("volumeClaimTemplates", [])}
|
|
data = {k: v for k, v in claims.items() if k.startswith("data-")}
|
|
if not data:
|
|
failed.append(f"master.data.type=persistentVolumeClaim: no data claim rendered ({claims})")
|
|
elif set(data.values()) != {"1Gi"}:
|
|
failed.append(f"master.data.type=persistentVolumeClaim: unexpected size {data}")
|
|
else:
|
|
print(f"master.data.type=persistentVolumeClaim: renders {sorted(data)} at 1Gi")
|
|
if any(v.get("hostPath") and v["name"].startswith("data-")
|
|
for v in d["spec"]["template"]["spec"].get("volumes", [])):
|
|
failed.append("master.data.type=persistentVolumeClaim: data still on a hostPath")
|
|
|
|
if failed:
|
|
print("\nFAIL:", file=sys.stderr)
|
|
for f in failed:
|
|
print(f" - {f}", file=sys.stderr)
|
|
sys.exit(1)
|
|
PYEOF
|
|
echo "License + master persistence tests passed"
|
|
|
|
echo "All template rendering tests passed!"
|
|
|
|
- name: Resolve an image tag that is published
|
|
run: |
|
|
set -e
|
|
# A release bumps appVersion on master ~40 minutes before the container
|
|
# build publishes that tag, and this workflow runs on the bump commit.
|
|
# Install the last released image for the length of that window instead
|
|
# of failing on ImagePullBackOff.
|
|
IMAGE=$(helm template test k8s/charts/seaweedfs \
|
|
-s templates/master/master-statefulset.yaml | awk '$1 == "image:" {print $2; exit}')
|
|
REPO=${IMAGE%:*}
|
|
TAG=${IMAGE##*:}
|
|
# Anything but a published tag installs latest, which between releases
|
|
# is the same digest as the chart's own appVersion, so a registry blip
|
|
# costs nothing while failing the job on one would cost a red build.
|
|
STATUS=$(curl -sSL --connect-timeout 5 --max-time 15 -o /dev/null \
|
|
-w '%{http_code}' "https://hub.docker.com/v2/repositories/$REPO/tags/$TAG" || true)
|
|
if [ "$STATUS" = 200 ]; then
|
|
echo "installing $IMAGE"
|
|
else
|
|
echo "$IMAGE is unavailable (HTTP ${STATUS:-none}), installing $REPO:latest"
|
|
TAG=latest
|
|
fi
|
|
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
|
|
|
|
- name: Create kind cluster
|
|
uses: helm/kind-action@v1.15.0
|
|
|
|
- name: Run chart-testing (install)
|
|
run: |
|
|
ct install --target-branch ${{ github.event.repository.default_branch }} --all --chart-dirs k8s/charts \
|
|
--helm-extra-set-args "--set=image.tag=$IMAGE_TAG"
|
|
|
|
- name: Verify SFTP host key secret lifecycle
|
|
run: |
|
|
set -e
|
|
CHART_DIR="k8s/charts/seaweedfs"
|
|
NS="sftp-hostkey"
|
|
SECRET="hk-seaweedfs-sftp-ssh-secret"
|
|
SFTP_ARGS="--set image.tag=$IMAGE_TAG --set sftp.enabled=true --set master.enabled=false --set volume.enabled=false --set filer.enabled=false"
|
|
kubectl create namespace "$NS"
|
|
|
|
echo "=== install generates a host key, upgrade keeps it ==="
|
|
helm install hk $CHART_DIR -n "$NS" $SFTP_ARGS
|
|
KEY1=$(kubectl get secret "$SECRET" -n "$NS" -o jsonpath='{.data.ssh_host_ed25519_key}')
|
|
[ -n "$KEY1" ] || { echo "FAIL: install did not create a host key"; exit 1; }
|
|
echo "$KEY1" | base64 -d | grep -q "BEGIN PRIVATE KEY" || { echo "FAIL: host key is not a PEM private key"; exit 1; }
|
|
helm upgrade hk $CHART_DIR -n "$NS" $SFTP_ARGS
|
|
KEY2=$(kubectl get secret "$SECRET" -n "$NS" -o jsonpath='{.data.ssh_host_ed25519_key}')
|
|
[ "$KEY1" = "$KEY2" ] || { echo "FAIL: host key changed across upgrade"; exit 1; }
|
|
echo "host key survives upgrade"
|
|
|
|
echo "=== the key the chart used to bundle is replaced ==="
|
|
kubectl delete secret "$SECRET" -n "$NS"
|
|
kubectl create secret generic "$SECRET" -n "$NS" \
|
|
--from-literal=ssh_host_ed25519_key="stand-in H4McwcDphteXVullu6q7ephEN1N60z stand-in"
|
|
helm upgrade hk $CHART_DIR -n "$NS" $SFTP_ARGS
|
|
ROTATED=$(kubectl get secret "$SECRET" -n "$NS" -o jsonpath='{.data.ssh_host_ed25519_key}' | base64 -d)
|
|
case "$ROTATED" in
|
|
*H4McwcDphteXVullu6q7ephEN1N60z*) echo "FAIL: bundled key survived the upgrade"; exit 1;;
|
|
esac
|
|
echo "$ROTATED" | grep -q "BEGIN PRIVATE KEY" || { echo "FAIL: replacement is not a generated key"; exit 1; }
|
|
echo "bundled key rotated to a generated one"
|
|
|
|
echo "=== operator-managed keys are kept as-is ==="
|
|
kubectl delete secret "$SECRET" -n "$NS"
|
|
ssh-keygen -q -t ed25519 -N "" -C "" -f /tmp/operator_key
|
|
kubectl create secret generic "$SECRET" -n "$NS" --from-file=my_key=/tmp/operator_key
|
|
helm upgrade hk $CHART_DIR -n "$NS" $SFTP_ARGS
|
|
kubectl get secret "$SECRET" -n "$NS" -o jsonpath='{.data.my_key}' | base64 -d | cmp -s - /tmp/operator_key \
|
|
|| { echo "FAIL: operator key was modified"; exit 1; }
|
|
NKEYS=$(kubectl get secret "$SECRET" -n "$NS" -o json | jq '.data | length')
|
|
[ "$NKEYS" = "1" ] || { echo "FAIL: expected only the operator key, found $NKEYS entries"; exit 1; }
|
|
echo "operator key kept, no extra key generated"
|
|
|
|
kubectl delete namespace "$NS"
|
|
echo "SFTP host key lifecycle tests passed"
|
|
|
|
- name: Verify install into a default-deny namespace
|
|
run: |
|
|
set -e
|
|
CHART_DIR="k8s/charts/seaweedfs"
|
|
NS="netpol"
|
|
|
|
# kind enforces NetworkPolicy out of the box since v0.24 (kindnetd
|
|
# runs sigs.k8s.io/kube-network-policies), so the cluster created for
|
|
# chart-testing above is enough and no extra CNI is needed. The two
|
|
# probes at the end fail loudly if that ever stops being true, rather
|
|
# than letting this pass vacuously.
|
|
kubectl create namespace "$NS"
|
|
kubectl apply -n "$NS" -f - <<'EOF'
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: default-deny
|
|
spec:
|
|
podSelector: {}
|
|
policyTypes: [Ingress, Egress]
|
|
EOF
|
|
|
|
# The backend address, not the kubernetes service ClusterIP: kube-proxy
|
|
# rewrites the destination, so an ipBlock has to name the real endpoint.
|
|
APISERVER=$(kubectl get endpointslice kubernetes \
|
|
-o jsonpath='{.endpoints[0].addresses[0]}' 2>/dev/null || true)
|
|
if [ -z "$APISERVER" ]; then
|
|
APISERVER=$(kubectl get endpoints kubernetes -o jsonpath='{.subsets[0].addresses[0].ip}')
|
|
fi
|
|
echo "kube-apiserver at $APISERVER"
|
|
|
|
echo "=== install with the policies on ==="
|
|
# Without them this hangs: the components cannot resolve or reach each
|
|
# other, and the post-install bucket hook waits on master and filer
|
|
# until it gives up. --wait covers the components, and helm fails the
|
|
# release if the hook Job does not finish, so a clean install is the
|
|
# assertion.
|
|
helm install np $CHART_DIR -n "$NS" --wait --timeout 8m \
|
|
--set image.tag=$IMAGE_TAG \
|
|
--set s3.enabled=true \
|
|
--set s3.createBuckets[0].name=testbucket \
|
|
--set networkPolicy.enabled=true \
|
|
--set networkPolicy.egress.enabled=true \
|
|
--set networkPolicy.egress.kubeApiServer.cidrs[0]="$APISERVER/32"
|
|
echo "release came up and the bucket hook finished under default-deny"
|
|
|
|
FILER_IP=$(kubectl get pod -n "$NS" -l app.kubernetes.io/component=filer \
|
|
-o jsonpath='{.items[0].status.podIP}')
|
|
echo "filer pod at $FILER_IP"
|
|
|
|
# Both probes get their own all-egress policy, so the namespace-wide
|
|
# default-deny is not what decides the outcome: the only thing left in
|
|
# the way is the filer's own policy, which admits release pods only.
|
|
# Probing the pod IP keeps DNS out of it.
|
|
kubectl apply -n "$NS" -f - <<'EOF'
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: probe-egress
|
|
spec:
|
|
podSelector:
|
|
matchExpressions:
|
|
- key: probe
|
|
operator: Exists
|
|
policyTypes: [Egress]
|
|
egress:
|
|
- {}
|
|
EOF
|
|
|
|
# Only the marker line, so a probe that never produced one cannot be
|
|
# mistaken for a verdict: "pod/x created" on stdout would read as
|
|
# "not exit=0", which is how the denied case passes. busybox wget
|
|
# reports a timeout on stderr even under -q and kubectl logs returns
|
|
# both streams, so the marker has to be picked out rather than taken
|
|
# as the whole log. Empty output stays empty - the callers fail on it.
|
|
probe() {
|
|
local name=$1
|
|
local labels=$2
|
|
kubectl run "$name" -n "$NS" --image=busybox:1.36 --restart=Never \
|
|
--labels="probe=$name,$labels" --command -- \
|
|
sh -c "wget -T 5 -q -O /dev/null http://$FILER_IP:8888/; echo exit=\$?" >/dev/null
|
|
kubectl wait -n "$NS" --for=jsonpath='{.status.phase}'=Succeeded \
|
|
"pod/$name" --timeout=120s >/dev/null
|
|
kubectl logs -n "$NS" "$name" | grep '^exit=' || true
|
|
}
|
|
|
|
# A pod carrying the release labels is what the filer's policy allows.
|
|
# This has to succeed, otherwise the policies are blocking traffic they
|
|
# are supposed to permit - or nothing is enforced and the next probe
|
|
# would be meaningless.
|
|
ALLOWED=$(probe probe-allowed "app.kubernetes.io/name=seaweedfs,app.kubernetes.io/instance=np")
|
|
echo "labelled probe: $ALLOWED"
|
|
case "$ALLOWED" in
|
|
exit=0) echo "a pod with the release labels reaches the filer";;
|
|
*) echo "FAIL: the filer policy rejects a pod carrying the release labels"; exit 1;;
|
|
esac
|
|
|
|
# The same probe without those labels must not get through. Assert the
|
|
# wget failure rather than the absence of a success, so an empty log
|
|
# fails the job instead of reading as a denial.
|
|
DENIED=$(probe probe-denied "role=outsider")
|
|
echo "unlabelled probe: $DENIED"
|
|
case "$DENIED" in
|
|
exit=0) echo "FAIL: a pod outside the release reached the filer; the policy over-allows"; exit 1;;
|
|
exit=*) echo "the filer policy refuses a pod outside the release";;
|
|
*) echo "FAIL: the probe produced no result, so nothing was tested: '$DENIED'"; exit 1;;
|
|
esac
|
|
|
|
kubectl delete namespace "$NS" --wait=false
|
|
echo "default-deny namespace tests passed"
|