mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-16 04:06:44 +00:00
* helm: mount an enterprise license Secret into every component
Running the enterprise image under this chart meant hand-rolling
extraVolumes and extraVolumeMounts on every component. Missing one is
easy and quiet: a component without the license silently drops to
community mode, and on the admin that surfaces only as Data Recovery and
Point-in-Time Recovery refusing to enable, with the master looking fine.
Add global.seaweedfs.license.existingSecret. The Secret is mounted
read-only into master, volume, filer, s3, sftp, admin, worker and
all-in-one, and SEAWEED_LICENSE points every process at the file rather
than relying on the binary's search paths, which depend on the working
directory.
The mount is a directory, never a subPath: kubelet refreshes Secret
contents in place, but a subPath is resolved once at container start and
never updates, which would break license renewal. There is deliberately
no checksum annotation on the pod template either — that would roll every
pod on renewal, the opposite of what is wanted. Verified on kind: the
renewed file reached a running master ~70s after the Secret was patched,
same pod UID, restartCount 0.
Also documents that only the master re-reads the license on a timer
today; the other components pick a renewal up on their next restart.
* helm: keep master data on a claim by default
The master's -mdir holds its Raft log and snapshots, and with them the
cluster's topology UUID — the identity an enterprise license is issued
against. It defaulted to a hostPath under /ssd, which does not follow a
rescheduled pod: the master came back with an empty data directory, a
freshly generated cluster UUID, and a license that no longer matched.
With the chart's default of a single master replica there is no peer to
recover the identity from either.
Default master.data.type to persistentVolumeClaim, sized 1Gi (Raft state
is small). hostPath stays available for anyone who wants it.
This is breaking for existing releases: volumeClaimTemplates is immutable,
so helm upgrade on a release installed with the old default fails with
"updates to statefulset spec for fields other than ... are forbidden".
Verified on kind, along with both ways out — pinning
master.data.type=hostPath upgrades cleanly, and the documented migration
(stop the master, pre-seed a claim named after the StatefulSet, upgrade)
preserves the cluster UUID. Seeding has to happen while the master is
stopped; copying into a live pod loses the state, since the running
master rewrites its Raft files before the restart.
* helm: mount the license on masters only
The master is what reads the license file: it validates it, enforces the
capacity limit and binds it to the cluster UUID. Mounting the Secret on
volume, filer, s3, sftp, admin and worker put it in six more containers
that never look at it, so drop it there and keep master plus all-in-one,
which runs `weed server -master`.
Two fixes from review while here:
- project only the configured key out of the Secret, so an unrelated
key in the same Secret is not exposed to the container. Verified the
key-scoped projection still updates in place: patching the Secret
reached the running master in ~50s, same pod UID, restartCount 0.
- drop SEAWEED_LICENSE from merged extraEnvironmentVars while a
license Secret is configured. It used to be possible to render the
key twice in one container, with the user's value winning over the
path the chart actually mounts.
CI now pins the scope (master only, all-in-one separately), the
key-scoped projection, readOnly, and that SEAWEED_LICENSE renders once.
* helm: fix the documented master-data migration
The seed pod in the migration never mounted the claim it was supposed to
seed, so following the steps verbatim copied the Raft state onto the
pod's ephemeral filesystem and threw it away with the pod — landing the
reader in exactly the empty-claim, new-cluster-UUID state the section
exists to avoid. Give the pod the volume.
The names were assembled as <release>-seaweedfs-*, which is wrong
whenever the release name already contains the chart name or an override
is set; read the StatefulSet name from the cluster instead and derive the
claim from it. Also scope the procedure to the chart's single-master
default, and create the Secret in the release namespace.
Trims the enterprise prose this section had accumulated: this is the OSS
chart, and the master-data default is a durability fix that stands on its
own.
* helm: quote the projected license key, reserve it on the secret env path
A secretKey that YAML reads as a non-string (123, yes, no) rendered
unquoted into the volume's items, so the projection would not match the
Secret's key. Quote both key and path.
all-in-one renders secretExtraEnvironmentVars itself, outside the merge
helper that already drops SEAWEED_LICENSE, so an entry there could still
render the variable twice. Skip it there too while a license Secret is
configured. The master template has no such block, so this is the only
remaining path.
* helm: correct the license helper comments after scoping to masters
* helm: keep hostPath as the master data default
Defaulting master.data.type to a claim broke every existing release:
volumeClaimTemplates is immutable on a StatefulSet, so helm upgrade
failed with "updates to statefulset spec for fields other than ... are
forbidden" before it changed anything.
Keep hostPath as the default and document the claim as the option to
choose — for a new install, or for an existing one via the migration
already in the README. The chart supported both types all along; only
the default moves back.
Every immutable field of every rendered StatefulSet is now identical to
upstream under default values, so an in-place upgrade cannot trip the
API. Verified on kind: install with the unmodified upstream chart,
upgrade to this branch (ok), upgrade again turning the license Secret on
(ok, volume added in place). A fresh install with
master.data.type=persistentVolumeClaim binds its claim as before.
The whole PR is additive now: nothing renders differently until a value
is set.
* helm: scope the migration's StatefulSet lookup to the release
* helm: trim the comments added by this change
1635 lines
84 KiB
YAML
1635 lines
84 KiB
YAML
name: "helm: lint and test charts"
|
|
|
|
on:
|
|
push:
|
|
branches: [ master ]
|
|
paths: ['k8s/**']
|
|
pull_request:
|
|
branches: [ master ]
|
|
paths: ['k8s/**']
|
|
|
|
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 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 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 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: Create kind cluster
|
|
uses: helm/kind-action@v1.14.0
|
|
|
|
- name: Run chart-testing (install)
|
|
run: ct install --target-branch ${{ github.event.repository.default_branch }} --all --chart-dirs k8s/charts
|
|
|
|
- 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 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 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"
|