This is an automated email from the ASF dual-hosted git repository.

JackieTien97 pushed a commit to branch rc/2.0.11
in repository https://gitbox.apache.org/repos/asf/iotdb.git


The following commit(s) were added to refs/heads/rc/2.0.11 by this push:
     new 2cb4d74a04a Add daemon and cleanup tools for IoTDB Edge (#18552)
2cb4d74a04a is described below

commit 2cb4d74a04aa48bbbe6989d7f37f2c7033bf6692
Author: Jackie Tien <[email protected]>
AuthorDate: Mon Aug 31 19:11:15 2026 +0800

    Add daemon and cleanup tools for IoTDB Edge (#18552)
---
 .github/scripts/test-edge-ops.py            | 466 ++++++++++++++++++++++++++++
 .github/workflows/edge-it.yml               |   4 +
 distribution/src/assembly/all.xml           |   1 +
 distribution/src/assembly/datanode.xml      |   1 +
 distribution/src/assembly/edge.xml          |  11 +
 iotdb-core/datanode/src/assembly/server.xml |   1 +
 scripts/sbin/windows/stop-edge.bat          |   9 +-
 scripts/tools/ops/daemon-edge.sh            | 114 +++++++
 scripts/tools/ops/destroy-edge.sh           | 161 ++++++++++
 scripts/tools/windows/ops/destroy-edge.bat  |  96 ++++++
 10 files changed, 861 insertions(+), 3 deletions(-)

diff --git a/.github/scripts/test-edge-ops.py b/.github/scripts/test-edge-ops.py
new file mode 100644
index 00000000000..ef16fd04ca8
--- /dev/null
+++ b/.github/scripts/test-edge-ops.py
@@ -0,0 +1,466 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+"""Run Edge ops against disposable data and stubbed service/stop commands."""
+
+import fnmatch
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import unittest
+import xml.etree.ElementTree as ET
+
+REPOSITORY = Path(__file__).resolve().parents[2]
+WINDOWS = os.name == "nt"
+DIRECTORY_KEYS = (
+    "cn_system_dir",
+    "cn_consensus_dir",
+    "dn_system_dir",
+    "dn_data_dirs",
+    "dn_consensus_dir",
+    "dn_wal_dirs",
+    "dn_tracing_dir",
+    "dn_sync_dir",
+    "pipe_receiver_file_dirs",
+    "iot_consensus_v2_receiver_file_dirs",
+    "sort_tmp_dir",
+)
+
+
+class EdgeOpsTest(unittest.TestCase):
+    def setUp(self):
+        self.temporary = tempfile.TemporaryDirectory(prefix="iotdb-edge-ops-")
+        self.addCleanup(self.temporary.cleanup)
+        self.root = Path(self.temporary.name).resolve()
+        self.installation = self.root / "edge installation"
+        self.config = self.installation / "conf"
+        self.config.mkdir(parents=True)
+        self.events = self.root / "events.txt"
+        self.bin = self.root / "bin"
+        self.bin.mkdir()
+        self.env = os.environ.copy()
+        for key in (
+            "IOTDB_HOME",
+            "IOTDB_CONF",
+            "IOTDB_DATA_HOME",
+            "IOTDB_LOG_DIR",
+            "JAVA_HOME",
+        ):
+            self.env.pop(key, None)
+        self.env["PATH"] = str(self.bin) + os.pathsep + self.env["PATH"]
+        self.env["EDGE_TEST_EVENTS"] = str(self.events)
+        self.env["EDGE_TEST_STOP_STATUS"] = "0"
+        self.env["SYSTEMD_DIR"] = str(self.root / "systemd")
+        Path(self.env["SYSTEMD_DIR"]).mkdir()
+        self.marker = self.directory(self.installation / "data") / "marker"
+        self.marker.touch()
+        self.keep = self.directory(self.installation / "logs") / "keep.log"
+        self.keep.touch()
+        if WINDOWS:
+            self.destroy = self.copy("tools/windows/ops/destroy-edge.bat")
+            self.stop = self.installation / "sbin/windows/stop-edge.bat"
+            self.write(
+                self.stop,
+                "@echo off\n"
+                '>>"%EDGE_TEST_EVENTS%" echo stop %*\n'
+                'if not exist "%IOTDB_HOME%\\data\\marker" exit /b 97\n'
+                "exit /b %EDGE_TEST_STOP_STATUS%\n",
+            )
+        else:
+            self.destroy = self.copy("tools/ops/destroy-edge.sh")
+            self.daemon = self.copy("tools/ops/daemon-edge.sh")
+            self.stop = self.installation / "sbin/stop-edge.sh"
+            self.write(
+                self.stop,
+                "#!/bin/bash\n"
+                'printf "stop %s\\n" "$*" >> "$EDGE_TEST_EVENTS"\n'
+                "sleep 0.05\n"
+                '[ -f "$IOTDB_HOME/data/marker" ] || exit 97\n'
+                'exit "$EDGE_TEST_STOP_STATUS"\n',
+            )
+            self.write(
+                self.installation / "sbin/start-edge.sh", "#!/bin/bash\nexit 
0\n"
+            )
+            self.write(self.bin / "java", "#!/bin/bash\nexit 0\n")
+            self.write(
+                self.bin / "systemctl",
+                "#!/bin/bash\n"
+                'printf "systemctl %s\\n" "$*" >> "$EDGE_TEST_EVENTS"\n'
+                'if [ "$1" = show ]; then printf "%s\\n" 
"$EDGE_TEST_SERVICE_PIDFILE"; fi\n'
+                '[ "$1" != "$EDGE_TEST_SYSTEMCTL_FAIL" ]\n',
+            )
+
+    @staticmethod
+    def write(path, content):
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_bytes(content.encode("utf-8"))
+        path.chmod(0o755)
+
+    def copy(self, relative):
+        destination = self.installation / relative
+        destination.parent.mkdir(parents=True, exist_ok=True)
+        shutil.copyfile(REPOSITORY / "scripts" / relative, destination)
+        destination.chmod(0o755)
+        return destination
+
+    @staticmethod
+    def directory(path):
+        path.mkdir(parents=True, exist_ok=True)
+        (path / "data.txt").touch()
+        return path
+
+    def properties(self, lines, name="iotdb-system.properties"):
+        self.write(self.config / name, "\r\n".join(lines) + "\r\n")
+
+    def run_script(self, script, arguments=(), answer=""):
+        if WINDOWS:
+            command = '"{}" /d /c call "{}" {}'.format(
+                os.environ["COMSPEC"], script, " ".join(arguments)
+            )
+        else:
+            command = ["bash", str(script), *arguments]
+        return subprocess.run(
+            command,
+            input=answer,
+            text=True,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.STDOUT,
+            errors="replace",
+            env=self.env,
+            cwd=self.root,
+            timeout=30,
+        )
+
+    def assert_success(self, result):
+        self.assertEqual(result.returncode, 0, result.stdout)
+
+    def event_lines(self):
+        return self.events.read_text().splitlines() if self.events.exists() 
else []
+
+    def test_default_answer_and_no_do_not_stop_or_delete(self):
+        for answer in ("", "\n", "n\n", "yes\n"):
+            with self.subTest(answer=answer):
+                self.env["CLEAN_SERVICE"] = "y"
+                self.assert_success(self.run_script(self.destroy, 
answer=answer))
+                self.assertTrue(self.marker.exists())
+                self.assertEqual(self.event_lines(), [])
+
+    def test_invalid_arguments_do_not_stop_or_delete(self):
+        for arguments in (("--unknown",), ("-f", "extra")):
+            with self.subTest(arguments=arguments):
+                result = self.run_script(self.destroy, arguments)
+                self.assertNotEqual(result.returncode, 0, result.stdout)
+                self.assertTrue(self.marker.exists())
+                self.assertEqual(self.event_lines(), [])
+
+    def test_force_cleans_defaults_from_an_unrelated_working_directory(self):
+        tracing = self.directory(self.installation / "datanode/tracing")
+        result = self.run_script(self.destroy, ("-f",))
+        self.assert_success(result)
+        self.assertFalse((self.installation / "data").exists())
+        self.assertFalse(tracing.exists())
+        self.assertTrue(self.keep.exists())
+        self.assertTrue(self.config.exists())
+        self.assertIn("stop -f", self.event_lines())
+        self.assertIn("IoTDB Edge clean done", result.stdout)
+
+    def test_yes_confirms_cleanup(self):
+        self.assert_success(self.run_script(self.destroy, answer="Y\n"))
+        self.assertFalse(self.marker.exists())
+
+    def test_stop_failure_preserves_all_data(self):
+        self.env["EDGE_TEST_STOP_STATUS"] = "42"
+        result = self.run_script(self.destroy, ("-f",))
+        self.assertNotEqual(result.returncode, 0, result.stdout)
+        self.assertTrue(self.marker.exists())
+        self.assertNotIn("clean done", result.stdout)
+
+    def test_all_configured_directories_and_multiple_tiers(self):
+        targets = []
+        lines = ["# dn_data_dirs=ignored", "! cn_system_dir=ignored"]
+        for index, key in enumerate(DIRECTORY_KEYS):
+            base = self.installation if index % 2 else self.root
+            target = self.directory(base / (key + " with spaces=[1]"))
+            value = target.relative_to(base) if base == self.installation else 
target
+            lines.append("  {} = {}  ".format(key, value.as_posix()))
+            targets.append(target)
+        tiers = [
+            self.directory(self.installation / "tier one"),
+            self.directory(self.root / "absolute tier"),
+            self.directory(self.installation / "tier=two"),
+        ]
+        lines.append(
+            " dn_data_dirs = tier one ; {}, tier=two 
".format(tiers[1].as_posix())
+        )
+        # The previous value of a repeated property must not be removed.
+        superseded = targets.pop(DIRECTORY_KEYS.index("dn_data_dirs"))
+        self.properties(lines)
+        self.assert_success(self.run_script(self.destroy, ("-f",)))
+        for target in targets + tiers:
+            self.assertFalse(target.exists(), str(target))
+        self.assertTrue(superseded.exists())
+        self.assertTrue((self.config / "iotdb-system.properties").exists())
+        self.assertTrue(self.keep.exists())
+
+    def test_legacy_configuration_files(self):
+        cn = self.directory(self.root / "legacy cn")
+        dn = self.directory(self.root / "legacy dn")
+        self.properties(
+            ["cn_system_dir=" + cn.as_posix()], "iotdb-confignode.properties"
+        )
+        self.properties(["dn_data_dirs=" + dn.as_posix()], 
"iotdb-datanode.properties")
+        self.assert_success(self.run_script(self.destroy, ("-f",)))
+        self.assertFalse(cn.exists())
+        self.assertFalse(dn.exists())
+
+    def test_custom_home_and_configuration(self):
+        self.env["IOTDB_HOME"] = str(self.installation)
+        self.config = self.root / "custom configuration"
+        self.config.mkdir()
+        self.env["IOTDB_CONF"] = str(self.config)
+        target = self.directory(self.root / "custom data")
+        self.properties(["dn_data_dirs=" + target.as_posix()])
+        self.assert_success(self.run_script(self.destroy, ("-f",)))
+        self.assertFalse(target.exists())
+
+    def test_unified_configuration_takes_precedence(self):
+        untouched = self.directory(self.root / "legacy data")
+        self.properties(
+            ["dn_data_dirs=" + untouched.as_posix()], 
"iotdb-datanode.properties"
+        )
+        self.properties(["# Defaults"])
+        self.assert_success(self.run_script(self.destroy, ("-f",)))
+        self.assertTrue(untouched.exists())
+
+    def test_home_or_parent_is_rejected_before_any_deletion(self):
+        # Only disposable paths are used even if the guard regresses.
+        for value in (".", "data/..", ".."):
+            with self.subTest(value=value):
+                self.properties(["dn_data_dirs=" + value])
+                result = self.run_script(self.destroy, ("-f",))
+                self.assertNotEqual(result.returncode, 0, result.stdout)
+                self.assertIn("Refusing to remove", result.stdout)
+                self.assertTrue(self.marker.exists())
+                self.assertTrue(self.keep.exists())
+
+    def test_missing_stop_script_preserves_data(self):
+        self.stop.unlink()
+        result = self.run_script(self.destroy, ("-f",))
+        self.assertNotEqual(result.returncode, 0, result.stdout)
+        self.assertTrue(self.marker.exists())
+
+    def test_empty_property_uses_default_not_an_earlier_value(self):
+        untouched = self.directory(self.root / "superseded data")
+        self.properties(["dn_data_dirs=" + untouched.as_posix(), 
"dn_data_dirs=  "])
+        self.assert_success(self.run_script(self.destroy, ("-f",)))
+        self.assertFalse(self.marker.exists())
+        self.assertTrue(untouched.exists())
+
+    @unittest.skipIf(WINDOWS, "Unix symbolic links")
+    def test_symlinked_parent_cannot_hide_a_home_directory(self):
+        (self.root / "home alias").symlink_to(
+            self.installation, target_is_directory=True
+        )
+        self.properties(
+            ["dn_data_dirs=" + (self.root / "home alias/data/..").as_posix()]
+        )
+        result = self.run_script(self.destroy, ("-f",))
+        self.assertNotEqual(result.returncode, 0, result.stdout)
+        self.assertIn("Refusing to remove", result.stdout)
+        self.assertTrue(self.marker.exists())
+
+    @unittest.skipIf(WINDOWS, "Unix deletion errors")
+    def test_deletion_failure_is_not_reported_as_success(self):
+        self.write(self.bin / "rm", "#!/bin/bash\nexit 42\n")
+        result = self.run_script(self.destroy, ("-f",))
+        self.assertNotEqual(result.returncode, 0, result.stdout)
+        self.assertNotIn("clean done", result.stdout)
+        self.assertTrue(self.marker.exists())
+
+    @unittest.skipIf(
+        WINDOWS, "The Unix launcher supports a separate DataNode data home"
+    )
+    def test_data_home_does_not_change_confignode_home(self):
+        data_home = self.directory(self.root / "external data home")
+        self.env["IOTDB_DATA_HOME"] = str(data_home)
+        cn = self.directory(self.installation / "cn")
+        dn = self.directory(data_home / "dn")
+        keep_cn = self.directory(data_home / "cn")
+        keep_dn = self.directory(self.installation / "dn")
+        self.properties(["cn_system_dir=cn", "dn_data_dirs=dn"])
+        self.assert_success(self.run_script(self.destroy, ("-f",)))
+        self.assertFalse(cn.exists())
+        self.assertFalse(dn.exists())
+        self.assertTrue(keep_cn.exists())
+        self.assertTrue(keep_dn.exists())
+
+    @unittest.skipIf(WINDOWS, "Unix systemd integration")
+    def test_cleanup_stops_only_the_matching_systemd_service(self):
+        self.env["EDGE_TEST_SERVICE_PIDFILE"] = str(self.installation / 
"edge.pid")
+        self.assert_success(self.run_script(self.destroy, ("-f",)))
+        events = self.event_lines()
+        self.assertLess(
+            events.index("systemctl stop iotdb-edge"), events.index("stop -f")
+        )
+
+    @unittest.skipIf(WINDOWS, "Unix systemd integration")
+    def test_other_systemd_installation_is_not_stopped(self):
+        self.env["EDGE_TEST_SERVICE_PIDFILE"] = str(self.root / "another 
edge/edge.pid")
+        self.assert_success(self.run_script(self.destroy, ("-f",)))
+        self.assertNotIn("systemctl stop iotdb-edge", self.event_lines())
+
+    @unittest.skipIf(WINDOWS, "Unix systemd integration")
+    def test_systemd_stop_failure_preserves_data(self):
+        self.env["EDGE_TEST_SERVICE_PIDFILE"] = str(self.installation / 
"edge.pid")
+        self.env["EDGE_TEST_SYSTEMCTL_FAIL"] = "stop"
+        result = self.run_script(self.destroy, ("-f",))
+        self.assertNotEqual(result.returncode, 0, result.stdout)
+        self.assertTrue(self.marker.exists())
+        self.assertNotIn("stop -f", self.event_lines())
+
+    @unittest.skipIf(WINDOWS, "Unix systemd registration")
+    def test_daemon_tracks_the_forked_jvm_and_supports_java_home(self):
+        java_home = self.root / "custom java"
+        self.write(java_home / "bin/java", "#!/bin/bash\nexit 0\n")
+        self.env["JAVA_HOME"] = str(java_home)
+        result = self.run_script(self.daemon, answer="\n\n")
+        self.assert_success(result)
+        unit_path = Path(self.env["SYSTEMD_DIR"]) / "iotdb-edge.service"
+        unit = unit_path.read_text()
+        for setting in (
+            "Type=forking",
+            "PIDFile={}/edge.pid".format(self.installation),
+            "Restart=on-failure",
+            "SuccessExitStatus=143",
+            "RestartPreventExitStatus=SIGKILL",
+            "LimitNOFILE=65536",
+            "StartLimitIntervalSec=600s",
+            "StartLimitBurst=3",
+            'Environment="JAVA_HOME={}"'.format(java_home),
+            'Environment="PATH={}/bin:'.format(java_home),
+            'ExecStart="{}/sbin/start-edge.sh"'.format(self.installation),
+            'ExecStop="{}/sbin/stop-edge.sh"'.format(self.installation),
+        ):
+            self.assertIn(setting, unit)
+        self.assertEqual(
+            self.event_lines(),
+            [
+                "systemctl daemon-reload",
+                "systemctl stop iotdb-edge",
+                "stop ",
+                "systemctl start iotdb-edge",
+                "systemctl enable iotdb-edge",
+            ],
+        )
+        if shutil.which("systemd-analyze"):
+            check = subprocess.run(
+                ["systemd-analyze", "verify", str(unit_path)],
+                text=True,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                timeout=30,
+            )
+            self.assertEqual(check.returncode, 0, check.stdout)
+
+    @unittest.skipIf(WINDOWS, "Unix systemd registration")
+    def test_daemon_path_java_and_declining_start_and_enable(self):
+        self.assert_success(self.run_script(self.daemon, answer="n\nn\n"))
+        self.assertEqual(self.event_lines(), ["systemctl daemon-reload"])
+        unit = (Path(self.env["SYSTEMD_DIR"]) / 
"iotdb-edge.service").read_text()
+        self.assertIn('Environment="JAVA_HOME="', unit)
+        self.assertIn('Environment="PATH={}"'.format(self.env["PATH"]), unit)
+
+    @unittest.skipIf(WINDOWS, "Unix systemd registration")
+    def test_daemon_rejects_invalid_java_home(self):
+        self.env["JAVA_HOME"] = str(self.root / "missing java")
+        result = self.run_script(self.daemon, answer="n\nn\n")
+        self.assertNotEqual(result.returncode, 0, result.stdout)
+        self.assertEqual(self.event_lines(), [])
+
+    @unittest.skipIf(WINDOWS, "Unix systemd registration")
+    def test_daemon_propagates_service_start_errors(self):
+        self.env["EDGE_TEST_SYSTEMCTL_FAIL"] = "start"
+        result = self.run_script(self.daemon, answer="y\ny\n")
+        self.assertNotEqual(result.returncode, 0, result.stdout)
+        self.assertNotIn("systemctl enable iotdb-edge", self.event_lines())
+
+    @unittest.skipUnless(WINDOWS, "Native Windows stop script")
+    def test_windows_force_stop_returns_without_pausing(self):
+        self.copy("sbin/windows/stop-edge.bat")
+        result = self.run_script(self.stop, ("-f",))
+        self.assert_success(result)
+        self.assertNotIn("Press any key", result.stdout)
+
+
+class AssemblyTest(unittest.TestCase):
+    @staticmethod
+    def packaged(descriptor, path):
+        root = ET.parse(REPOSITORY / descriptor).getroot()
+        for file_set in root.findall("./fileSets/fileSet"):
+            directory = file_set.findtext("directory", "")
+            if not directory.endswith("/scripts/tools"):
+                continue
+            includes = [node.text for node in 
file_set.findall("./includes/include")]
+            excludes = [node.text for node in 
file_set.findall("./excludes/exclude")]
+            if (
+                not includes or any(fnmatch.fnmatchcase(path, p) for p in 
includes)
+            ) and not any(fnmatch.fnmatchcase(path, p) for p in excludes):
+                return file_set.findtext("fileMode")
+        return None
+
+    def test_edge_ops_are_executable_and_only_in_edge_packages(self):
+        for path in (
+            "ops/daemon-edge.sh",
+            "ops/destroy-edge.sh",
+            "windows/ops/destroy-edge.bat",
+        ):
+            self.assertEqual(
+                self.packaged("distribution/src/assembly/edge.xml", path), 
"0755"
+            )
+            for descriptor in (
+                "distribution/src/assembly/all.xml",
+                "distribution/src/assembly/datanode.xml",
+                "distribution/src/assembly/confignode.xml",
+                "iotdb-core/datanode/src/assembly/server.xml",
+                "iotdb-core/confignode/src/assembly/confignode.xml",
+            ):
+                self.assertIsNone(
+                    self.packaged(descriptor, path), descriptor + ": " + path
+                )
+
+    def test_edge_does_not_restore_standalone_ops(self):
+        paths = ["ops/daemon-{}.sh".format(node) for node in ("confignode", 
"datanode")]
+        for node in ("all", "confignode", "datanode"):
+            paths.extend(
+                (
+                    "ops/destroy-{}.sh".format(node),
+                    "windows/ops/destroy-{}.bat".format(node),
+                )
+            )
+        for path in paths:
+            self.assertIsNone(
+                self.packaged("distribution/src/assembly/edge.xml", path), path
+            )
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)
diff --git a/.github/workflows/edge-it.yml b/.github/workflows/edge-it.yml
index 9fb14c4370d..c908b0e1096 100644
--- a/.github/workflows/edge-it.yml
+++ b/.github/workflows/edge-it.yml
@@ -61,6 +61,8 @@ jobs:
 
     steps:
       - uses: actions/checkout@v5
+      - name: Test Edge ops scripts
+        run: python3 .github/scripts/test-edge-ops.py
       - name: Set up JDK
         uses: actions/setup-java@v5
         with:
@@ -108,6 +110,8 @@ jobs:
     runs-on: windows-latest
     steps:
       - uses: actions/checkout@v5
+      - name: Test Edge ops scripts
+        run: python .github/scripts/test-edge-ops.py
       - name: Test Edge Windows launchers
         shell: powershell
         run: .github/scripts/test-edge-windows.ps1
diff --git a/distribution/src/assembly/all.xml 
b/distribution/src/assembly/all.xml
index 610e53edbfc..25662a5fe95 100644
--- a/distribution/src/assembly/all.xml
+++ b/distribution/src/assembly/all.xml
@@ -101,6 +101,7 @@
             <excludes>
                 <exclude>*ainode.*</exclude>
                 <exclude>**/*ainode.*</exclude>
+                <exclude>**/*edge.*</exclude>
             </excludes>
             <fileMode>0755</fileMode>
         </fileSet>
diff --git a/distribution/src/assembly/datanode.xml 
b/distribution/src/assembly/datanode.xml
index d3996da9786..0cb7b66a280 100644
--- a/distribution/src/assembly/datanode.xml
+++ b/distribution/src/assembly/datanode.xml
@@ -73,6 +73,7 @@
                 <exclude>**/*confignode.*</exclude>
                 <exclude>**/*all.*</exclude>
                 <exclude>**/*ainode.*</exclude>
+                <exclude>**/*edge.*</exclude>
             </excludes>
             <fileMode>0755</fileMode>
         </fileSet>
diff --git a/distribution/src/assembly/edge.xml 
b/distribution/src/assembly/edge.xml
index 54c598e80b5..67eed08bec3 100644
--- a/distribution/src/assembly/edge.xml
+++ b/distribution/src/assembly/edge.xml
@@ -125,6 +125,17 @@
             </excludes>
             <fileMode>0755</fileMode>
         </fileSet>
+        <!-- Edge replacements for the standalone node daemon and cleanup 
tools. -->
+        <fileSet>
+            <outputDirectory>tools</outputDirectory>
+            <directory>${project.basedir}/../scripts/tools</directory>
+            <includes>
+                <include>ops/daemon-edge.sh</include>
+                <include>ops/destroy-edge.sh</include>
+                <include>windows/ops/destroy-edge.bat</include>
+            </includes>
+            <fileMode>0755</fileMode>
+        </fileSet>
     </fileSets>
     <files>
         <file>
diff --git a/iotdb-core/datanode/src/assembly/server.xml 
b/iotdb-core/datanode/src/assembly/server.xml
index 2e1dab2e584..50a25bbce9e 100644
--- a/iotdb-core/datanode/src/assembly/server.xml
+++ b/iotdb-core/datanode/src/assembly/server.xml
@@ -70,6 +70,7 @@
                 <exclude>**/*confignode.*</exclude>
                 <exclude>**/*all.*</exclude>
                 <exclude>**/*ainode.*</exclude>
+                <exclude>**/*edge.*</exclude>
             </excludes>
             <fileMode>0755</fileMode>
         </fileSet>
diff --git a/scripts/sbin/windows/stop-edge.bat 
b/scripts/sbin/windows/stop-edge.bat
index 4eed010be62..2253f5354c9 100644
--- a/scripts/sbin/windows/stop-edge.bat
+++ b/scripts/sbin/windows/stop-edge.bat
@@ -18,9 +18,12 @@
 @REM under the License.
 @REM
 
+setlocal
 echo Stopping IoTDB Edge (the merged ConfigNode + DataNode process)
-pushd %~dp0\..\..
+pushd "%~dp0\..\.."
 set "IOTDB_HOME=%cd%"
 popd
-powershell -NoProfile -Command "$plain='-DIOTDB_HOME=' + $env:IOTDB_HOME; 
$quoted='-DIOTDB_HOME=' + [char]34 + $env:IOTDB_HOME + [char]34; 
Get-CimInstance Win32_Process -Filter \"name='java.exe'\" | Where-Object { 
$line=$_.CommandLine; $sameHome=$line -and ($line.Contains($plain + ' ') -or 
$line.EndsWith($plain) -or $line.Contains($quoted + ' ') -or 
$line.EndsWith($quoted)); $sameHome -and 
$line.Contains('org.apache.iotdb.edge.EdgeNode') } | ForEach-Object { 
Stop-Process -Id $_.ProcessId [...]
-pause
+powershell -NoProfile -Command "$ErrorActionPreference='Stop'; 
$plain='-DIOTDB_HOME=' + $env:IOTDB_HOME; $quoted='-DIOTDB_HOME=' + [char]34 + 
$env:IOTDB_HOME + [char]34; Get-CimInstance Win32_Process -Filter 
\"name='java.exe'\" | Where-Object { $line=$_.CommandLine; $sameHome=$line -and 
($line.Contains($plain + ' ') -or $line.EndsWith($plain) -or 
$line.Contains($quoted + ' ') -or $line.EndsWith($quoted)); $sameHome -and 
$line.Contains('org.apache.iotdb.edge.EdgeNode') } | ForEach-Object  [...]
+set "STOP_STATUS=%errorlevel%"
+if not "%~1"=="-f" pause
+exit /b %STOP_STATUS%
diff --git a/scripts/tools/ops/daemon-edge.sh b/scripts/tools/ops/daemon-edge.sh
new file mode 100644
index 00000000000..060ab203192
--- /dev/null
+++ b/scripts/tools/ops/daemon-edge.sh
@@ -0,0 +1,114 @@
+#!/bin/bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+set -e
+
+IOTDB_HOME="${IOTDB_HOME:-$(cd "$(dirname "$0")"/../.. && pwd)}"
+IOTDB_HOME="$(cd "$IOTDB_HOME" && pwd -P)"
+export IOTDB_HOME
+IOTDB_SBIN_HOME="$IOTDB_HOME/sbin"
+SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
+
+if [ ! -d "$SYSTEMD_DIR" ] || ! command -v systemctl >/dev/null 2>&1; then
+    echo "Current system can't support systemd."
+    exit 1
+fi
+
+JAVA=java
+if [ -n "$JAVA_HOME" ]; then
+    JAVA="$JAVA_HOME/bin/java"
+    if [ -x "$JAVA_HOME/bin/amd64/java" ]; then
+        JAVA="$JAVA_HOME/bin/amd64/java"
+    fi
+fi
+if ! "$JAVA" --version >/dev/null 2>&1; then
+    echo "Java is not available. Please check JAVA_HOME and PATH."
+    exit 1
+fi
+
+if [ ! -x "$IOTDB_SBIN_HOME/start-edge.sh" ] || [ ! -x 
"$IOTDB_SBIN_HOME/stop-edge.sh" ]; then
+    echo "Cannot find the executable Edge start/stop scripts in 
$IOTDB_SBIN_HOME."
+    exit 1
+fi
+
+# Quote environment values and executable paths using systemd's syntax.
+systemd_quote() {
+    local value="$1"
+    value="${value//\\/\\\\}"
+    value="${value//\"/\\\"}"
+    value="${value//%/%%}"
+    printf '"%s"' "$value"
+}
+
+FILE_NAME="$SYSTEMD_DIR/iotdb-edge.service"
+cat > "$FILE_NAME" <<EOF
+[Unit]
+Description=iotdb-edge
+Documentation=https://iotdb.apache.org/
+After=network.target
+StartLimitIntervalSec=600s
+StartLimitBurst=3
+
+[Service]
+StandardOutput=null
+StandardError=null
+LimitNOFILE=65536
+# start-edge.sh forks the JVM and records its PID before returning.
+Type=forking
+PIDFile=${IOTDB_HOME//%/%%}/edge.pid
+WorkingDirectory=${IOTDB_HOME//%/%%}
+User=root
+Group=root
+Environment=$(systemd_quote "JAVA_HOME=$JAVA_HOME")
+Environment=$(systemd_quote "PATH=${JAVA_HOME:+$JAVA_HOME/bin:}$PATH")
+Environment=$(systemd_quote "IOTDB_HOME=$IOTDB_HOME")
+Environment=$(systemd_quote "IOTDB_CONF=${IOTDB_CONF:-$IOTDB_HOME/conf}")
+Environment=$(systemd_quote "IOTDB_DATA_HOME=${IOTDB_DATA_HOME:-$IOTDB_HOME}")
+Environment=$(systemd_quote "IOTDB_LOG_DIR=${IOTDB_LOG_DIR:-$IOTDB_HOME/logs}")
+ExecStart=$(systemd_quote "$IOTDB_SBIN_HOME/start-edge.sh")
+ExecStop=$(systemd_quote "$IOTDB_SBIN_HOME/stop-edge.sh")
+Restart=on-failure
+SuccessExitStatus=143
+RestartSec=5
+RestartPreventExitStatus=SIGKILL
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+systemctl daemon-reload
+echo "Daemon service of IoTDB Edge has been successfully registered."
+echo
+echo "Do you want to execute 'systemctl start iotdb-edge'? y/n (default y)"
+read -r START_SERVICE || START_SERVICE=""
+if [[ -z "$START_SERVICE" || "$START_SERVICE" =~ ^[Yy]$ ]]; then
+    # Stop both an existing service and a manually started Edge before 
starting.
+    systemctl stop iotdb-edge
+    "$IOTDB_SBIN_HOME/stop-edge.sh"
+    systemctl start iotdb-edge
+    echo "Executed successfully."
+fi
+echo
+echo "Do you want to execute 'systemctl enable iotdb-edge' to start at boot? 
y/n (default y)"
+read -r ADD_STARTUP || ADD_STARTUP=""
+if [[ -z "$ADD_STARTUP" || "$ADD_STARTUP" =~ ^[Yy]$ ]]; then
+    systemctl enable iotdb-edge
+    echo "Executed successfully."
+fi
diff --git a/scripts/tools/ops/destroy-edge.sh 
b/scripts/tools/ops/destroy-edge.sh
new file mode 100644
index 00000000000..980be9c25fa
--- /dev/null
+++ b/scripts/tools/ops/destroy-edge.sh
@@ -0,0 +1,161 @@
+#!/bin/bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+set -e
+
+if [ "$#" -gt 1 ] || { [ "$#" -eq 1 ] && [ "$1" != "-f" ]; }; then
+    echo "Usage: $0 [-f]"
+    exit 1
+fi
+if [ "${1:-}" != "-f" ]; then
+    read -r -p "Do you want to clean all the data of IoTDB Edge? y/n (default 
n): " CLEAN_SERVICE || CLEAN_SERVICE=""
+    if [[ "$CLEAN_SERVICE" != "y" && "$CLEAN_SERVICE" != "Y" ]]; then
+        echo "Exiting..."
+        exit 0
+    fi
+fi
+
+IOTDB_HOME="${IOTDB_HOME:-$(cd "$(dirname "$0")"/../.. && pwd)}"
+IOTDB_HOME="$(cd "$IOTDB_HOME" && pwd -P)"
+export IOTDB_HOME
+IOTDB_CONF="${IOTDB_CONF:-$IOTDB_HOME/conf}"
+IOTDB_DATA_HOME="${IOTDB_DATA_HOME:-$IOTDB_HOME}"
+if [ -d "$IOTDB_DATA_HOME" ]; then
+    IOTDB_DATA_HOME="$(cd "$IOTDB_DATA_HOME" && pwd -P)"
+fi
+
+if [ ! -f "$IOTDB_HOME/sbin/stop-edge.sh" ]; then
+    echo "Cannot find $IOTDB_HOME/sbin/stop-edge.sh. No data has been removed."
+    exit 1
+fi
+
+# Stop the matching service first so Restart=on-failure cannot race with 
cleanup.
+if command -v systemctl >/dev/null 2>&1 &&
+        [ "$(systemctl show --property=PIDFile --value iotdb-edge.service 
2>/dev/null)" = "$IOTDB_HOME/edge.pid" ]; then
+    systemctl stop iotdb-edge
+fi
+bash "$IOTDB_HOME/sbin/stop-edge.sh" -f
+
+if [ -f "$IOTDB_CONF/iotdb-system.properties" ]; then
+    CN_CONFIG="$IOTDB_CONF/iotdb-system.properties"
+    DN_CONFIG="$CN_CONFIG"
+else
+    CN_CONFIG="$IOTDB_CONF/iotdb-confignode.properties"
+    DN_CONFIG="$IOTDB_CONF/iotdb-datanode.properties"
+fi
+
+read_property() {
+    local config="$1" key="$2" fallback="$3"
+    if [ ! -f "$config" ]; then
+        printf '%s\n' "$fallback"
+        return
+    fi
+    # Keep spaces and '=' in values; the last active property wins, as in Java.
+    awk -v key="$key" -v fallback="$fallback" '
+        {
+            separator = index($0, "=")
+            if (!separator) next
+            name = substr($0, 1, separator - 1)
+            gsub(/^[[:space:]]+|[[:space:]]+$/, "", name)
+            if (name == key) {
+                value = substr($0, separator + 1)
+                gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
+            }
+        }
+        END { print (value == "" ? fallback : value) }
+    ' "$config"
+}
+
+CLEAN_PATHS=()
+add_paths() {
+    local directories="$1" base="$2" path protected
+    local paths=()
+    IFS=';,' read -r -a paths <<< "$directories"
+    for path in "${paths[@]}"; do
+        path="${path#"${path%%[![:space:]]*}"}"
+        path="${path%"${path##*[![:space:]]}"}"
+        [ -n "$path" ] || continue
+        case "$path" in
+            *://*|OBJECT_STORAGE) continue ;;
+            /*) ;;
+            *) path="$base/$path" ;;
+        esac
+        # Remove a final symlink itself, not its target. Resolve directory 
aliases
+        # before checking them so '..' and symlinked parents cannot hide a 
root.
+        while [[ "$path" != / && "$path" == */ ]]; do path="${path%/}"; done
+        if [ -L "$path" ]; then
+            path="$(cd -P "$(dirname "$path")" && pwd -P)/$(basename "$path")"
+        elif [ -d "$path" ]; then
+            path="$(cd -P "$path" && pwd -P)"
+        elif [ -e "$path" ]; then
+            echo "Refusing to remove a non-directory data path: $path"
+            exit 1
+        else
+            continue
+        fi
+        if [ "$path" = / ]; then
+            echo "Refusing to remove the filesystem root."
+            exit 1
+        fi
+        for protected in "$IOTDB_HOME" "$IOTDB_DATA_HOME" "$HOME"; do
+            case "$protected/" in
+                "$path/"*)
+                    echo "Refusing to remove a home directory or its parent: 
$path"
+                    exit 1
+                    ;;
+            esac
+        done
+        CLEAN_PATHS+=("$path")
+    done
+}
+
+# The merged process still uses both sets of node directories. Preserve the
+# local destroy-all behavior as well as every custom path from the node tools.
+add_paths "data" "$IOTDB_HOME"
+add_paths "data/datanode" "$IOTDB_DATA_HOME"
+for key in cn_system_dir cn_consensus_dir; do
+    case "$key" in
+        cn_system_dir) fallback=data/confignode/system ;;
+        cn_consensus_dir) fallback=data/confignode/consensus ;;
+    esac
+    value=$(read_property "$CN_CONFIG" "$key" "$fallback")
+    add_paths "$value" "$IOTDB_HOME"
+done
+for key in dn_system_dir dn_data_dirs dn_consensus_dir dn_wal_dirs 
dn_tracing_dir dn_sync_dir pipe_receiver_file_dirs 
iot_consensus_v2_receiver_file_dirs sort_tmp_dir; do
+    case "$key" in
+        dn_system_dir) fallback=data/datanode/system ;;
+        dn_data_dirs) fallback=data/datanode/data ;;
+        dn_consensus_dir) fallback=data/datanode/consensus ;;
+        dn_wal_dirs) fallback=data/datanode/wal ;;
+        dn_tracing_dir) fallback=datanode/tracing ;;
+        dn_sync_dir) fallback=data/datanode/sync ;;
+        pipe_receiver_file_dirs) fallback=data/datanode/system/pipe/receiver ;;
+        iot_consensus_v2_receiver_file_dirs) 
fallback=data/datanode/system/pipe/consensus/receiver ;;
+        sort_tmp_dir) fallback=data/datanode/tmp ;;
+    esac
+    value=$(read_property "$DN_CONFIG" "$key" "$fallback")
+    add_paths "$value" "$IOTDB_DATA_HOME"
+done
+
+# Validate every target before deleting any data, and finish before reporting 
success.
+for path in "${CLEAN_PATHS[@]}"; do
+    rm -rf -- "$path"
+done
+echo "IoTDB Edge clean done ..."
diff --git a/scripts/tools/windows/ops/destroy-edge.bat 
b/scripts/tools/windows/ops/destroy-edge.bat
new file mode 100644
index 00000000000..417ffda2b2a
--- /dev/null
+++ b/scripts/tools/windows/ops/destroy-edge.bat
@@ -0,0 +1,96 @@
+@REM
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements.  See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership.  The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License.  You may obtain a copy of the License at
+@REM
+@REM     http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied.  See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM
+@echo off
+setlocal EnableExtensions DisableDelayedExpansion
+
+if not "%~2"=="" goto usage
+if "%~1"=="-f" goto confirmed
+if not "%~1"=="" goto usage
+set "CLEAN_SERVICE="
+set /p "CLEAN_SERVICE=Do you want to clean all the data of IoTDB Edge? y/n 
(default n): "
+if /i "%CLEAN_SERVICE%"=="y" goto confirmed
+echo Exiting...
+exit /b 0
+
+:confirmed
+if not defined IOTDB_HOME set "IOTDB_HOME=%~dp0..\..\.."
+for %%i in ("%IOTDB_HOME%") do set "IOTDB_HOME=%%~fi"
+if not defined IOTDB_CONF set "IOTDB_CONF=%IOTDB_HOME%\conf"
+if not exist "%IOTDB_HOME%\sbin\windows\stop-edge.bat" (
+    echo Cannot find the Edge stop script. No data has been removed.
+    exit /b 1
+)
+
+@REM Wait for the merged process and propagate stop errors before removing 
data.
+call "%IOTDB_HOME%\sbin\windows\stop-edge.bat" -f
+if errorlevel 1 exit /b 1
+
+@REM PowerShell is already required by stop-edge.bat. Literal paths preserve 
spaces,
+@REM Unicode, drive/UNC paths and wildcard characters in configured 
directories.
+powershell -NoProfile -ExecutionPolicy Bypass -Command ^
+    "$ErrorActionPreference = 'Stop';" ^
+    "$iotdbHome = [IO.Path]::GetFullPath($env:IOTDB_HOME).TrimEnd('\', '/');" ^
+    "$cn = [ordered]@{cn_system_dir='data/confignode/system'; 
cn_consensus_dir='data/confignode/consensus'};" ^
+    "$dn = [ordered]@{dn_system_dir='data/datanode/system'; 
dn_data_dirs='data/datanode/data'; dn_consensus_dir='data/datanode/consensus'; 
dn_wal_dirs='data/datanode/wal'; dn_tracing_dir='datanode/tracing'; 
dn_sync_dir='data/datanode/sync'; 
pipe_receiver_file_dirs='data/datanode/system/pipe/receiver'; 
iot_consensus_v2_receiver_file_dirs='data/datanode/system/pipe/consensus/receiver';
 sort_tmp_dir='data/datanode/tmp'};" ^
