diff --git a/.codespellrc b/.codespellrc index 82b2e1396d..038fb65244 100644 --- a/.codespellrc +++ b/.codespellrc @@ -19,4 +19,5 @@ ignore-regex = \b[a-z]+[A-Z]\w*\b|\b[A-Z][a-z]+[A-Z]\w*\b # thirdparty: literal Maven groupId `org.apache.hadoop.thirdparty` (external, cannot rename) # unknwon: GitHub username / Go module path (`github.com/unknwon/goconfig`) # atleast: CLI mode literal string in test/benchmark/fuse_db/bin/sqlite_verify.py -ignore-words-list = visibles,fo,te,ser,bject,unparseable,keep-alives,tread,anc,ue,auther,thirdparty,unknwon,atleast +# sme: local variable for a *streamMutateError in mount tests +ignore-words-list = visibles,fo,te,ser,bject,unparseable,keep-alives,tread,anc,ue,auther,thirdparty,unknwon,atleast,sme diff --git a/.github/actions/cleanup-stale-fuse-mounts/action.yml b/.github/actions/cleanup-stale-fuse-mounts/action.yml new file mode 100644 index 0000000000..7e6ddab7bd --- /dev/null +++ b/.github/actions/cleanup-stale-fuse-mounts/action.yml @@ -0,0 +1,11 @@ +name: Clean up stale SeaweedFS test mounts +description: Detach disconnected FUSE test mounts left by earlier jobs on a shared runner. +runs: + using: composite + steps: + - name: Unmount disconnected SeaweedFS test mounts + if: runner.os == 'Linux' && runner.environment == 'self-hosted' + shell: bash + env: + CLEANUP_SCRIPT: ${{ github.action_path }}/cleanup.py + run: python3 "$CLEANUP_SCRIPT" diff --git a/.github/actions/cleanup-stale-fuse-mounts/cleanup.py b/.github/actions/cleanup-stale-fuse-mounts/cleanup.py new file mode 100644 index 0000000000..ad17a767fa --- /dev/null +++ b/.github/actions/cleanup-stale-fuse-mounts/cleanup.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Detach disconnected SeaweedFS integration-test mounts without touching live ones.""" + +import errno +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile + + +def test_mounts(mountinfo): + """Read the kernel table directly: traversing a stale mount may block.""" + roots = {tempfile.gettempdir(), "/tmp", "/var/tmp"} + for line in mountinfo.splitlines(): + fields, filesystem = line.split(" - ", 1) + if filesystem.split()[0].split(".")[0] != "fuse": + continue + fields = fields.split() + # mountinfo escapes whitespace and backslashes with octal sequences. + target = re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), fields[4]) + path = Path(target) + # Test mounts are a mount* leaf under a seaweedfs_fuse_* scratch dir + # (seaweedfs_fuse_test_*/mount, seaweedfs_fuse_failover_*/mount0, ...) + # or the posix-compliance workflow's fixed /tmp/seaweedfs/mount. + if (not path.name.startswith("mount") + or str(path.parent.parent) not in roots + or not (path.parent.name.startswith("seaweedfs_fuse") + or path.parent.name == "seaweedfs")): + continue + yield fields[0], target + + +def mounted(mount_id, target): + return (mount_id, target) in test_mounts(Path("/proc/self/mountinfo").read_text()) + + +def cleanup(): + failed = False + for mount_id, target in test_mounts(Path("/proc/self/mountinfo").read_text()): + try: + # A separate, bounded probe prevents a wedged FUSE daemon from + # hanging cleanup. Only ENOTCONN proves the daemon disconnected; + # a timeout or another error is not enough to detach a live mount. + probe = subprocess.run( + [sys.executable, __file__, "--probe", target], timeout=5, + capture_output=True, check=False, + ) + except subprocess.TimeoutExpired: + print(f"::warning::FUSE probe timed out; leaving {target} mounted") + continue + if probe.returncode != errno.ENOTCONN or not mounted(mount_id, target): + continue + + print(f"Unmounting disconnected FUSE test mount: {target}", flush=True) + # Lazy unmount detaches a dead mount even when an old process still + # holds it open. Prefer the unprivileged FUSE helper; sudo is fallback. + for command in ( + ["fusermount3", "-uz", "--", target], + ["fusermount", "-uz", "--", target], + ["sudo", "-n", "umount", "-l", "--", target], + ): + try: + subprocess.run(command, timeout=10, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + print(f"{command[0]}: {error}", file=sys.stderr) + if not mounted(mount_id, target): + break + else: + print(f"::error::Could not unmount disconnected FUSE test mount: {target}") + failed = True + continue + # The detached mount's parent still holds the crashed run's data and + # logs; test_mounts only yields targets under a SeaweedFS test dir in + # a temp root, so the parent is safe to reclaim here. + shutil.rmtree(Path(target).parent, ignore_errors=True) + return int(failed) + + +if __name__ == "__main__": + if len(sys.argv) == 3 and sys.argv[1] == "--probe": + try: + os.stat(sys.argv[2]) + except OSError as error: + sys.exit(error.errno) + sys.exit(0) + sys.exit(cleanup()) diff --git a/.github/actions/cleanup-stale-fuse-mounts/test_cleanup.py b/.github/actions/cleanup-stale-fuse-mounts/test_cleanup.py new file mode 100644 index 0000000000..b8ee3fa028 --- /dev/null +++ b/.github/actions/cleanup-stale-fuse-mounts/test_cleanup.py @@ -0,0 +1,137 @@ +import errno +import importlib.util +from pathlib import Path +import subprocess +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("cleanup", Path(__file__).with_name("cleanup.py")) +cleanup = importlib.util.module_from_spec(spec) +spec.loader.exec_module(cleanup) + +TARGET = "/tmp/seaweedfs_fuse_test_123/mount" +MOUNTINFO = f"42 1 0:9 / {TARGET} rw - fuse.seaweedfs seaweedfs rw\n" + + +class CleanupTests(unittest.TestCase): + def run_cleanup(self, results, mounted=True): + with patch.object(cleanup.Path, "read_text", return_value=MOUNTINFO), \ + patch.object(cleanup, "mounted", side_effect=mounted if isinstance(mounted, list) else None, + return_value=mounted) as check, \ + patch.object(cleanup.shutil, "rmtree") as rmtree, \ + patch.object(cleanup.subprocess, "run", side_effect=results) as run: + status = cleanup.cleanup() + return status, run, check, rmtree + + def result(self, status): + return subprocess.CompletedProcess([], status) + + def test_only_fuse_integration_test_mounts_are_candidates(self): + rows = MOUNTINFO + rows += MOUNTINFO.replace("fuse.seaweedfs", "ext4") + rows += MOUNTINFO.replace(TARGET, "/mnt/production") + rows += MOUNTINFO.replace(TARGET, "/tmp/seaweedfs_fuse_test_123/data") + rows += MOUNTINFO.replace(TARGET, "/home/user/seaweedfs_fuse_test_123/mount") + rows += MOUNTINFO.replace(TARGET, "/tmp/seaweedfs/data") + self.assertEqual(list(cleanup.test_mounts(rows)), [("42", TARGET)]) + + def test_all_test_mount_layouts_are_candidates(self): + targets = ( + "/tmp/seaweedfs_fuse_test_123/mount", + "/tmp/seaweedfs_fuse_failover_456/mount0", + "/tmp/seaweedfs_fuse_failover_456/mount1", + "/var/tmp/seaweedfs_fuse_p2p_test_789/mount2", + "/tmp/seaweedfs/mount", + ) + rows = "".join( + MOUNTINFO.replace("42", str(40 + i)).replace(TARGET, target) + for i, target in enumerate(targets) + ) + self.assertEqual( + list(cleanup.test_mounts(rows)), + [(str(40 + i), target) for i, target in enumerate(targets)], + ) + + def test_mountinfo_paths_are_unescaped_once(self): + target = "/tmp/seaweedfs_fuse_test_space and\\slash/mount" + encoded = target.replace("\\", r"\134").replace(" ", r"\040") + rows = MOUNTINFO.replace(TARGET, encoded) + self.assertEqual(list(cleanup.test_mounts(rows)), [("42", target)]) + + def test_no_mounts_is_a_noop(self): + with patch.object(cleanup.Path, "read_text", return_value=""), \ + patch.object(cleanup.subprocess, "run") as run: + self.assertEqual(cleanup.cleanup(), 0) + run.assert_not_called() + + def test_healthy_mount_is_untouched(self): + status, run, _, _ = self.run_cleanup([self.result(0)]) + self.assertEqual(status, 0) + self.assertEqual(run.call_count, 1) + + def test_other_probe_errors_are_untouched(self): + for error in (errno.EACCES, errno.ENOENT, errno.EIO): + with self.subTest(error=error): + status, run, _, _ = self.run_cleanup([self.result(error)]) + self.assertEqual(status, 0) + self.assertEqual(run.call_count, 1) + + def test_probe_timeout_does_not_detach_a_possibly_live_mount(self): + status, run, _, _ = self.run_cleanup([subprocess.TimeoutExpired("probe", 5)]) + self.assertEqual(status, 0) + self.assertEqual(run.call_count, 1) + + def test_disconnected_mount_is_lazily_unmounted(self): + status, run, _, _ = self.run_cleanup([self.result(errno.ENOTCONN), self.result(0)], [True, False]) + self.assertEqual(status, 0) + self.assertEqual(run.call_args_list[1].args[0], ["fusermount3", "-uz", "--", TARGET]) + self.assertEqual(run.call_args_list[0].kwargs["timeout"], 5) + self.assertEqual(run.call_args_list[1].kwargs["timeout"], 10) + + def test_replaced_or_removed_mount_is_untouched(self): + status, run, _, _ = self.run_cleanup([self.result(errno.ENOTCONN)], False) + self.assertEqual(status, 0) + self.assertEqual(run.call_count, 1) + + def test_mount_identity_is_checked_without_stat(self): + with patch.object(cleanup.Path, "read_text", return_value=MOUNTINFO): + self.assertTrue(cleanup.mounted("42", TARGET)) + self.assertFalse(cleanup.mounted("43", TARGET)) + + def test_missing_or_timed_out_helper_uses_fallback(self): + for error in (FileNotFoundError(), subprocess.TimeoutExpired("fusermount3", 10)): + with self.subTest(error=error): + status, run, _, _ = self.run_cleanup( + [self.result(errno.ENOTCONN), error, self.result(0)], [True, True, False]) + self.assertEqual(status, 0) + self.assertEqual(run.call_args.args[0], ["fusermount", "-uz", "--", TARGET]) + + def test_failed_helpers_fall_back_to_sudo(self): + status, run, _, _ = self.run_cleanup( + [self.result(errno.ENOTCONN), self.result(1), self.result(1), self.result(0)], + [True, True, True, False]) + self.assertEqual(status, 0) + self.assertEqual(run.call_args.args[0], ["sudo", "-n", "umount", "-l", "--", TARGET]) + + def test_cleanup_fails_when_stale_mount_remains(self): + status, run, _, rmtree = self.run_cleanup( + [self.result(errno.ENOTCONN), self.result(1), self.result(1), self.result(1)]) + self.assertEqual(status, 1) + self.assertEqual(run.call_count, 4) + rmtree.assert_not_called() + + def test_unmounted_test_dir_is_reclaimed(self): + status, _, _, rmtree = self.run_cleanup( + [self.result(errno.ENOTCONN), self.result(0)], [True, False]) + self.assertEqual(status, 0) + rmtree.assert_called_once_with(Path(TARGET).parent, ignore_errors=True) + + def test_probe_process_reports_errno(self): + for target, expected in ((str(Path(__file__)), 0), (str(Path(__file__) / "absent"), errno.ENOTDIR)): + with self.subTest(target=target): + result = subprocess.run([cleanup.sys.executable, cleanup.__file__, "--probe", target], check=False) + self.assertEqual(result.returncode, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/actions/fix-fusermount-setuid/action.yml b/.github/actions/fix-fusermount-setuid/action.yml index 7aad5bf482..5b0428c713 100644 --- a/.github/actions/fix-fusermount-setuid/action.yml +++ b/.github/actions/fix-fusermount-setuid/action.yml @@ -23,8 +23,14 @@ runs: bin=$(command -v fusermount3 || true) if [ -z "$bin" ]; then - echo "no fusermount3 in PATH" >&2 - exit 1 + # Self-hosted runners are not guaranteed to carry fuse3. + sudo apt-get update + sudo apt-get install -y fuse3 + bin=$(command -v fusermount3 || true) + if [ -z "$bin" ]; then + echo "no fusermount3 in PATH after installing fuse3" >&2 + exit 1 + fi fi if can_mount "$bin"; then ls -l "$bin" diff --git a/.github/actions/sign-image/action.yml b/.github/actions/sign-image/action.yml new file mode 100644 index 0000000000..82cc046c08 --- /dev/null +++ b/.github/actions/sign-image/action.yml @@ -0,0 +1,44 @@ +name: Sign container images +description: > + Keyless cosign signature on each image, then a verification pass against the + identity the signature should carry, so a misconfigured job fails here and not + on someone's cluster. That identity is the calling workflow's own, + https://github.com///.github/workflows/@. + The calling job needs `id-token: write` and a registry login for every image. + +inputs: + images: + description: Image references by digest (name@sha256:...), whitespace separated. + required: true + +runs: + using: composite + steps: + - name: Install cosign + uses: sigstore/cosign-installer@v4.1.2 + + - name: Sign + shell: bash + env: + IMAGES: ${{ inputs.images }} + run: | + set -euo pipefail + # The .sig tag layout: the OCI-referrer bundle cosign 3 writes by default + # is not read by the Kyverno and policy-controller releases in use today. + # Cosign 3 also defaults to --use-signing-config, which insists on a + # bundle for its output; turning it off falls back to the default + # Fulcio and Rekor URLs, which is all the .sig layout ever used. + cosign sign --yes --recursive \ + --new-bundle-format=false --use-signing-config=false \ + $IMAGES + + - name: Verify + shell: bash + env: + IMAGES: ${{ inputs.images }} + run: | + set -euo pipefail + cosign verify \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity "https://github.com/$GITHUB_WORKFLOW_REF" \ + $IMAGES diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2e7a327bd3..b4cd98f77c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,14 @@ version: 2 updates: - package-ecosystem: "github-actions" - directory: "/" + directories: + - "/" + - "/.github/actions/sign-image" schedule: interval: "weekly" - package-ecosystem: gomod directory: "/" schedule: interval: weekly + ignore: + - dependency-name: "github.com/seaweedfs/goexif" diff --git a/.github/scripts/helm_jwt_keys.py b/.github/scripts/helm_jwt_keys.py new file mode 100755 index 0000000000..8b79984203 --- /dev/null +++ b/.github/scripts/helm_jwt_keys.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Check JWT extraction and, with --context, real Helm upgrade persistence.""" + +import argparse +import base64 +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import uuid + +try: + import tomllib +except ModuleNotFoundError: # CI also exercises Python 3.10. + import tomli as tomllib + + +ROOT = Path(__file__).resolve().parents[2] +CHART = ROOT / "k8s/charts/seaweedfs" +SECTIONS = ("jwt.signing", "jwt.signing.read", "jwt.filer_signing", "jwt.filer_signing.read") +KEYS = {section: f"active-{index}" for index, section in enumerate(SECTIONS)} +ESCAPED_HEADER = '["jw\\u0074".signing]\nkey = "existing"' + + +def run(*args): + return subprocess.run(args, check=True, text=True, capture_output=True).stdout + + +def keys(raw): + document = tomllib.loads(raw) + result = {} + for section in SECTIONS: + table = document + for part in section.split("."): + table = table.get(part, {}) + if "key" in table: + result[section] = table["key"] + return result + + +def fixtures(): + canonical = "\n".join(f'[{section}]\nkey = "{value}"' for section, value in KEYS.items()) + yield "canonical / final line without newline", canonical + yield "commented stale keys", canonical.replace("key =", '# key = "stale"\nkey =') + yield "commented headers / mixed line endings", "\n".join( + f'# [{section}]\r\n# key = "stale"\r\n[{section}]\nkey = "{value}"' + for section, value in KEYS.items() + ) + yield "indented headers and keys / trailing comments", "\n".join( + f' \t[ {section} ] \t# header [note]\n \tkey \t= \t"{value}" # key comment' + for section, value in KEYS.items() + ) + yield "CRLF", canonical.replace("\n", "\r\n") + yield "quoted and spaced section names", "\n".join( + f'["{section.split(".")[0]}" . \'{section.split(".")[1]}\'' + + (f' . "{section.split(".")[2]}"' if section.count(".") == 2 else "") + + f'] # original table\nkey = "{value}"' + for section, value in KEYS.items() + ) + yield "unrelated quoted header before JWT keys", '["custom section"]\nkey = "other"\n' + canonical + yield "brackets in comments", canonical.replace("key =", "# consult [notes]\nkey =") + yield "quoted values and quoted key names", "\n".join(( + '[jwt.signing]\n"key" = "brackets[inside]#value"', + "[jwt.signing.read]\n'key' = 'literal\\path[#value]'", + r'[jwt.filer_signing]' + '\n' + r'key = "escaped\"quote\\slash\u0041"', + '[jwt.filer_signing.read]\nkey = ""', + )) + yield "absent keys and sections / unrelated key", '\n'.join(( + '[jwt.signing]\nexpires_after_seconds = 10', + '[jwt.signing.read]\nkey = "read-only"', + '[unrelated]\nkey = "not-a-jwt-key"', + '# [jwt.filer_signing]\n# key = "not-active"', + )) + yield "no existing security.toml", "" + + +def check_generated(value): + decoded = base64.b64decode(value, validate=True).decode("ascii") + assert len(decoded) == 10 and decoded.isascii() and decoded.isalnum(), "invalid generated JWT key" + + +def check_helpers(helm, reference_helm): + # Exercise the real helper, not a second implementation of its matching rules. + with tempfile.TemporaryDirectory(prefix="helm-jwt-helper-") as directory: + chart = Path(directory) + (chart / "templates").mkdir() + (chart / "Chart.yaml").write_text("apiVersion: v2\nname: jwt-regression\nversion: 0.0.0\n") + shutil.copyfile(CHART / "templates/shared/_helpers.tpl", chart / "templates/_helpers.tpl") + entries = [ + json.dumps(section) + ': {{ include "seaweedfs.existingTomlKey" (list ' + + json.dumps(section) + ' .Values.raw) | toJson }}' + for section in SECTIONS + ] + template = chart / "templates/keys.yaml" + prefix = '{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"keys"},"data":{' + helper_template = prefix + ",".join(entries) + "}}" + reference_entries = [ + json.dumps(section) + ': {{ dig ' + + " ".join(json.dumps(part) for part in section.split(".")) + + ' "key" "__ABSENT__" (fromToml .Values.raw) | toJson }}' + for section in SECTIONS + ] + reference_template = prefix + ",".join(reference_entries) + "}}" + + def render(binary, source, raw): + template.write_text(source) + values = chart / "input.json" + values.write_text(json.dumps({"raw": raw})) + output = run(binary, "template", "keys", str(chart), "-f", str(values)) + return json.loads(output[output.index("{"):])["data"] + + for name, raw in fixtures(): + expected = keys(raw) + tokens = render(helm, helper_template, raw) + actual = {section: tomllib.loads("key = " + token)["key"] + for section, token in tokens.items() if token != ""} + assert actual == expected, f"{name}: extracted keys differ from stored TOML" + if reference_helm: + reference = render(reference_helm, reference_template, raw) + reference = {section: value for section, value in reference.items() if value != "__ABSENT__"} + assert actual == reference, f"{name}: keys differ from fromToml/dig" + print(f"PASS helper: {name}") + + # A present but unsupported value must not silently become a fresh key. + try: + render(helm, helper_template, '[jwt.signing]\nkey = """multi\nline"""') + except subprocess.CalledProcessError as error: + assert "refusing to replace an existing key" in error.stderr, error.stderr + else: + raise AssertionError("multiline existing key was silently accepted or replaced") + print("PASS helper: unsupported existing value fails without rotation") + + try: + render(helm, helper_template, ESCAPED_HEADER) + except subprocess.CalledProcessError as error: + assert "unsupported quoted section header" in error.stderr, error.stderr + else: + raise AssertionError("unsupported quoted header silently rotated its key") + print("PASS helper: unsupported quoted header fails without rotation") + + +def check_upgrades(helm, context): + namespace = "jwt-key-persist-" + uuid.uuid4().hex[:8] + current = "jk-seaweedfs-security-config" + legacy = "seaweedfs-security-config" + kubectl = ["kubectl", "--context", context, "-n", namespace] + release_args = ["jk", str(CHART), "--kube-context", context, "-n", namespace] + # No workload is needed to exercise Helm's real ConfigMap lookup and update. + for setting in ( + "master.enabled=false", "volume.enabled=false", "filer.enabled=false", + "global.seaweedfs.createClusterRole=false", + "global.seaweedfs.securityConfig.jwtSigning.volumeWrite=true", + "global.seaweedfs.securityConfig.jwtSigning.volumeRead=true", + "global.seaweedfs.securityConfig.jwtSigning.filerWrite=true", + "global.seaweedfs.securityConfig.jwtSigning.filerRead=true", + ): + release_args += ["--set", setting] + + def stored(): + cm = json.loads(run(*kubectl, "get", "configmap", current, "-o", "json")) + return keys(cm["data"]["security.toml"]) + + def upgrade(): + run(helm, "upgrade", *release_args) + return stored() + + def patch(raw): + # Seed previous-release content without taking Helm 4's SSA ownership. + run(*kubectl, "patch", "configmap", current, "--type=merge", "--field-manager=helm", "-p", + json.dumps({"data": {"security.toml": raw}})) + + run(*kubectl, "create", "namespace", namespace) + try: + run(helm, "install", *release_args) + initial = stored() + assert set(initial) == set(SECTIONS), "install omitted a JWT section" + for value in initial.values(): + check_generated(value) + assert upgrade() == initial, "no-op upgrade changed an existing key" + print("PASS upgrade: all four generated keys persist") + + for name, raw in fixtures(): + patch(raw) + actual = upgrade() + expected = keys(raw) + assert set(actual) == set(SECTIONS), f"{name}: upgrade omitted a JWT section" + for section in SECTIONS: + if section in expected: + assert actual[section] == expected[section], f"{name}: changed {section}" + else: + check_generated(actual[section]) + assert upgrade() == actual, f"{name}: subsequent upgrade changed a key" + print(f"PASS upgrade: {name}") + + # Migration from the old chart name, followed by precedence of the current name. + legacy_raw = "\n".join(f'[{section}]\nkey = "legacy-{index}"' + for index, section in enumerate(SECTIONS)) + run(*kubectl, "create", "configmap", legacy, "--from-literal=security.toml=" + legacy_raw) + run(*kubectl, "delete", "configmap", current) + assert upgrade() == keys(legacy_raw), "legacy ConfigMap keys were not preserved" + print("PASS upgrade: legacy ConfigMap migration") + current_raw = next(fixtures())[1] + patch(current_raw) + assert upgrade() == keys(current_raw), "legacy ConfigMap overrode current ConfigMap" + print("PASS upgrade: current ConfigMap takes precedence") + + patch(ESCAPED_HEADER) + try: + upgrade() + except subprocess.CalledProcessError as error: + assert "unsupported quoted section header" in error.stderr, error.stderr + else: + raise AssertionError("unsupported quoted header silently rotated its key") + assert keys(run(*kubectl, "get", "configmap", current, "-o", + "jsonpath={.data.security\\.toml}"))["jwt.signing"] == "existing" + print("PASS upgrade: unsupported quoted header leaves stored key untouched") + finally: + run(*kubectl, "delete", "namespace", namespace, "--wait=false") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--helm", default="helm") + parser.add_argument("--reference-helm", help="Helm >=3.17 binary for differential checks") + parser.add_argument("--context", help="explicit disposable Kubernetes context for live upgrade checks") + args = parser.parse_args() + print(run(args.helm, "version", "--short").strip()) + check_helpers(args.helm, args.reference_helm) + if args.context: + check_upgrades(args.helm, args.context) + + +if __name__ == "__main__": + try: + main() + except subprocess.CalledProcessError as error: + print(error.stderr, file=sys.stderr) + sys.exit(error.returncode) diff --git a/.github/scripts/star_history.py b/.github/scripts/star_history.py new file mode 100644 index 0000000000..fa61a8bcd7 --- /dev/null +++ b/.github/scripts/star_history.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Render the repository's star history to note/star_history.svg. + +Uses the GitHub REST stargazers endpoint with the starred-at accept header, +which caps at 40,000 entries (400 pages of 100). The chart is regenerated on +a schedule; if the repo grows past that cap the script stops at 40,000 and +logs a warning rather than under-reporting. +""" +import json +import os +import sys +import urllib.request +from datetime import datetime + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +from matplotlib.dates import AutoDateLocator, DateFormatter # noqa: E402 + +REPO = "seaweedfs/seaweedfs" +TOKEN = os.environ["GITHUB_TOKEN"] +OUT = os.environ.get("OUT", "note/star_history.svg") +PAGE_CAP = 400 # GitHub's hard limit on stargazer pagination + + +def fetch_stargazers(): + stars = [] + page = 1 + while page <= PAGE_CAP: + url = f"https://api.github.com/repos/{REPO}/stargazers?per_page=100&page={page}" + req = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github.star+json", + "Authorization": f"Bearer {TOKEN}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "seaweedfs-star-history", + }, + ) + with urllib.request.urlopen(req) as resp: + batch = json.load(resp) + if not batch: + break + for u in batch: + sa = u.get("starred_at") + if sa: + stars.append(datetime.fromisoformat(sa.replace("Z", "+00:00"))) + if len(batch) < 100: + break + page += 1 + if page > PAGE_CAP: + print( + f"::warning::Hit the {PAGE_CAP}-page stargazer pagination cap; " + "chart reflects the first 40,000 stars only." + ) + return stars + + +def render(stars, out): + stars.sort() + counts = list(range(1, len(stars) + 1)) + fig, ax = plt.subplots(figsize=(10, 6), dpi=130) + ax.plot(stars, counts, color="#0969da", linewidth=1.6) + ax.set_xlabel("Date") + ax.set_ylabel("Stars") + ax.set_title(f"{REPO} star history") + ax.grid(True, linestyle="--", alpha=0.3) + ax.xaxis.set_major_locator(AutoDateLocator()) + ax.xaxis.set_major_formatter(DateFormatter("%Y-%m")) + fig.autofmt_xdate() + fig.tight_layout() + fig.savefig(out, format="svg", transparent=False) + plt.close(fig) + + +def main(): + stars = fetch_stargazers() + if not stars: + print("::error::No stargazers fetched; not updating the chart.") + sys.exit(1) + render(stars, OUT) + print(f"Rendered {len(stars)} stars to {OUT}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/action-tests.yml b/.github/workflows/action-tests.yml new file mode 100644 index 0000000000..88f33ed93c --- /dev/null +++ b/.github/workflows/action-tests.yml @@ -0,0 +1,30 @@ +name: "actions: unit tests" + +on: + push: + branches: [ master ] + paths: + - '.github/actions/**' + - '.github/workflows/action-tests.yml' + pull_request: + branches: [ master ] + paths: + - '.github/actions/**' + - '.github/workflows/action-tests.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} + +permissions: + contents: read + +jobs: + cleanup-stale-fuse-mounts: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v7 + + - name: Run cleanup action unit tests + run: python3 .github/actions/cleanup-stale-fuse-mounts/test_cleanup.py diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 33d2c10e3b..79adf73c98 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,8 +9,8 @@ on: - '.github/workflows/codeql.yml' concurrency: - group: ${{ github.head_ref }}/codeql - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} jobs: CodeQL-Build: @@ -25,9 +25,15 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: 'go.mod' + cache: ${{ runner.environment == 'github-hosted' }} + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.4 + uses: github/codeql-action/init@v4.39.0 # Override language selection by uncommenting this and choosing your languages with: languages: go @@ -35,7 +41,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below). - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.4 + uses: github/codeql-action/autobuild@v4.39.0 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -49,4 +55,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.4 + uses: github/codeql-action/analyze@v4.39.0 diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 9c4d99a79e..a7be2ead3b 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -8,6 +8,10 @@ on: pull_request: branches: [master] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} + permissions: contents: read diff --git a/.github/workflows/container_dev.yml b/.github/workflows/container_dev.yml index 8d72fa4a7c..2c30f861a2 100644 --- a/.github/workflows/container_dev.yml +++ b/.github/workflows/container_dev.yml @@ -6,17 +6,23 @@ on: paths: - 'weed/**' - 'seaweed-volume/**' + - 'seaweed-common/**' + - 'seaweed-worker/**' - 'docker/**' - 'go.mod' - 'go.sum' - '.github/workflows/container_dev.yml' +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} + permissions: contents: read jobs: - # ── Pre-build Rust volume server binaries natively ────────────────── + # ── Pre-build the Rust binaries natively ──────────────────────────── build-rust-binaries: runs-on: ubuntu-22.04 strategy: @@ -31,9 +37,6 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Install protobuf compiler - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -52,16 +55,33 @@ jobs: echo "CFLAGS_aarch64_unknown_linux_musl=-U_FORTIFY_SOURCE" >> "$GITHUB_ENV" - name: Cache cargo registry and target + if: runner.environment == 'github-hosted' uses: actions/cache@v6 with: path: | ~/.cargo/registry ~/.cargo/git seaweed-volume/target - key: rust-docker-dev-${{ matrix.target }}-${{ hashFiles('seaweed-volume/Cargo.lock') }} + seaweed-worker/target/${{ matrix.target }}/release + key: rust-docker-dev-${{ matrix.target }}-${{ hashFiles('seaweed-volume/Cargo.lock', 'seaweed-worker/Cargo.lock') }} restore-keys: | rust-docker-dev-${{ matrix.target }}- + # lance's build scripts compile their own protos and look for a protoc. + # Point them at the one protoc-bin-vendored ships, which seaweed-worker's + # own build already uses, so no job depends on a system package and every + # build sees the same version. + - name: Use the vendored protoc + run: | + cd seaweed-worker + cargo fetch + # The version from the lock, not whatever else a restored cache holds. + version=$(awk '/^name = "protoc-bin-vendored-linux-x86_64"$/{found=1; next} found && /^version = /{gsub(/"/,"",$3); print $3; exit}' Cargo.lock) + test -n "$version" || { echo "protoc-bin-vendored-linux-x86_64 is not in Cargo.lock" >&2; exit 1; } + protoc=$(find ~/.cargo/registry/src -path "*protoc-bin-vendored-linux-x86_64-$version/bin/protoc" | head -1) + test -x "$protoc" || { echo "no vendored protoc $version in the registry" >&2; exit 1; } + echo "PROTOC=$protoc" >> "$GITHUB_ENV" + - name: Build normal variant env: SEAWEEDFS_COMMIT: ${{ github.sha }} @@ -70,15 +90,26 @@ jobs: cargo build --release --target ${{ matrix.target }} --no-default-features cp target/${{ matrix.target }}/release/weed-volume ../weed-volume-normal-${{ matrix.arch }} + - name: Build the Rust maintenance worker + run: | + cd seaweed-worker + cargo build --release -p weed-lance-worker --target ${{ matrix.target }} + cp target/${{ matrix.target }}/release/weed-worker ../weed-worker-${{ matrix.arch }} + - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: rust-volume-${{ matrix.arch }} - path: weed-volume-normal-${{ matrix.arch }} + name: rust-bins-${{ matrix.arch }} + path: | + weed-volume-normal-${{ matrix.arch }} + weed-worker-${{ matrix.arch }} build-dev-containers: needs: [build-rust-binaries] runs-on: [ubuntu-latest] + permissions: + contents: read + id-token: write steps: - name: Checkout @@ -87,7 +118,7 @@ jobs: - name: Download pre-built Rust binaries uses: actions/download-artifact@v8 with: - pattern: rust-volume-* + pattern: rust-bins-* merge-multiple: true path: ./rust-bins @@ -101,7 +132,16 @@ jobs: echo "Placed pre-built Rust binary for ${arch}" fi done + mkdir -p docker/weed-worker-prebuilt + for arch in amd64 arm64; do + src="./rust-bins/weed-worker-${arch}" + if [ -f "$src" ]; then + cp "$src" "docker/weed-worker-prebuilt/weed-worker-${arch}" + echo "Placed pre-built Rust worker for ${arch}" + fi + done ls -la docker/weed-volume-prebuilt/ + ls -la docker/weed-worker-prebuilt/ - name: Docker meta id: docker_meta @@ -118,7 +158,7 @@ jobs: org.opencontainers.image.vendor=Chris Lu - name: Set up QEMU - uses: docker/setup-qemu-action@v4.2.0 + uses: docker/setup-qemu-action@v4.4.0 - name: Create BuildKit config run: | @@ -149,6 +189,7 @@ jobs: password: ${{ secrets.GHCR_TOKEN }} - name: Build + id: build uses: docker/build-push-action@v7 with: context: ./docker @@ -157,3 +198,17 @@ jobs: platforms: linux/amd64, linux/arm64 tags: ${{ steps.docker_meta.outputs.tags }} labels: ${{ steps.docker_meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + # Key the checkout layer on the pushed sha so cached layers can't + # serve stale code; the Dockerfile fetch fallback picks it up. + build-args: | + BRANCH=${{ github.sha }} + + - name: Sign + if: github.event_name != 'pull_request' + uses: ./.github/actions/sign-image + with: + images: >- + chrislusf/seaweedfs@${{ steps.build.outputs.digest }} + ghcr.io/chrislusf/seaweedfs@${{ steps.build.outputs.digest }} diff --git a/.github/workflows/container_foundationdb_version.yml b/.github/workflows/container_foundationdb_version.yml index 5e2ac35e14..b976a9b245 100644 --- a/.github/workflows/container_foundationdb_version.yml +++ b/.github/workflows/container_foundationdb_version.yml @@ -24,12 +24,19 @@ on: required: false default: '' +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.event.inputs.image_tag || format('foundationdb_{0}_seaweedfs_{1}', github.event.inputs.fdb_version || '7.4.5', github.event.inputs.seaweedfs_ref || github.ref_name) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} + permissions: contents: read jobs: build-foundationdb-image: runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: Checkout @@ -61,6 +68,7 @@ jobs: uses: actions/setup-go@v7 with: go-version-file: go.mod + cache: ${{ runner.environment == 'github-hosted' }} - name: Run FoundationDB tagged tests env: @@ -126,7 +134,7 @@ jobs: echo "seaweedfs_ref=$seaweed" >> "$GITHUB_OUTPUT" - name: Set up QEMU - uses: docker/setup-qemu-action@v4.2.0 + uses: docker/setup-qemu-action@v4.4.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -154,6 +162,7 @@ jobs: fi - name: Build and push image + id: build uses: docker/build-push-action@v7 with: context: ./docker @@ -171,3 +180,8 @@ jobs: org.opencontainers.image.description=SeaweedFS is a distributed storage system for blobs, objects, files, and data lake, to store and serve billions of files fast! org.opencontainers.image.vendor=Chris Lu + - name: Sign + if: github.event_name != 'pull_request' + uses: ./.github/actions/sign-image + with: + images: chrislusf/seaweedfs@${{ steps.build.outputs.digest }} diff --git a/.github/workflows/container_latest.yml b/.github/workflows/container_latest.yml index d760006b99..8eccfc89d4 100644 --- a/.github/workflows/container_latest.yml +++ b/.github/workflows/container_latest.yml @@ -30,6 +30,11 @@ on: type: boolean default: false +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.event.inputs.image_tag || github.run_id }} + cancel-in-progress: false + queue: max + permissions: contents: read security-events: write @@ -59,7 +64,7 @@ jobs: echo "publish=true" >> "$GITHUB_OUTPUT" fi - # ── Pre-build Rust volume server binaries natively ────────────────── + # ── Pre-build the Rust binaries natively ──────────────────────────── build-rust-binaries: runs-on: ubuntu-22.04 strategy: @@ -76,9 +81,6 @@ jobs: with: ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.source_ref || github.ref }} - - name: Install protobuf compiler - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -97,16 +99,33 @@ jobs: echo "CFLAGS_aarch64_unknown_linux_musl=-U_FORTIFY_SOURCE" >> "$GITHUB_ENV" - name: Cache cargo registry and target + if: runner.environment == 'github-hosted' uses: actions/cache@v6 with: path: | ~/.cargo/registry ~/.cargo/git seaweed-volume/target - key: rust-docker-${{ matrix.target }}-${{ hashFiles('seaweed-volume/Cargo.lock') }} + seaweed-worker/target/${{ matrix.target }}/release + key: rust-docker-${{ matrix.target }}-${{ hashFiles('seaweed-volume/Cargo.lock', 'seaweed-worker/Cargo.lock') }} restore-keys: | rust-docker-${{ matrix.target }}- + # lance's build scripts compile their own protos and look for a protoc. + # Point them at the one protoc-bin-vendored ships, which seaweed-worker's + # own build already uses, so no job depends on a system package and every + # build sees the same version. + - name: Use the vendored protoc + run: | + cd seaweed-worker + cargo fetch + # The version from the lock, not whatever else a restored cache holds. + version=$(awk '/^name = "protoc-bin-vendored-linux-x86_64"$/{found=1; next} found && /^version = /{gsub(/"/,"",$3); print $3; exit}' Cargo.lock) + test -n "$version" || { echo "protoc-bin-vendored-linux-x86_64 is not in Cargo.lock" >&2; exit 1; } + protoc=$(find ~/.cargo/registry/src -path "*protoc-bin-vendored-linux-x86_64-$version/bin/protoc" | head -1) + test -x "$protoc" || { echo "no vendored protoc $version in the registry" >&2; exit 1; } + echo "PROTOC=$protoc" >> "$GITHUB_ENV" + - name: Build large-disk variant env: SEAWEEDFS_COMMIT: ${{ github.sha }} @@ -123,20 +142,27 @@ jobs: cargo build --release --target ${{ matrix.target }} --no-default-features cp target/${{ matrix.target }}/release/weed-volume ../weed-volume-normal-${{ matrix.arch }} + - name: Build the Rust maintenance worker + run: | + cd seaweed-worker + cargo build --release -p weed-lance-worker --target ${{ matrix.target }} + cp target/${{ matrix.target }}/release/weed-worker ../weed-worker-${{ matrix.arch }} + - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: rust-volume-${{ matrix.arch }} + name: rust-bins-${{ matrix.arch }} path: | weed-volume-large-disk-${{ matrix.arch }} weed-volume-normal-${{ matrix.arch }} + weed-worker-${{ matrix.arch }} build: needs: [setup, build-rust-binaries] runs-on: ubuntu-latest strategy: matrix: - platform: [amd64, arm64, arm, 386] + platform: [amd64, arm64, arm, 386, ppc64le, s390x] variant: ${{ fromJSON(needs.setup.outputs.variants) }} steps: @@ -144,10 +170,13 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.source_ref || github.ref }} + - name: Clean up stale FUSE mounts + uses: ./.github/actions/cleanup-stale-fuse-mounts + - name: Free Disk Space run: | echo "Available disk space before cleanup:" - df -h + timeout 15 df -h || true # Remove pre-installed tools sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL # Clean package managers @@ -159,7 +188,7 @@ jobs: [ -d ~/.cache/go-build ] && rm -rf ~/.cache/go-build || true [ -d /go/pkg ] && rm -rf /go/pkg || true echo "Available disk space after cleanup:" - df -h + timeout 15 df -h || true - name: Configure variant id: config @@ -177,7 +206,7 @@ jobs: - name: Download pre-built Rust binaries uses: actions/download-artifact@v8 with: - pattern: rust-volume-* + pattern: rust-bins-* merge-multiple: true path: ./rust-bins @@ -191,7 +220,16 @@ jobs: echo "Placed pre-built Rust binary for ${arch}" fi done + mkdir -p docker/weed-worker-prebuilt + for arch in amd64 arm64; do + src="./rust-bins/weed-worker-${arch}" + if [ -f "$src" ]; then + cp "$src" "docker/weed-worker-prebuilt/weed-worker-${arch}" + echo "Placed pre-built Rust worker for ${arch}" + fi + done ls -la docker/weed-volume-prebuilt/ + ls -la docker/weed-worker-prebuilt/ - name: Docker meta id: docker_meta @@ -207,7 +245,7 @@ jobs: org.opencontainers.image.vendor=Chris Lu - name: Set up QEMU if: matrix.platform != 'amd64' - uses: docker/setup-qemu-action@v4.2.0 + uses: docker/setup-qemu-action@v4.4.0 - name: Create BuildKit config run: | cat > /tmp/buildkitd.toml <> "$GITHUB_OUTPUT" # Copy the complete multi-arch image from GHCR to Docker Hub # This only requires one pull from GHCR (no rate limit) and one push to Docker Hub @@ -494,16 +555,25 @@ jobs: # Use crane or skopeo to copy, fallback to docker if not available if command -v crane &> /dev/null; then echo "Using crane to copy..." - retry_with_backoff crane copy ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX} chrislusf/seaweedfs:${BASE_TAG}${SUFFIX} + retry_with_backoff crane copy ghcr.io/chrislusf/seaweedfs@${DIGEST} chrislusf/seaweedfs:${BASE_TAG}${SUFFIX} elif command -v skopeo &> /dev/null; then echo "Using skopeo to copy..." - retry_with_backoff skopeo copy --all docker://ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX} docker://chrislusf/seaweedfs:${BASE_TAG}${SUFFIX} + retry_with_backoff skopeo copy --all docker://ghcr.io/chrislusf/seaweedfs@${DIGEST} docker://chrislusf/seaweedfs:${BASE_TAG}${SUFFIX} else - echo "Using docker buildx imagetools (pulling 4 images from Docker Hub)..." + echo "Using docker buildx imagetools (pulling 6 images from Docker Hub)..." # Fallback: create manifest directly on Docker Hub (pulls from Docker Hub - rate limited) retry_with_backoff docker buildx imagetools create -t chrislusf/seaweedfs:${BASE_TAG}${SUFFIX} \ ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-amd64 \ ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-arm64 \ ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-arm \ - ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-386 + ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-386 \ + ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-ppc64le \ + ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-s390x fi + + - name: Sign + uses: ./.github/actions/sign-image + with: + images: >- + ghcr.io/chrislusf/seaweedfs@${{ steps.manifest.outputs.digest }} + chrislusf/seaweedfs@${{ steps.manifest.outputs.digest }} diff --git a/.github/workflows/container_release_foundationdb.yml b/.github/workflows/container_release_foundationdb.yml index ebb098aab0..2050d99604 100644 --- a/.github/workflows/container_release_foundationdb.yml +++ b/.github/workflows/container_release_foundationdb.yml @@ -11,6 +11,11 @@ on: required: true default: '' +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || format('refs/tags/{0}', github.event.inputs.release_tag) || github.run_id }} + cancel-in-progress: false + queue: max + permissions: contents: read @@ -21,6 +26,9 @@ jobs: build-large-release-container_foundationdb: runs-on: [ubuntu-latest] + permissions: + contents: read + id-token: write steps: - @@ -43,7 +51,7 @@ jobs: org.opencontainers.image.vendor=Chris Lu - name: Set up QEMU - uses: docker/setup-qemu-action@v4.2.0 + uses: docker/setup-qemu-action@v4.4.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -65,6 +73,7 @@ jobs: fi - name: Build + id: build uses: docker/build-push-action@v7 with: context: ./docker @@ -76,4 +85,10 @@ jobs: platforms: linux/amd64 tags: ${{ steps.docker_meta.outputs.tags }} labels: ${{ steps.docker_meta.outputs.labels }} + - + name: Sign + if: github.event_name != 'pull_request' + uses: ./.github/actions/sign-image + with: + images: chrislusf/seaweedfs@${{ steps.build.outputs.digest }} diff --git a/.github/workflows/container_release_unified.yml b/.github/workflows/container_release_unified.yml index 31cba7e6d5..17d976509e 100644 --- a/.github/workflows/container_release_unified.yml +++ b/.github/workflows/container_release_unified.yml @@ -25,7 +25,7 @@ on: rocksdb_version: description: 'RocksDB git tag to use when variant=rocksdb' required: false - default: 'v10.10.1' + default: 'v11.1.2' permissions: contents: read @@ -42,9 +42,10 @@ concurrency: jobs: - # ── Pre-build Rust volume server binaries natively ────────────────── - # Cross-compiles for amd64 and arm64 without QEMU, turning a 5-hour - # emulated cargo build into ~15 minutes of native compilation. + # ── Pre-build the Rust binaries natively ──────────────────────────── + # The volume server and the Rust maintenance worker, cross-compiled for + # amd64 and arm64 without QEMU, turning a 5-hour emulated cargo build into + # ~15 minutes of native compilation. build-rust-binaries: runs-on: ubuntu-22.04 strategy: @@ -59,9 +60,6 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Install protobuf compiler - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -80,16 +78,33 @@ jobs: echo "CFLAGS_aarch64_unknown_linux_musl=-U_FORTIFY_SOURCE" >> "$GITHUB_ENV" - name: Cache cargo registry and target + if: runner.environment == 'github-hosted' uses: actions/cache@v6 with: path: | ~/.cargo/registry ~/.cargo/git seaweed-volume/target - key: rust-docker-${{ matrix.target }}-${{ hashFiles('seaweed-volume/Cargo.lock') }} + seaweed-worker/target/${{ matrix.target }}/release + key: rust-docker-${{ matrix.target }}-${{ hashFiles('seaweed-volume/Cargo.lock', 'seaweed-worker/Cargo.lock') }} restore-keys: | rust-docker-${{ matrix.target }}- + # lance's build scripts compile their own protos and look for a protoc. + # Point them at the one protoc-bin-vendored ships, which seaweed-worker's + # own build already uses, so no job depends on a system package and every + # build sees the same version. + - name: Use the vendored protoc + run: | + cd seaweed-worker + cargo fetch + # The version from the lock, not whatever else a restored cache holds. + version=$(awk '/^name = "protoc-bin-vendored-linux-x86_64"$/{found=1; next} found && /^version = /{gsub(/"/,"",$3); print $3; exit}' Cargo.lock) + test -n "$version" || { echo "protoc-bin-vendored-linux-x86_64 is not in Cargo.lock" >&2; exit 1; } + protoc=$(find ~/.cargo/registry/src -path "*protoc-bin-vendored-linux-x86_64-$version/bin/protoc" | head -1) + test -x "$protoc" || { echo "no vendored protoc $version in the registry" >&2; exit 1; } + echo "PROTOC=$protoc" >> "$GITHUB_ENV" + - name: Build large-disk variant env: SEAWEEDFS_COMMIT: ${{ github.sha }} @@ -106,13 +121,20 @@ jobs: cargo build --release --target ${{ matrix.target }} --no-default-features cp target/${{ matrix.target }}/release/weed-volume ../weed-volume-normal-${{ matrix.arch }} + - name: Build the Rust maintenance worker + run: | + cd seaweed-worker + cargo build --release -p weed-lance-worker --target ${{ matrix.target }} + cp target/${{ matrix.target }}/release/weed-worker ../weed-worker-${{ matrix.arch }} + - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: rust-volume-${{ matrix.arch }} + name: rust-bins-${{ matrix.arch }} path: | weed-volume-large-disk-${{ matrix.arch }} weed-volume-normal-${{ matrix.arch }} + weed-worker-${{ matrix.arch }} # One job per (variant, platform) on a native runner, pushed by digest; # the merge job stitches the digests into one multi-arch tag. @@ -128,12 +150,16 @@ jobs: - { variant: normal, tag_suffix: "", dockerfile: ./docker/Dockerfile.go_build, build_args: "", rust_variant: normal, platform: linux/arm64, arch: arm64, runner: ubuntu-24.04-arm, qemu: false } - { variant: normal, tag_suffix: "", dockerfile: ./docker/Dockerfile.go_build, build_args: "", rust_variant: normal, platform: linux/arm/v7, arch: armv7, runner: ubuntu-latest, qemu: true } - { variant: normal, tag_suffix: "", dockerfile: ./docker/Dockerfile.go_build, build_args: "", rust_variant: normal, platform: linux/386, arch: i386, runner: ubuntu-latest, qemu: false } + - { variant: normal, tag_suffix: "", dockerfile: ./docker/Dockerfile.go_build, build_args: "", rust_variant: normal, platform: linux/ppc64le, arch: ppc64le, runner: ubuntu-latest, qemu: true } + - { variant: normal, tag_suffix: "", dockerfile: ./docker/Dockerfile.go_build, build_args: "", rust_variant: normal, platform: linux/s390x, arch: s390x, runner: ubuntu-latest, qemu: true } # Large disk - multi-arch - { variant: large_disk, tag_suffix: _large_disk, dockerfile: ./docker/Dockerfile.go_build, build_args: TAGS=5BytesOffset, rust_variant: large-disk, platform: linux/amd64, arch: amd64, runner: ubuntu-latest, qemu: false } - { variant: large_disk, tag_suffix: _large_disk, dockerfile: ./docker/Dockerfile.go_build, build_args: TAGS=5BytesOffset, rust_variant: large-disk, platform: linux/arm64, arch: arm64, runner: ubuntu-24.04-arm, qemu: false } - { variant: large_disk, tag_suffix: _large_disk, dockerfile: ./docker/Dockerfile.go_build, build_args: TAGS=5BytesOffset, rust_variant: large-disk, platform: linux/arm/v7, arch: armv7, runner: ubuntu-latest, qemu: true } - { variant: large_disk, tag_suffix: _large_disk, dockerfile: ./docker/Dockerfile.go_build, build_args: TAGS=5BytesOffset, rust_variant: large-disk, platform: linux/386, arch: i386, runner: ubuntu-latest, qemu: false } + - { variant: large_disk, tag_suffix: _large_disk, dockerfile: ./docker/Dockerfile.go_build, build_args: TAGS=5BytesOffset, rust_variant: large-disk, platform: linux/ppc64le, arch: ppc64le, runner: ubuntu-latest, qemu: true } + - { variant: large_disk, tag_suffix: _large_disk, dockerfile: ./docker/Dockerfile.go_build, build_args: TAGS=5BytesOffset, rust_variant: large-disk, platform: linux/s390x, arch: s390x, runner: ubuntu-latest, qemu: true } # Full tags - multi-arch - { variant: full, tag_suffix: _full, dockerfile: ./docker/Dockerfile.go_build, build_args: "TAGS=elastic,gocdk,rclone,sqlite,tarantool,tikv,ydb", rust_variant: normal, platform: linux/amd64, arch: amd64, runner: ubuntu-latest, qemu: false } @@ -154,11 +180,15 @@ jobs: if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant uses: actions/checkout@v7 + - name: Clean up stale FUSE mounts + if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant + uses: ./.github/actions/cleanup-stale-fuse-mounts + - name: Download pre-built Rust binaries if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant uses: actions/download-artifact@v8 with: - pattern: rust-volume-* + pattern: rust-bins-* merge-multiple: true path: ./rust-bins @@ -173,7 +203,16 @@ jobs: echo "Placed pre-built Rust binary for ${arch}" fi done + mkdir -p docker/weed-worker-prebuilt + for arch in amd64 arm64; do + src="./rust-bins/weed-worker-${arch}" + if [ -f "$src" ]; then + cp "$src" "docker/weed-worker-prebuilt/weed-worker-${arch}" + echo "Placed pre-built Rust worker for ${arch}" + fi + done ls -la docker/weed-volume-prebuilt/ + ls -la docker/weed-worker-prebuilt/ - name: Free Disk Space if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant @@ -184,7 +223,7 @@ jobs: sudo docker system prune -af --volumes [ -d ~/.cache/go-build ] && rm -rf ~/.cache/go-build || true [ -d /go/pkg ] && rm -rf /go/pkg || true - df -h + timeout 15 df -h || true - name: Docker meta if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant @@ -201,7 +240,7 @@ jobs: - name: Set up QEMU if: (github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant) && matrix.qemu - uses: docker/setup-qemu-action@v4.2.0 + uses: docker/setup-qemu-action@v4.4.0 - name: Create BuildKit config if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant @@ -236,8 +275,11 @@ jobs: file: ${{ matrix.dockerfile }} platforms: ${{ matrix.platform }} labels: ${{ steps.docker_meta.outputs.labels }} - # Flat single-platform manifest so imagetools create assembles cleanly. - provenance: false + # mode=max keeps resolvedDependencies complete and records the + # builder identity; attestations ride inside each pushed index and + # survive the imagetools merge into the multi-arch manifest. + provenance: mode=max + sbom: true outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.variant }}-${{ matrix.arch }} # max only for rocksdb: its RocksDB compile is sha-independent and worth @@ -247,7 +289,7 @@ jobs: ${{ matrix.build_args }} BUILDKIT_INLINE_CACHE=1 BRANCH=${{ github.sha }} - ${{ matrix.variant == 'rocksdb' && format('ROCKSDB_VERSION={0}', github.event.inputs.rocksdb_version || 'v10.10.1') || '' }} + ${{ matrix.variant == 'rocksdb' && format('ROCKSDB_VERSION={0}', github.event.inputs.rocksdb_version || 'v11.1.2') || '' }} - name: Export digest if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant @@ -265,10 +307,16 @@ jobs: if-no-files-found: error retention-days: 1 - # Assemble each variant's per-platform digests into one tag, then mirror to Docker Hub. + # Assemble each variant's per-platform digests into one tag, mirror it to + # Docker Hub, and sign the result on both registries. merge: needs: [build] runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + packages: write strategy: fail-fast: false matrix: @@ -279,6 +327,13 @@ jobs: - { variant: large_disk_full, tag_suffix: _large_disk_full } - { variant: rocksdb, tag_suffix: _large_disk_rocksdb } steps: + - name: Checkout the signing action + if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant + uses: actions/checkout@v7 + with: + sparse-checkout: .github/actions + persist-credentials: false + - name: Download digests if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant uses: actions/download-artifact@v8 @@ -301,12 +356,17 @@ jobs: - name: Create multi-arch tag on GHCR if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant + id: manifest working-directory: /tmp/digests run: | docker buildx imagetools create \ -t ${{ env.IMAGE }}:${{ env.RELEASE_TAG }}${{ matrix.tag_suffix }} \ + --metadata-file /tmp/manifest.json \ $(printf '${{ env.IMAGE }}@sha256:%s ' *) docker buildx imagetools inspect ${{ env.IMAGE }}:${{ env.RELEASE_TAG }}${{ matrix.tag_suffix }} + # The copy and the signature below use the digest this run pushed, not whatever the tag points at by then. + digest=$(jq -er '."containerimage.descriptor".digest' /tmp/manifest.json) + echo "digest=${digest}" >> "$GITHUB_OUTPUT" - name: Login to Docker Hub if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant @@ -319,7 +379,9 @@ jobs: if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant run: | cd $(mktemp -d) - curl -sL "https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Linux_x86_64.tar.gz" | tar xz + curl -sLO https://github.com/google/go-containerregistry/releases/download/v0.22.0/go-containerregistry_Linux_x86_64.tar.gz + echo "edb74d53fad9a596860f59d1c5d04a43dfb5f441dc71f57060dd0bf39483c833 go-containerregistry_Linux_x86_64.tar.gz" | sha256sum -c - + tar xzf go-containerregistry_Linux_x86_64.tar.gz crane sudo mv crane /usr/local/bin/ crane version @@ -350,10 +412,57 @@ jobs: echo "Copying ${{ matrix.variant }} from GHCR to Docker Hub..." retry_with_backoff crane copy \ - ${{ env.IMAGE }}:${{ env.RELEASE_TAG }}${{ matrix.tag_suffix }} \ + ${{ env.IMAGE }}@${{ steps.manifest.outputs.digest }} \ chrislusf/seaweedfs:${{ env.RELEASE_TAG }}${{ matrix.tag_suffix }} echo "Copied ${{ matrix.variant }} to Docker Hub" + - name: Sign ${{ matrix.variant }} + if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant + uses: ./.github/actions/sign-image + with: + images: >- + ${{ env.IMAGE }}@${{ steps.manifest.outputs.digest }} + chrislusf/seaweedfs@${{ steps.manifest.outputs.digest }} + + # Attest the merged multi-arch digest itself, so digest-pinning consumers + # can verify provenance against the exact index they pull. Both steps + # reuse the GHCR login above and push as OCI referrers. + - name: Attest build provenance for ${{ matrix.variant }} + if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant + uses: actions/attest-build-provenance@v4 + with: + subject-name: ${{ env.IMAGE }} + subject-digest: ${{ steps.manifest.outputs.digest }} + push-to-registry: true + + # SBOMs are generated and attested per platform manifest: an index-wide + # scan only describes syft's default platform, while the manifests it + # merges differ (some ship the real binary, others placeholders), so the + # SBOM has to be bound to the platform digest a consumer actually pulls. + - name: Install syft + if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant + uses: anchore/sbom-action/download-syft@v0 + + - name: Attest per-platform SBOMs for ${{ matrix.variant }} + if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant + working-directory: /tmp/digests + run: | + set -euo pipefail + for f in *; do + # Each saved digest is an OCI index (image + attestation manifests); + # scan and attest the image manifest a same-platform pull resolves. + ref="${{ env.IMAGE }}@sha256:${f}" + image=$(crane manifest "$ref" | jq -r ' + [.manifests[]? | select((.annotations["vnd.docker.reference.type"] // "") != "attestation-manifest" + and (.platform.os // "unknown") != "unknown") | .digest][0] // empty') + ref="${{ env.IMAGE }}@${image:-sha256:${f}}" + syft "registry:${ref}" -o "spdx-json=/tmp/sbom-${f}.spdx.json" + cosign attest --yes \ + --predicate "/tmp/sbom-${f}.spdx.json" \ + --type spdxjson \ + "$ref" + done + # Report-only trivy scan: uploads fixable HIGH/CRITICAL findings to GitHub # Security for visibility, but never blocks the release. Releases (including # `latest`) ship regardless — vulnerabilities are tracked, not gated, since @@ -392,7 +501,9 @@ jobs: scanners: vuln vuln-type: os,library severity: HIGH,CRITICAL - ignore-unfixed: true + # Keep unfixed findings in the report so digest-pinning consumers see + # the complete picture, not just what a patch could resolve. + ignore-unfixed: false limit-severities-for-sarif: true format: sarif output: trivy-results.sarif @@ -400,7 +511,7 @@ jobs: - name: Upload Trivy scan results to GitHub Security if: always() - uses: github/codeql-action/upload-sarif@v4.37.4 + uses: github/codeql-action/upload-sarif@v4.39.0 with: sarif_file: trivy-results.sarif category: trivy-${{ matrix.variant }} @@ -409,7 +520,8 @@ jobs: # image. crane tag adds an extra tag to an existing manifest — no rebuild, # no QEMU, no separate workflow. Replaces the old container_latest.yml # rebuild that often failed or lagged behind the release. Independent of - # trivy-scan: vuln findings are reported but do not block `latest`. + # trivy-scan: vuln findings are reported but do not block `latest`. The + # cosign signature is attached to the digest, so `latest` carries it too. tag-latest: runs-on: ubuntu-latest needs: [merge] @@ -438,7 +550,9 @@ jobs: - name: Install crane run: | cd $(mktemp -d) - curl -sL "https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Linux_x86_64.tar.gz" | tar xz + curl -sLO https://github.com/google/go-containerregistry/releases/download/v0.22.0/go-containerregistry_Linux_x86_64.tar.gz + echo "edb74d53fad9a596860f59d1c5d04a43dfb5f441dc71f57060dd0bf39483c833 go-containerregistry_Linux_x86_64.tar.gz" | sha256sum -c - + tar xzf go-containerregistry_Linux_x86_64.tar.gz crane sudo mv crane /usr/local/bin/ crane version diff --git a/.github/workflows/container_rocksdb_version.yml b/.github/workflows/container_rocksdb_version.yml index 0ab6eeb3e9..0d3b5f2b8f 100644 --- a/.github/workflows/container_rocksdb_version.yml +++ b/.github/workflows/container_rocksdb_version.yml @@ -4,9 +4,9 @@ on: workflow_dispatch: inputs: rocksdb_version: - description: 'RocksDB git tag or branch to build (e.g. v10.10.1)' + description: 'RocksDB git tag or branch to build (e.g. v11.1.2)' required: true - default: 'v10.10.1' + default: 'v11.1.2' seaweedfs_ref: description: 'SeaweedFS git tag, branch, or commit to build' required: true @@ -16,12 +16,20 @@ on: required: false default: '' +concurrency: + group: ${{ github.workflow }}-${{ github.event.inputs.image_tag || format('rocksdb_{0}_seaweedfs_{1}', github.event.inputs.rocksdb_version, github.event.inputs.seaweedfs_ref) }} + cancel-in-progress: false + queue: max + permissions: contents: read jobs: build-rocksdb-image: runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: Checkout @@ -82,7 +90,7 @@ jobs: echo "seaweedfs_ref=$seaweed" >> "$GITHUB_OUTPUT" - name: Set up QEMU - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v1 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v1 @@ -94,6 +102,7 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push image + id: build uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v2 with: context: ./docker @@ -108,3 +117,8 @@ jobs: org.opencontainers.image.title=seaweedfs org.opencontainers.image.description=SeaweedFS is a distributed storage system for blobs, objects, files, and data lake, to store and serve billions of files fast! org.opencontainers.image.vendor=Chris Lu + + - name: Sign + uses: ./.github/actions/sign-image + with: + images: chrislusf/seaweedfs@${{ steps.build.outputs.digest }} diff --git a/.github/workflows/deploy_telemetry.yml b/.github/workflows/deploy_telemetry.yml index 2c67e51f8d..2f45c64176 100644 --- a/.github/workflows/deploy_telemetry.yml +++ b/.github/workflows/deploy_telemetry.yml @@ -27,6 +27,7 @@ jobs: uses: actions/setup-go@v7 with: go-version-file: 'telemetry/server/go.mod' + cache: ${{ runner.environment == 'github-hosted' }} - name: Build Telemetry Server if: github.event_name == 'workflow_dispatch' && inputs.deploy @@ -35,11 +36,48 @@ jobs: cd telemetry/server go mod tidy echo "Building telemetry server..." - GOOS=linux GOARCH=amd64 go build -o ../../telemetry-server . + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ../../telemetry-server . cd ../.. ls -la telemetry-server echo "Build completed successfully" + - name: Generate Service Configuration + if: github.event_name == 'workflow_dispatch' && (inputs.setup || inputs.deploy) + env: + REMOTE_USER: ${{ secrets.TELEMETRY_USER }} + run: | + # Create systemd service file + echo " + [Unit] + Description=SeaweedFS Telemetry Server + After=network.target + + [Service] + Type=simple + User=$REMOTE_USER + WorkingDirectory=/home/$REMOTE_USER/seaweedfs-telemetry + ExecStart=/bin/sh -c 'exec /home/$REMOTE_USER/seaweedfs-telemetry/bin/telemetry-server -port=8353 >>/home/$REMOTE_USER/seaweedfs-telemetry/logs/telemetry.log 2>>/home/$REMOTE_USER/seaweedfs-telemetry/logs/telemetry.error.log' + Restart=always + RestartSec=5 + + [Install] + WantedBy=multi-user.target" > telemetry.service + + # Setup logrotate configuration + echo "# SeaweedFS Telemetry service log rotation + /home/$REMOTE_USER/seaweedfs-telemetry/logs/*.log { + daily + rotate 30 + compress + delaycompress + missingok + notifempty + create 644 $REMOTE_USER $REMOTE_USER + postrotate + systemctl restart telemetry.service + endscript + }" > telemetry_logrotate + - name: First-time Server Setup if: github.event_name == 'workflow_dispatch' && inputs.setup env: @@ -61,40 +99,6 @@ jobs: touch ~/seaweedfs-telemetry/logs/telemetry.log ~/seaweedfs-telemetry/logs/telemetry.error.log && \ chmod 644 ~/seaweedfs-telemetry/logs/*.log" - # Create systemd service file - echo " - [Unit] - Description=SeaweedFS Telemetry Server - After=network.target - - [Service] - Type=simple - User=$REMOTE_USER - WorkingDirectory=/home/$REMOTE_USER/seaweedfs-telemetry - ExecStart=/home/$REMOTE_USER/seaweedfs-telemetry/bin/telemetry-server -port=8353 - Restart=always - RestartSec=5 - StandardOutput=append:/home/$REMOTE_USER/seaweedfs-telemetry/logs/telemetry.log - StandardError=append:/home/$REMOTE_USER/seaweedfs-telemetry/logs/telemetry.error.log - - [Install] - WantedBy=multi-user.target" > telemetry.service - - # Setup logrotate configuration - echo "# SeaweedFS Telemetry service log rotation - /home/$REMOTE_USER/seaweedfs-telemetry/logs/*.log { - daily - rotate 30 - compress - delaycompress - missingok - notifempty - create 644 $REMOTE_USER $REMOTE_USER - postrotate - systemctl restart telemetry.service - endscript - }" > telemetry_logrotate - # Copy configuration files scp -i ~/.ssh/deploy_key telemetry/grafana-dashboard.json $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/ scp -i ~/.ssh/deploy_key telemetry/prometheus.yml $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/ @@ -137,11 +141,18 @@ jobs: scp -i ~/.ssh/deploy_key telemetry/grafana-dashboard.json $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/ scp -i ~/.ssh/deploy_key telemetry/prometheus.yml $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/ + # Copy updated service and logrotate files + scp -i ~/.ssh/deploy_key telemetry.service telemetry_logrotate $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/ + # Check if service exists and deploy accordingly ssh -i ~/.ssh/deploy_key $REMOTE_USER@$REMOTE_HOST " if systemctl list-unit-files telemetry.service >/dev/null 2>&1; then echo 'Service exists, performing update...' + set -e sudo systemctl stop telemetry.service + sudo mv ~/seaweedfs-telemetry/telemetry.service /etc/systemd/system/ + sudo mv ~/seaweedfs-telemetry/telemetry_logrotate /etc/logrotate.d/seaweedfs-telemetry + sudo systemctl daemon-reload mkdir -p ~/seaweedfs-telemetry/bin mv ~/seaweedfs-telemetry/tmp/telemetry-server ~/seaweedfs-telemetry/bin/ chmod +x ~/seaweedfs-telemetry/bin/telemetry-server diff --git a/.github/workflows/depsreview.yml b/.github/workflows/depsreview.yml index e4338c240a..0ba5dbcd2c 100644 --- a/.github/workflows/depsreview.yml +++ b/.github/workflows/depsreview.yml @@ -1,6 +1,10 @@ name: 'Dependency Review' on: [pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} + permissions: contents: read diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a0aa5c7965..363627df27 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -19,8 +19,8 @@ on: - '.github/workflows/e2e.yml' concurrency: - group: ${{ github.head_ref }}/e2e - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} permissions: contents: read @@ -42,6 +42,7 @@ jobs: uses: actions/setup-go@v7 with: go-version-file: 'go.mod' + cache: ${{ runner.environment == 'github-hosted' }} - name: Configure Docker Hub mirror run: | @@ -52,6 +53,7 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Cache Docker layers + if: runner.environment == 'github-hosted' uses: actions/cache@v6 with: path: /tmp/.buildx-cache @@ -61,13 +63,10 @@ jobs: - name: Install dependencies run: | - # Use faster mirrors and install with timeout sudo rm -f /etc/apt/sources.list.d/azure-cli.list /etc/apt/sources.list.d/microsoft-prod.list - echo "deb http://azure.archive.ubuntu.com/ubuntu/ $(lsb_release -cs) main restricted universe multiverse" | sudo tee /etc/apt/sources.list - echo "deb http://azure.archive.ubuntu.com/ubuntu/ $(lsb_release -cs)-updates main restricted universe multiverse" | sudo tee -a /etc/apt/sources.list - - sudo apt-get update --fix-missing - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fuse + # Same helper the e2e image installs through: the runner's own list is + # azure-only too, and an outage there fails this step outright. + sudo ./apt-install fuse # Verify FUSE installation echo "FUSE version: $(fusermount --version 2>&1 || echo 'fusermount not found')" diff --git a/.github/workflows/ec-integration-tests.yml b/.github/workflows/ec-integration-tests.yml index d510d63b8b..a1ddfa8e65 100644 --- a/.github/workflows/ec-integration-tests.yml +++ b/.github/workflows/ec-integration-tests.yml @@ -18,6 +18,10 @@ on: - 'go.sum' - '.github/workflows/ec-integration-tests.yml' +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} + permissions: contents: read @@ -30,7 +34,8 @@ jobs: - name: Set up Go 1.x uses: actions/setup-go@v7 with: - go-version: ^1.25 + cache: ${{ runner.environment == 'github-hosted' }} + go-version: ^1.26 id: go - name: Check out code into the Go module directory @@ -43,7 +48,10 @@ jobs: - name: Run EC Integration Tests working-directory: test/erasure_coding run: | - go test -v + # The suite now includes the interruption matrix and runs close to Go's + # default 10m binary timeout on slower runners; bound it by the job's + # 30m budget instead. + go test -v -timeout 25m - name: Collect server logs on failure if: failure() diff --git a/.github/workflows/fuse-dlm-integration.yml b/.github/workflows/fuse-dlm-integration.yml index 6ce3f0724c..869eae40ad 100644 --- a/.github/workflows/fuse-dlm-integration.yml +++ b/.github/workflows/fuse-dlm-integration.yml @@ -19,8 +19,8 @@ on: - '.github/actions/fix-fusermount-setuid/**' concurrency: - group: ${{ github.head_ref || github.ref }}/fuse-dlm-integration - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} permissions: contents: read @@ -39,11 +39,13 @@ jobs: uses: actions/setup-go@v7 with: go-version-file: 'go.mod' + cache: ${{ runner.environment == 'github-hosted' }} - - name: Install FUSE dependencies + - name: Configure FUSE run: | - sudo apt-get update - sudo apt-get install -y libfuse3-dev + # Nothing to install: fuse3 ships fusermount3 and is pre-installed, + # and go-fuse is pure Go, so the libfuse headers were never linked + # against. echo 'user_allow_other' | sudo tee -a /etc/fuse.conf sudo chmod 644 /etc/fuse.conf diff --git a/.github/workflows/fuse-failover.yml b/.github/workflows/fuse-failover.yml new file mode 100644 index 0000000000..2c03cf9369 --- /dev/null +++ b/.github/workflows/fuse-failover.yml @@ -0,0 +1,80 @@ +name: "FUSE Volume Server Failover Tests" + +on: + pull_request: + paths: + - 'weed/command/mount*.go' + - 'weed/mount/**' + - 'weed/filer/**' + - 'weed/wdclient/**' + - 'weed/operation/upload_content.go' + - 'test/fuse_failover/**' + - '.github/workflows/fuse-failover.yml' + - '.github/actions/fix-fusermount-setuid/**' + push: + branches: [master] + paths: + - 'weed/command/mount*.go' + - 'weed/mount/**' + - 'weed/filer/**' + - 'weed/wdclient/**' + - 'weed/operation/upload_content.go' + - 'test/fuse_failover/**' + - '.github/workflows/fuse-failover.yml' + - '.github/actions/fix-fusermount-setuid/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} + +permissions: + contents: read + +jobs: + fuse-failover: + name: FUSE Volume Server Failover + runs-on: ubuntu-22.04 + timeout-minutes: 40 + + steps: + - name: Check out code + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Clean up stale FUSE mounts + uses: ./.github/actions/cleanup-stale-fuse-mounts + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: 'go.mod' + cache: ${{ runner.environment == 'github-hosted' }} + + - name: Configure FUSE + run: | + # Nothing to install: fuse3 ships fusermount3 and is pre-installed, + # and go-fuse is pure Go, so the libfuse headers were never linked + # against. + echo 'user_allow_other' | sudo tee -a /etc/fuse.conf + sudo chmod 644 /etc/fuse.conf + + - name: Repair the fusermount3 setuid bit + uses: ./.github/actions/fix-fusermount-setuid + + - name: Build SeaweedFS + run: go build -o weed/weed -buildvcs=false ./weed + + - name: Run failover integration tests + timeout-minutes: 35 + env: + WEED_BINARY: ${{ github.workspace }}/weed/weed + run: go test -v -count=1 -timeout=30m ./test/fuse_failover/... + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: fuse-failover-test-logs + path: /tmp/seaweedfs-fuse-failover-logs/ + retention-days: 3 diff --git a/.github/workflows/fuse-integration.yml b/.github/workflows/fuse-integration.yml index 72bb23ad57..f45e3ed0a1 100644 --- a/.github/workflows/fuse-integration.yml +++ b/.github/workflows/fuse-integration.yml @@ -17,8 +17,8 @@ on: - '.github/actions/fix-fusermount-setuid/**' concurrency: - group: ${{ github.head_ref }}/fuse-integration - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} permissions: contents: read @@ -37,13 +37,12 @@ jobs: uses: actions/setup-go@v7 with: go-version-file: 'go.mod' + cache: ${{ runner.environment == 'github-hosted' }} - - name: Install FUSE and dependencies + - name: Configure FUSE run: | - sudo apt-get update - # fuse3 is pre-installed on ubuntu-22.04 runners and conflicts - # with the legacy fuse package, so only install the dev headers. - sudo apt-get install -y libfuse3-dev + # Nothing to install: fuse3 ships fusermount3 and is pre-installed, and + # go-fuse is pure Go, so the libfuse headers were never linked against. # Allow non-root FUSE mounts with allow_other echo 'user_allow_other' | sudo tee -a /etc/fuse.conf sudo chmod 644 /etc/fuse.conf diff --git a/.github/workflows/fuse-p2p-integration.yml b/.github/workflows/fuse-p2p-integration.yml index 8cdc181470..a65f5ddbc2 100644 --- a/.github/workflows/fuse-p2p-integration.yml +++ b/.github/workflows/fuse-p2p-integration.yml @@ -25,8 +25,8 @@ on: - '.github/actions/fix-fusermount-setuid/**' concurrency: - group: ${{ github.head_ref || github.ref }}/fuse-p2p-integration - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} permissions: contents: read @@ -41,15 +41,20 @@ jobs: - name: Check out code uses: actions/checkout@v7 + - name: Clean up stale FUSE mounts + uses: ./.github/actions/cleanup-stale-fuse-mounts + - name: Set up Go uses: actions/setup-go@v7 with: go-version-file: 'go.mod' + cache: ${{ runner.environment == 'github-hosted' }} - - name: Install FUSE dependencies + - name: Configure FUSE run: | - sudo apt-get update - sudo apt-get install -y libfuse3-dev + # Nothing to install: fuse3 ships fusermount3 and is pre-installed, + # and go-fuse is pure Go, so the libfuse headers were never linked + # against. echo 'user_allow_other' | sudo tee -a /etc/fuse.conf sudo chmod 644 /etc/fuse.conf diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index a2c5364ce2..0a06fbf6be 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -17,8 +17,8 @@ on: - '.github/workflows/go.yml' concurrency: - group: ${{ github.head_ref }}/go - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} permissions: contents: read @@ -35,6 +35,7 @@ jobs: uses: actions/setup-go@v7 with: go-version-file: 'go.mod' + cache: ${{ runner.environment == 'github-hosted' }} - name: Get dependencies run: | cd weed; go get -v -t -d ./... @@ -57,6 +58,7 @@ jobs: uses: actions/setup-go@v7 with: go-version-file: 'go.mod' + cache: ${{ runner.environment == 'github-hosted' }} - name: Go Vet linux/386 (type-checks code and tests for 32-bit int overflows) run: | GOOS=linux GOARCH=386 go vet ./... 2>&1 | grep -v "MessageState contains sync.Mutex" | grep -v "IdentityAccessManagement contains sync.RWMutex" | tee vet-32bit-output.txt @@ -72,6 +74,7 @@ jobs: uses: actions/setup-go@v7 with: go-version-file: 'go.mod' + cache: ${{ runner.environment == 'github-hosted' }} - name: Build run: cd weed; go build -tags "elastic gocdk sqlite ydb tarantool tikv rclone" -v . @@ -94,6 +97,7 @@ jobs: uses: actions/setup-go@v7 with: go-version-file: 'go.mod' + cache: ${{ runner.environment == 'github-hosted' }} # Nothing else on a PR compiles these, so per-OS syscall constants creep # back into shared files unnoticed and only a release build catches it. - name: Cross-compile @@ -111,6 +115,16 @@ jobs: test: name: Test runs-on: ubuntu-latest + services: + redis: + image: redis:8 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: Check out code into the Go module directory uses: actions/checkout@v7 @@ -118,7 +132,10 @@ jobs: uses: actions/setup-go@v7 with: go-version-file: 'go.mod' + cache: ${{ runner.environment == 'github-hosted' }} - name: Test + env: + RUN_REDIS_TESTS: "1" run: cd weed; go test -tags "elastic gocdk sqlite ydb tarantool tikv rclone" -v ./... test-32bit: @@ -131,8 +148,28 @@ jobs: uses: actions/setup-go@v7 with: go-version-file: 'go.mod' + cache: ${{ runner.environment == 'github-hosted' }} # 386 test binaries run natively on the amd64 runner. This catches what vet # can't: unaligned 64-bit atomics and arithmetic that wraps at runtime. # -short skips the e2e suites already covered on amd64. - name: Test linux/386 run: cd weed; GOOS=linux GOARCH=386 go test -short ./... + + build-azurekms: + name: Build and test with azurekms tag + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v7 + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: 'go.mod' + cache: ${{ runner.environment == 'github-hosted' }} + # weed/kms/azure is excluded from every default build, so without this + # nothing compiles it and tag-gated code silently rots. + - name: Build and test with azurekms + run: | + cd weed + go build -tags azurekms ./... + go test -tags azurekms ./kms/... diff --git a/.github/workflows/helm_ci.yml b/.github/workflows/helm_ci.yml index 554f85fbc4..0a75617d79 100644 --- a/.github/workflows/helm_ci.yml +++ b/.github/workflows/helm_ci.yml @@ -3,10 +3,14 @@ name: "helm: lint and test charts" on: push: branches: [ master ] - paths: ['k8s/**'] + paths: ['k8s/**', '.github/workflows/helm_ci.yml', '.github/scripts/helm_jwt_keys.py'] pull_request: branches: [ master ] - paths: ['k8s/**'] + paths: ['k8s/**', '.github/workflows/helm_ci.yml', '.github/scripts/helm_jwt_keys.py'] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} permissions: contents: read @@ -20,6 +24,14 @@ jobs: with: fetch-depth: 0 + - name: Set up Helm before fromToml was available + uses: azure/setup-helm@v5 + with: + version: v3.16.3 + + - name: Record legacy Helm binary + run: echo "HELM_LEGACY=$(command -v helm)" >> "$GITHUB_ENV" + - name: Set up Helm uses: azure/setup-helm@v5 with: @@ -31,7 +43,7 @@ jobs: check-latest: true - name: Set up chart-testing - uses: helm/chart-testing-action@v2.8.0 + uses: helm/chart-testing-action@v2.9.0 - name: Run chart-testing (list-changed) id: list-changed @@ -44,6 +56,17 @@ jobs: - 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 legacy Helm rendering + run: | + "$HELM_LEGACY" lint k8s/charts/seaweedfs + "$HELM_LEGACY" template test k8s/charts/seaweedfs > "$RUNNER_TEMP/legacy-default.yaml" + "$HELM_LEGACY" template test k8s/charts/seaweedfs \ + --set global.seaweedfs.securityConfig.jwtSigning.volumeRead=true \ + --set global.seaweedfs.securityConfig.jwtSigning.filerWrite=true \ + --set global.seaweedfs.securityConfig.jwtSigning.filerRead=true \ + > "$RUNNER_TEMP/legacy-jwt.yaml" + + - name: Verify template rendering run: | set -e @@ -57,17 +80,164 @@ jobs: 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 rollout settings ===" + helm template test $CHART_DIR --show-only templates/s3/s3-deployment.yaml \ + --set s3.enabled=true > /tmp/s3-rollout-defaults.yaml + grep -q "terminationGracePeriodSeconds: 10$" /tmp/s3-rollout-defaults.yaml + test "$(grep -cE '^ strategy:|^ +lifecycle:' /tmp/s3-rollout-defaults.yaml)" -eq 0 + helm template test $CHART_DIR --show-only templates/s3/s3-deployment.yaml \ + --set s3.enabled=true \ + --set s3.terminationGracePeriodSeconds=30 \ + --set s3.updateStrategy.type=RollingUpdate \ + --set s3.updateStrategy.rollingUpdate.maxUnavailable=0 \ + --set-string 's3.lifecycle.preStop.exec.command={sleep,5}' \ + > /tmp/s3-rollout.yaml + grep -q "terminationGracePeriodSeconds: 30$" /tmp/s3-rollout.yaml + grep -A 4 "^ strategy:" /tmp/s3-rollout.yaml | grep -q "maxUnavailable: 0" + grep -A 4 "^ strategy:" /tmp/s3-rollout.yaml | grep -q "type: RollingUpdate" + grep -A 5 "^ lifecycle:" /tmp/s3-rollout.yaml | grep -q -- "- sleep" + echo "S3 rollout settings render correctly" + + echo "=== Testing the S3 config checksum is stable across renders ===" + # s3-secret.yaml keeps its generated keys with lookup. Hashing the rendered + # Secret drew new keys for the checksum whenever lookup found nothing (on + # install), so the first upgrade changed the checksum and restarted the + # filer and S3 pods although nothing had changed. + s3_checksum() { #