deploy: render launchctl templates safely
This commit is contained in:
@@ -44,31 +44,11 @@ export YUTONG_MQTT_PASSWORD=''
|
|||||||
mkdir -p "$HOME/Library/LaunchAgents" "$PROJECT_ROOT/data"
|
mkdir -p "$HOME/Library/LaunchAgents" "$PROJECT_ROOT/data"
|
||||||
mkdir -p /tmp/lingniu-gb32960-live /tmp/lingniu-jt808-live /tmp/lingniu-yutong-mqtt-live /tmp/lingniu-vehicle-history-live /tmp/lingniu-vehicle-analytics-live
|
mkdir -p /tmp/lingniu-gb32960-live /tmp/lingniu-jt808-live /tmp/lingniu-yutong-mqtt-live /tmp/lingniu-vehicle-history-live /tmp/lingniu-vehicle-analytics-live
|
||||||
|
|
||||||
for service in gb32960 jt808 yutong-mqtt vehicle-history vehicle-analytics; do
|
python3 deploy/local/launchctl/render.py
|
||||||
sed \
|
|
||||||
-e "s#__PROJECT_ROOT__#$PROJECT_ROOT#g" \
|
|
||||||
-e "s#__JAVA_BIN__#$JAVA_BIN#g" \
|
|
||||||
-e "s#__SHARED_ARCHIVE_PATH__#$INGEST_ARCHIVE_PATH#g" \
|
|
||||||
-e "s#__TDENGINE_JDBC_URL__#$TDENGINE_JDBC_URL#g" \
|
|
||||||
-e "s#__TDENGINE_USERNAME__#$TDENGINE_USERNAME#g" \
|
|
||||||
-e "s#__TDENGINE_PASSWORD__#$TDENGINE_PASSWORD#g" \
|
|
||||||
-e "s#__VEHICLE_IDENTITY_MYSQL_JDBC_URL__#$VEHICLE_IDENTITY_MYSQL_JDBC_URL#g" \
|
|
||||||
-e "s#__VEHICLE_IDENTITY_MYSQL_USERNAME__#$VEHICLE_IDENTITY_MYSQL_USERNAME#g" \
|
|
||||||
-e "s#__VEHICLE_IDENTITY_MYSQL_PASSWORD__#$VEHICLE_IDENTITY_MYSQL_PASSWORD#g" \
|
|
||||||
-e "s#__VEHICLE_IDENTITY_MYSQL_REFRESH_INTERVAL__#$VEHICLE_IDENTITY_MYSQL_REFRESH_INTERVAL#g" \
|
|
||||||
-e "s#__MYSQL_JDBC_URL__#$MYSQL_JDBC_URL#g" \
|
|
||||||
-e "s#__MYSQL_USERNAME__#$MYSQL_USERNAME#g" \
|
|
||||||
-e "s#__MYSQL_PASSWORD__#$MYSQL_PASSWORD#g" \
|
|
||||||
-e "s#__YUTONG_MQTT_ENABLED__#$YUTONG_MQTT_ENABLED#g" \
|
|
||||||
-e "s#__YUTONG_MQTT_URI__#$YUTONG_MQTT_URI#g" \
|
|
||||||
-e "s#__YUTONG_MQTT_TOPIC__#$YUTONG_MQTT_TOPIC#g" \
|
|
||||||
-e "s#__YUTONG_MQTT_USERNAME__#$YUTONG_MQTT_USERNAME#g" \
|
|
||||||
-e "s#__YUTONG_MQTT_PASSWORD__#$YUTONG_MQTT_PASSWORD#g" \
|
|
||||||
"deploy/local/launchctl/com.lingniu.${service}.plist.template" \
|
|
||||||
> "$HOME/Library/LaunchAgents/com.lingniu.${service}.plist"
|
|
||||||
done
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
渲染脚本固定生成 `gb32960 jt808 yutong-mqtt vehicle-history vehicle-analytics` 五个 plist,并会对密码、JDBC URL、MQTT topic 中的 XML 特殊字符做转义,避免 `#`、`&` 等字符破坏 plist。
|
||||||
|
|
||||||
如果生产配置由 Nacos 下发,可以把模板里的 `NACOS_CONFIG_ENABLED` 改成 `true`,并补充 `NACOS_SERVER_ADDR`、`NACOS_NAMESPACE`、`NACOS_GROUP`、`NACOS_USERNAME`、`NACOS_PASSWORD`。端口、Kafka topic、接入服务冷备 archive 路径、TDengine 连接建议仍保留为启动环境变量,便于 Portainer、launchctl 和临时压测保持一致。
|
如果生产配置由 Nacos 下发,可以把模板里的 `NACOS_CONFIG_ENABLED` 改成 `true`,并补充 `NACOS_SERVER_ADDR`、`NACOS_NAMESPACE`、`NACOS_GROUP`、`NACOS_USERNAME`、`NACOS_PASSWORD`。端口、Kafka topic、接入服务冷备 archive 路径、TDengine 连接建议仍保留为启动环境变量,便于 Portainer、launchctl 和临时压测保持一致。
|
||||||
|
|
||||||
history 消费者会按协议和 raw/event 自动拆分 consumer group。设置 `KAFKA_GROUP_HISTORY=vehicle-history-live` 后,实际 group 为:
|
history 消费者会按协议和 raw/event 自动拆分 consumer group。设置 `KAFKA_GROUP_HISTORY=vehicle-history-live` 后,实际 group 为:
|
||||||
|
|||||||
87
deploy/local/launchctl/render.py
Normal file
87
deploy/local/launchctl/render.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import html
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SERVICES = (
|
||||||
|
"gb32960",
|
||||||
|
"jt808",
|
||||||
|
"yutong-mqtt",
|
||||||
|
"vehicle-history",
|
||||||
|
"vehicle-analytics",
|
||||||
|
)
|
||||||
|
|
||||||
|
LOG_DIRS = (
|
||||||
|
"lingniu-gb32960-live",
|
||||||
|
"lingniu-jt808-live",
|
||||||
|
"lingniu-yutong-mqtt-live",
|
||||||
|
"lingniu-vehicle-history-live",
|
||||||
|
"lingniu-vehicle-analytics-live",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def env(name: str, default: str = "") -> str:
|
||||||
|
return os.environ.get(name, default)
|
||||||
|
|
||||||
|
|
||||||
|
def xml_value(value: str) -> str:
|
||||||
|
return html.escape(value, quote=False)
|
||||||
|
|
||||||
|
|
||||||
|
def replacements() -> dict[str, str]:
|
||||||
|
identity_jdbc = env("VEHICLE_IDENTITY_MYSQL_JDBC_URL", "jdbc:mysql://127.0.0.1:3306/vehicle_ingest")
|
||||||
|
identity_username = env("VEHICLE_IDENTITY_MYSQL_USERNAME", "")
|
||||||
|
identity_password = env("VEHICLE_IDENTITY_MYSQL_PASSWORD", "")
|
||||||
|
return {
|
||||||
|
"__PROJECT_ROOT__": env("PROJECT_ROOT", str(Path.cwd())),
|
||||||
|
"__JAVA_BIN__": env("JAVA_BIN", "/usr/bin/java"),
|
||||||
|
"__SHARED_ARCHIVE_PATH__": env("INGEST_ARCHIVE_PATH", str(Path.cwd() / "data/archive-live")),
|
||||||
|
"__TDENGINE_JDBC_URL__": env("TDENGINE_JDBC_URL", "jdbc:TAOS-WS://127.0.0.1:6041/vehicle_ts"),
|
||||||
|
"__TDENGINE_USERNAME__": env("TDENGINE_USERNAME", "root"),
|
||||||
|
"__TDENGINE_PASSWORD__": env("TDENGINE_PASSWORD", ""),
|
||||||
|
"__VEHICLE_IDENTITY_MYSQL_JDBC_URL__": identity_jdbc,
|
||||||
|
"__VEHICLE_IDENTITY_MYSQL_USERNAME__": identity_username,
|
||||||
|
"__VEHICLE_IDENTITY_MYSQL_PASSWORD__": identity_password,
|
||||||
|
"__VEHICLE_IDENTITY_MYSQL_REFRESH_INTERVAL__": env("VEHICLE_IDENTITY_MYSQL_REFRESH_INTERVAL", "60s"),
|
||||||
|
"__MYSQL_JDBC_URL__": env("MYSQL_JDBC_URL", identity_jdbc),
|
||||||
|
"__MYSQL_USERNAME__": env("MYSQL_USERNAME", identity_username),
|
||||||
|
"__MYSQL_PASSWORD__": env("MYSQL_PASSWORD", identity_password),
|
||||||
|
"__YUTONG_MQTT_ENABLED__": env("YUTONG_MQTT_ENABLED", "false"),
|
||||||
|
"__YUTONG_MQTT_URI__": env("YUTONG_MQTT_URI", ""),
|
||||||
|
"__YUTONG_MQTT_TOPIC__": env("YUTONG_MQTT_TOPIC", "#"),
|
||||||
|
"__YUTONG_MQTT_USERNAME__": env("YUTONG_MQTT_USERNAME", ""),
|
||||||
|
"__YUTONG_MQTT_PASSWORD__": env("YUTONG_MQTT_PASSWORD", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(template: str, values: dict[str, str]) -> str:
|
||||||
|
rendered = template
|
||||||
|
for placeholder, value in values.items():
|
||||||
|
rendered = rendered.replace(placeholder, xml_value(value))
|
||||||
|
unresolved = sorted(set(re.findall(r"__[A-Z0-9_]+__", rendered)))
|
||||||
|
if unresolved:
|
||||||
|
raise RuntimeError("unresolved launchctl placeholder(s): " + ", ".join(unresolved))
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
root = Path(env("PROJECT_ROOT", str(Path.cwd()))).resolve()
|
||||||
|
template_dir = Path(__file__).resolve().parent
|
||||||
|
launch_agents = Path.home() / "Library/LaunchAgents"
|
||||||
|
launch_agents.mkdir(parents=True, exist_ok=True)
|
||||||
|
(root / "data").mkdir(parents=True, exist_ok=True)
|
||||||
|
for log_dir in LOG_DIRS:
|
||||||
|
(Path("/tmp") / log_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
values = replacements()
|
||||||
|
for service in SERVICES:
|
||||||
|
template_path = template_dir / f"com.lingniu.{service}.plist.template"
|
||||||
|
output_path = launch_agents / f"com.lingniu.{service}.plist"
|
||||||
|
output_path.write_text(render_template(template_path.read_text(), values), encoding="utf-8")
|
||||||
|
print(output_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -5,6 +5,7 @@ import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
|
|||||||
import org.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -138,6 +139,7 @@ class PortainerComposeResourceLimitsTest {
|
|||||||
String readme = Files.readString(launchctl.resolve("README.md"));
|
String readme = Files.readString(launchctl.resolve("README.md"));
|
||||||
|
|
||||||
assertThat(readme)
|
assertThat(readme)
|
||||||
|
.contains("python3 deploy/local/launchctl/render.py")
|
||||||
.contains("gb32960 jt808 yutong-mqtt vehicle-history vehicle-analytics")
|
.contains("gb32960 jt808 yutong-mqtt vehicle-history vehicle-analytics")
|
||||||
.contains("mkdir -p /tmp/lingniu-gb32960-live /tmp/lingniu-jt808-live /tmp/lingniu-yutong-mqtt-live /tmp/lingniu-vehicle-history-live /tmp/lingniu-vehicle-analytics-live")
|
.contains("mkdir -p /tmp/lingniu-gb32960-live /tmp/lingniu-jt808-live /tmp/lingniu-yutong-mqtt-live /tmp/lingniu-vehicle-history-live /tmp/lingniu-vehicle-analytics-live")
|
||||||
.contains("launchctl bootstrap \"gui/$(id -u)\" \"$HOME/Library/LaunchAgents/com.lingniu.yutong-mqtt.plist\"")
|
.contains("launchctl bootstrap \"gui/$(id -u)\" \"$HOME/Library/LaunchAgents/com.lingniu.yutong-mqtt.plist\"")
|
||||||
@@ -147,6 +149,7 @@ class PortainerComposeResourceLimitsTest {
|
|||||||
.contains("launchctl list | rg 'com.lingniu.(gb32960|jt808|yutong-mqtt|vehicle-history|vehicle-analytics)'")
|
.contains("launchctl list | rg 'com.lingniu.(gb32960|jt808|yutong-mqtt|vehicle-history|vehicle-analytics)'")
|
||||||
.contains("Yutong MQTT ingest: `http://127.0.0.1:20500/swagger-ui/index.html`")
|
.contains("Yutong MQTT ingest: `http://127.0.0.1:20500/swagger-ui/index.html`")
|
||||||
.contains("Analytics metrics: `http://127.0.0.1:20300/swagger-ui/index.html`")
|
.contains("Analytics metrics: `http://127.0.0.1:20300/swagger-ui/index.html`")
|
||||||
|
.doesNotContain("sed \\")
|
||||||
.doesNotContain("三个服务");
|
.doesNotContain("三个服务");
|
||||||
|
|
||||||
assertLaunchctlTemplate(launchctl, "gb32960", "gb32960-ingest-app", "20100");
|
assertLaunchctlTemplate(launchctl, "gb32960", "gb32960-ingest-app", "20100");
|
||||||
@@ -156,6 +159,62 @@ class PortainerComposeResourceLimitsTest {
|
|||||||
assertLaunchctlTemplate(launchctl, "vehicle-analytics", "vehicle-analytics-app", "20300");
|
assertLaunchctlTemplate(launchctl, "vehicle-analytics", "vehicle-analytics-app", "20300");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void localLaunchctlRendererPreservesSpecialCharactersSafely() throws Exception {
|
||||||
|
Path root = repositoryRoot();
|
||||||
|
Path launchctl = root.resolve("deploy/local/launchctl");
|
||||||
|
Path home = Files.createTempDirectory("lingniu-launchctl-home");
|
||||||
|
|
||||||
|
ProcessBuilder processBuilder = new ProcessBuilder(
|
||||||
|
"python3",
|
||||||
|
launchctl.resolve("render.py").toString());
|
||||||
|
processBuilder.directory(root.toFile());
|
||||||
|
processBuilder.environment().put("HOME", home.toString());
|
||||||
|
processBuilder.environment().put("PROJECT_ROOT", "/tmp/lingniu project & live");
|
||||||
|
processBuilder.environment().put("JAVA_BIN", "/usr/bin/java");
|
||||||
|
processBuilder.environment().put("INGEST_ARCHIVE_PATH", "/tmp/archive&live__v1");
|
||||||
|
processBuilder.environment().put("TDENGINE_JDBC_URL", "jdbc:TAOS-WS://host:6041/vehicle_ts");
|
||||||
|
processBuilder.environment().put("TDENGINE_USERNAME", "root");
|
||||||
|
processBuilder.environment().put("TDENGINE_PASSWORD", "td&engine#pwd");
|
||||||
|
processBuilder.environment().put("VEHICLE_IDENTITY_MYSQL_JDBC_URL",
|
||||||
|
"jdbc:mysql://host:3306/vehicle_ingest?useSSL=false&serverTimezone=Asia/Shanghai");
|
||||||
|
processBuilder.environment().put("VEHICLE_IDENTITY_MYSQL_USERNAME", "lingniu_vehicle");
|
||||||
|
processBuilder.environment().put("VEHICLE_IDENTITY_MYSQL_PASSWORD", "id&pwd#1");
|
||||||
|
processBuilder.environment().put("MYSQL_JDBC_URL",
|
||||||
|
"jdbc:mysql://host:3306/vehicle_stat?useSSL=false&serverTimezone=Asia/Shanghai");
|
||||||
|
processBuilder.environment().put("MYSQL_USERNAME", "stat_user");
|
||||||
|
processBuilder.environment().put("MYSQL_PASSWORD", "stat&pwd#2");
|
||||||
|
processBuilder.environment().put("YUTONG_MQTT_ENABLED", "true");
|
||||||
|
processBuilder.environment().put("YUTONG_MQTT_URI", "ssl://mqtt.example.com:8883");
|
||||||
|
processBuilder.environment().put("YUTONG_MQTT_TOPIC", "#");
|
||||||
|
processBuilder.environment().put("YUTONG_MQTT_USERNAME", "mqtt_user");
|
||||||
|
processBuilder.environment().put("YUTONG_MQTT_PASSWORD", "mqtt&pwd#3");
|
||||||
|
|
||||||
|
Process process = processBuilder.start();
|
||||||
|
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8)
|
||||||
|
+ new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
assertThat(process.waitFor()).as(output).isZero();
|
||||||
|
|
||||||
|
Path launchAgents = home.resolve("Library/LaunchAgents");
|
||||||
|
String mqtt = Files.readString(launchAgents.resolve("com.lingniu.yutong-mqtt.plist"));
|
||||||
|
String analytics = Files.readString(launchAgents.resolve("com.lingniu.vehicle-analytics.plist"));
|
||||||
|
|
||||||
|
assertThat(mqtt)
|
||||||
|
.contains("<string>#</string>")
|
||||||
|
.contains("<string>mqtt&pwd#3</string>")
|
||||||
|
.contains("<string>/tmp/archive&live__v1</string>")
|
||||||
|
.doesNotContain("__YUTONG_MQTT_TOPIC__")
|
||||||
|
.doesNotContain("__YUTONG_MQTT_PASSWORD__");
|
||||||
|
assertThat(analytics)
|
||||||
|
.contains("<string>stat&pwd#2</string>")
|
||||||
|
.doesNotContain("__MYSQL_PASSWORD__");
|
||||||
|
assertThat(Files.list(launchAgents)
|
||||||
|
.filter(path -> path.getFileName().toString().endsWith(".plist"))
|
||||||
|
.count())
|
||||||
|
.isEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void tdengineVerificationRunbookChecksLivenessAndReadinessForManagedApps() throws IOException {
|
void tdengineVerificationRunbookChecksLivenessAndReadinessForManagedApps() throws IOException {
|
||||||
String runbook = Files.readString(repositoryRoot()
|
String runbook = Files.readString(repositoryRoot()
|
||||||
|
|||||||
Reference in New Issue
Block a user