+    "function Read-Directories($file, $directories) {" ^
+    "    $defaults = @{}; foreach ($key in $directories.Keys) { 
$defaults[$key] = $directories[$key]; }" ^
+    "    if (Test-Path -LiteralPath $file) {" ^
+    "        foreach ($line in Get-Content -LiteralPath $file -Encoding UTF8) 
{" ^
+    "            if ($line -match '^\s*([^#!\s][^=]*?)\s*=\s*(.*?)\s*$') {" ^
+    "                $key = $Matches[1].Trim(); $value = $Matches[2].Trim();" ^
+    "                if ($directories.Contains($key)) { $directories[$key] = 
if ($value) { $value } else { $defaults[$key] }; }" ^
+    "            }" ^
+    "        }" ^
+    "    }" ^
+    "}" ^
+    "$systemConfig = Join-Path $env:IOTDB_CONF 'iotdb-system.properties';" ^
+    "if (Test-Path -LiteralPath $systemConfig) {" ^
+    "    Read-Directories $systemConfig $cn; Read-Directories $systemConfig 
$dn;" ^
+    "} else {" ^
+    "    Read-Directories (Join-Path $env:IOTDB_CONF 
'iotdb-confignode.properties') $cn;" ^
+    "    Read-Directories (Join-Path $env:IOTDB_CONF 
'iotdb-datanode.properties') $dn;" ^
+    "}" ^
+    "$targets = @();" ^
+    "foreach ($directories in (@('data') + @($cn.Values) + @($dn.Values))) {" ^
+    "    foreach ($directory in ($directories -split '[,;]')) {" ^
+    "        $directory = $directory.Trim();" ^
+    "        if (-not $directory -or $directory -match '://' -or $directory 
-eq 'OBJECT_STORAGE') { continue; }" ^
+    "        if (-not [IO.Path]::IsPathRooted($directory)) { $directory = 
Join-Path $iotdbHome $directory; }" ^
+    "        $directory = [IO.Path]::GetFullPath($directory).TrimEnd('\', 
'/');" ^
+    "        $root = [IO.Path]::GetPathRoot($directory).TrimEnd('\', '/');" ^
+    "        if ($directory -eq $root) { throw ('Refusing to remove a 
filesystem root: ' + $directory); }" ^
+    "        foreach ($protected in @($iotdbHome, $env:USERPROFILE)) {" ^
+    "            if (-not $protected) { continue; }" ^
+    "            $protected = [IO.Path]::GetFullPath($protected).TrimEnd('\', 
'/');" ^
+    "            if ($protected.Equals($directory, 
[StringComparison]::OrdinalIgnoreCase) -or $protected.StartsWith($directory + 
'\', [StringComparison]::OrdinalIgnoreCase)) {" ^
+    "                throw ('Refusing to remove a home directory or its 
parent: ' + $directory);" ^
+    "            }" ^
+    "        }" ^
+    "        $targets += $directory;" ^
+    "    }" ^
+    "}" ^
+    "foreach ($directory in ($targets | Select-Object -Unique)) {" ^
+    "    if (Test-Path -LiteralPath $directory) { Remove-Item -LiteralPath 
$directory -Recurse -Force; }" ^
+    "}" ^
+    "Write-Host 'IoTDB Edge clean done ...';"
+exit /b %errorlevel%
+
+:usage
+echo Usage: %~nx0 [-f]
+exit /b 1

Reply via email to