wattetheria 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.release.example +43 -0
- package/LICENSE +201 -0
- package/README.md +736 -0
- package/bin/wattetheria.js +9 -0
- package/docker-compose.release.yml +165 -0
- package/lib/cli.js +423 -0
- package/package.json +34 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Release deployment stack: wattetheria + wattswarm.
|
|
2
|
+
#
|
|
3
|
+
# This file is intended for published image deployment.
|
|
4
|
+
# It should not depend on sibling local repositories or local Docker builds.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# export WATTSWARM_PG_PASSWORD='<strong-password>'
|
|
8
|
+
# docker compose -f docker-compose.release.yml up -d
|
|
9
|
+
#
|
|
10
|
+
# Notes:
|
|
11
|
+
# - Use coordinated image tags for wattetheria and wattswarm.
|
|
12
|
+
# - Prefer pinning image digests for production-grade reproducibility.
|
|
13
|
+
# - Avoid `container_name` so multiple environments and rolling upgrades remain possible.
|
|
14
|
+
|
|
15
|
+
services:
|
|
16
|
+
kernel:
|
|
17
|
+
image: ${WATTETHERIA_KERNEL_IMAGE:-wattetheria/wattetheria-kernel:latest}
|
|
18
|
+
restart: unless-stopped
|
|
19
|
+
depends_on:
|
|
20
|
+
wattswarm-kernel:
|
|
21
|
+
condition: service_started
|
|
22
|
+
environment:
|
|
23
|
+
WATTETHERIA_DATA_DIR: /var/lib/wattetheria
|
|
24
|
+
WATTETHERIA_CONTROL_PLANE_BIND: 0.0.0.0:7777
|
|
25
|
+
WATTETHERIA_WATTSWARM_UI_BASE_URL: http://wattswarm-kernel:7788
|
|
26
|
+
WATTETHERIA_WATTSWARM_SYNC_GRPC_ENDPOINT: http://wattswarm-kernel:7791
|
|
27
|
+
WATTETHERIA_BRAIN_PROVIDER_KIND: ${WATTETHERIA_BRAIN_PROVIDER_KIND:-rules}
|
|
28
|
+
WATTETHERIA_BRAIN_BASE_URL: ${WATTETHERIA_BRAIN_BASE_URL:-}
|
|
29
|
+
WATTETHERIA_BRAIN_MODEL: ${WATTETHERIA_BRAIN_MODEL:-}
|
|
30
|
+
WATTETHERIA_BRAIN_API_KEY_ENV: ${WATTETHERIA_BRAIN_API_KEY_ENV:-}
|
|
31
|
+
WATTETHERIA_SERVICENET_BASE_URL: ${WATTETHERIA_SERVICENET_BASE_URL:-}
|
|
32
|
+
WATTETHERIA_AUTONOMY_ENABLED: ${WATTETHERIA_AUTONOMY_ENABLED:-false}
|
|
33
|
+
WATTETHERIA_AUTONOMY_INTERVAL_SEC: ${WATTETHERIA_AUTONOMY_INTERVAL_SEC:-30}
|
|
34
|
+
OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-}
|
|
35
|
+
volumes:
|
|
36
|
+
- wattetheria_state:/var/lib/wattetheria
|
|
37
|
+
ports:
|
|
38
|
+
- "${WATTETHERIA_CONTROL_PLANE_BIND_HOST:-127.0.0.1}:${WATTETHERIA_CONTROL_PLANE_PORT:-7777}:7777"
|
|
39
|
+
networks:
|
|
40
|
+
- watt-internal
|
|
41
|
+
entrypoint: ["/app/scripts/docker-kernel-entrypoint.sh"]
|
|
42
|
+
|
|
43
|
+
observatory:
|
|
44
|
+
image: ${WATTETHERIA_OBSERVATORY_IMAGE:-wattetheria/wattetheria-observatory:latest}
|
|
45
|
+
restart: unless-stopped
|
|
46
|
+
depends_on:
|
|
47
|
+
- kernel
|
|
48
|
+
environment:
|
|
49
|
+
PORT: 8787
|
|
50
|
+
ports:
|
|
51
|
+
- "${WATTETHERIA_OBSERVATORY_BIND_HOST:-127.0.0.1}:${WATTETHERIA_OBSERVATORY_PORT:-8780}:8787"
|
|
52
|
+
networks:
|
|
53
|
+
- watt-internal
|
|
54
|
+
entrypoint: ["/app/scripts/docker-observatory-entrypoint.sh"]
|
|
55
|
+
|
|
56
|
+
wattswarm-postgres:
|
|
57
|
+
image: postgres:16
|
|
58
|
+
restart: unless-stopped
|
|
59
|
+
environment:
|
|
60
|
+
POSTGRES_DB: ${WATTSWARM_PG_DB:-wattswarm}
|
|
61
|
+
POSTGRES_USER: ${WATTSWARM_PG_USER:-postgres}
|
|
62
|
+
POSTGRES_PASSWORD: ${WATTSWARM_PG_PASSWORD:?WATTSWARM_PG_PASSWORD must be set}
|
|
63
|
+
volumes:
|
|
64
|
+
- wattswarm_pg_data:/var/lib/postgresql/data
|
|
65
|
+
healthcheck:
|
|
66
|
+
test: ["CMD-SHELL", "pg_isready -U ${WATTSWARM_PG_USER:-postgres} -d ${WATTSWARM_PG_DB:-wattswarm}"]
|
|
67
|
+
interval: 5s
|
|
68
|
+
timeout: 3s
|
|
69
|
+
retries: 20
|
|
70
|
+
networks:
|
|
71
|
+
- watt-internal
|
|
72
|
+
|
|
73
|
+
wattswarm-runtime:
|
|
74
|
+
image: ${WATTSWARM_RUNTIME_IMAGE:-wattetheria/wattswarm-runtime:latest}
|
|
75
|
+
restart: unless-stopped
|
|
76
|
+
entrypoint: ["/app/target/release/wattswarm-runtime"]
|
|
77
|
+
command: ["--listen", "0.0.0.0:8787"]
|
|
78
|
+
healthcheck:
|
|
79
|
+
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8787/health > /dev/null"]
|
|
80
|
+
interval: 5s
|
|
81
|
+
timeout: 3s
|
|
82
|
+
retries: 20
|
|
83
|
+
start_period: 5s
|
|
84
|
+
networks:
|
|
85
|
+
- watt-internal
|
|
86
|
+
|
|
87
|
+
wattswarm-kernel:
|
|
88
|
+
image: ${WATTSWARM_KERNEL_IMAGE:-wattetheria/wattswarm-kernel:latest}
|
|
89
|
+
restart: unless-stopped
|
|
90
|
+
depends_on:
|
|
91
|
+
wattswarm-postgres:
|
|
92
|
+
condition: service_healthy
|
|
93
|
+
wattswarm-runtime:
|
|
94
|
+
condition: service_healthy
|
|
95
|
+
environment:
|
|
96
|
+
WATTSWARM_PG_URL: postgres://${WATTSWARM_PG_USER:-postgres}:${WATTSWARM_PG_PASSWORD:?WATTSWARM_PG_PASSWORD must be set}@wattswarm-postgres:5432/${WATTSWARM_PG_DB:-wattswarm}
|
|
97
|
+
WATTSWARM_STATE_DIR: /var/lib/wattswarm
|
|
98
|
+
WATTSWARM_STORE_NAME: wattswarm.state
|
|
99
|
+
WATTSWARM_UI_LISTEN: 0.0.0.0:7788
|
|
100
|
+
WATTSWARM_WATTETHERIA_SYNC_GRPC_LISTEN: "0.0.0.0:7791"
|
|
101
|
+
WATTSWARM_P2P_ENABLED: ${WATTSWARM_P2P_ENABLED:-true}
|
|
102
|
+
WATTSWARM_P2P_MDNS: ${WATTSWARM_P2P_MDNS:-true}
|
|
103
|
+
WATTSWARM_P2P_PORT: ${WATTSWARM_P2P_PORT:-4001}
|
|
104
|
+
WATTSWARM_BOOTSTRAP_EXECUTOR_NAME: rt
|
|
105
|
+
WATTSWARM_BOOTSTRAP_EXECUTOR_URL: http://wattswarm-runtime:8787
|
|
106
|
+
WATTSWARM_UDP_ANNOUNCE_ENABLED: ${WATTSWARM_UDP_ANNOUNCE_ENABLED:-false}
|
|
107
|
+
WATTSWARM_UDP_ANNOUNCE_MODE: ${WATTSWARM_UDP_ANNOUNCE_MODE:-multicast}
|
|
108
|
+
WATTSWARM_UDP_ANNOUNCE_ADDR: ${WATTSWARM_UDP_ANNOUNCE_ADDR:-239.255.42.99}
|
|
109
|
+
WATTSWARM_UDP_ANNOUNCE_PORT: ${WATTSWARM_UDP_ANNOUNCE_PORT:-37931}
|
|
110
|
+
volumes:
|
|
111
|
+
- wattswarm_state_data:/var/lib/wattswarm
|
|
112
|
+
ports:
|
|
113
|
+
- "${WATTSWARM_UI_BIND_HOST:-127.0.0.1}:${WATTSWARM_UI_PORT:-7788}:7788"
|
|
114
|
+
- "${WATTSWARM_P2P_HOST_PORT:-4001}:${WATTSWARM_P2P_PORT:-4001}"
|
|
115
|
+
- "${WATTSWARM_UDP_ANNOUNCE_HOST_PORT:-37931}:${WATTSWARM_UDP_ANNOUNCE_PORT:-37931}/udp"
|
|
116
|
+
networks:
|
|
117
|
+
- watt-internal
|
|
118
|
+
entrypoint: ["/app/scripts/docker-kernel-entrypoint.sh"]
|
|
119
|
+
|
|
120
|
+
wattswarm-worker:
|
|
121
|
+
image: ${WATTSWARM_WORKER_IMAGE:-wattetheria/wattswarm-worker:latest}
|
|
122
|
+
restart: unless-stopped
|
|
123
|
+
depends_on:
|
|
124
|
+
wattswarm-postgres:
|
|
125
|
+
condition: service_healthy
|
|
126
|
+
wattswarm-runtime:
|
|
127
|
+
condition: service_healthy
|
|
128
|
+
wattswarm-kernel:
|
|
129
|
+
condition: service_started
|
|
130
|
+
environment:
|
|
131
|
+
WATTSWARM_PG_URL: postgres://${WATTSWARM_PG_USER:-postgres}:${WATTSWARM_PG_PASSWORD:?WATTSWARM_PG_PASSWORD must be set}@wattswarm-postgres:5432/${WATTSWARM_PG_DB:-wattswarm}
|
|
132
|
+
WATTSWARM_STATE_DIR: /var/lib/wattswarm
|
|
133
|
+
WATTSWARM_STORE_NAME: wattswarm.state
|
|
134
|
+
WATTSWARM_WORKER_CONCURRENCY: ${WATTSWARM_WORKER_CONCURRENCY:-16}
|
|
135
|
+
WATTSWARM_WORKER_POLL_MS: ${WATTSWARM_WORKER_POLL_MS:-250}
|
|
136
|
+
WATTSWARM_WORKER_LEASE_MS: ${WATTSWARM_WORKER_LEASE_MS:-30000}
|
|
137
|
+
volumes:
|
|
138
|
+
- wattswarm_state_data:/var/lib/wattswarm
|
|
139
|
+
networks:
|
|
140
|
+
- watt-internal
|
|
141
|
+
entrypoint: ["/app/target/release/wattswarm"]
|
|
142
|
+
command:
|
|
143
|
+
- --state-dir
|
|
144
|
+
- /var/lib/wattswarm
|
|
145
|
+
- --store
|
|
146
|
+
- wattswarm.state
|
|
147
|
+
- run
|
|
148
|
+
- --pg-url
|
|
149
|
+
- postgres://${WATTSWARM_PG_USER:-postgres}:${WATTSWARM_PG_PASSWORD:?WATTSWARM_PG_PASSWORD must be set}@wattswarm-postgres:5432/${WATTSWARM_PG_DB:-wattswarm}
|
|
150
|
+
- worker
|
|
151
|
+
- --concurrency
|
|
152
|
+
- "${WATTSWARM_WORKER_CONCURRENCY:-16}"
|
|
153
|
+
- --poll-ms
|
|
154
|
+
- "${WATTSWARM_WORKER_POLL_MS:-250}"
|
|
155
|
+
- --lease-ms
|
|
156
|
+
- "${WATTSWARM_WORKER_LEASE_MS:-30000}"
|
|
157
|
+
|
|
158
|
+
volumes:
|
|
159
|
+
wattetheria_state:
|
|
160
|
+
wattswarm_pg_data:
|
|
161
|
+
wattswarm_state_data:
|
|
162
|
+
|
|
163
|
+
networks:
|
|
164
|
+
watt-internal:
|
|
165
|
+
driver: bridge
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { spawnSync } = require("node:child_process");
|
|
6
|
+
|
|
7
|
+
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
8
|
+
const PACKAGE_JSON = require(path.join(PACKAGE_ROOT, "package.json"));
|
|
9
|
+
const DEFAULT_DEPLOY_DIR = path.join(os.homedir(), ".wattetheria", "deploy");
|
|
10
|
+
const DEFAULT_PROJECT_NAME = "wattetheria";
|
|
11
|
+
const DEFAULT_COMMAND = "install";
|
|
12
|
+
const IMAGE_KEYS = [
|
|
13
|
+
"WATTETHERIA_KERNEL_IMAGE",
|
|
14
|
+
"WATTETHERIA_OBSERVATORY_IMAGE",
|
|
15
|
+
"WATTSWARM_KERNEL_IMAGE",
|
|
16
|
+
"WATTSWARM_RUNTIME_IMAGE",
|
|
17
|
+
"WATTSWARM_WORKER_IMAGE"
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function printHelp() {
|
|
21
|
+
console.log(`Wattetheria CLI ${PACKAGE_JSON.version}
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
npx wattetheria [command] [options]
|
|
25
|
+
npx wattetheria install
|
|
26
|
+
|
|
27
|
+
Commands:
|
|
28
|
+
install Prepare deployment, pull images, and start the stack
|
|
29
|
+
start Start an existing deployment
|
|
30
|
+
status Show docker compose status
|
|
31
|
+
update Update image tags, pull, and restart
|
|
32
|
+
stop Stop the deployment
|
|
33
|
+
uninstall Stop the deployment and optionally remove volumes
|
|
34
|
+
logs Show docker compose logs
|
|
35
|
+
doctor Check local prerequisites
|
|
36
|
+
help Show this help
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--dir <path> Deployment directory (default: ${DEFAULT_DEPLOY_DIR})
|
|
40
|
+
--project-name <name> Docker compose project name (default: ${DEFAULT_PROJECT_NAME})
|
|
41
|
+
--tag <tag> Override all release image tags
|
|
42
|
+
--force Refresh deployment templates from package assets
|
|
43
|
+
--no-health-checks Skip HTTP health checks
|
|
44
|
+
--volumes With uninstall, remove named docker volumes
|
|
45
|
+
--purge With uninstall, remove the deployment directory
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
let command = DEFAULT_COMMAND;
|
|
51
|
+
let index = 0;
|
|
52
|
+
if (argv[0] && !argv[0].startsWith("-")) {
|
|
53
|
+
command = argv[0];
|
|
54
|
+
index = 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const options = {
|
|
58
|
+
dir: DEFAULT_DEPLOY_DIR,
|
|
59
|
+
projectName: DEFAULT_PROJECT_NAME,
|
|
60
|
+
tag: PACKAGE_JSON.version,
|
|
61
|
+
force: false,
|
|
62
|
+
healthChecks: true,
|
|
63
|
+
volumes: false,
|
|
64
|
+
purge: false,
|
|
65
|
+
composeArgs: []
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
while (index < argv.length) {
|
|
69
|
+
const arg = argv[index];
|
|
70
|
+
if (arg === "--dir") {
|
|
71
|
+
options.dir = requireValue(arg, argv[++index]);
|
|
72
|
+
} else if (arg === "--project-name") {
|
|
73
|
+
options.projectName = requireValue(arg, argv[++index]);
|
|
74
|
+
} else if (arg === "--tag") {
|
|
75
|
+
options.tag = requireValue(arg, argv[++index]);
|
|
76
|
+
} else if (arg === "--force") {
|
|
77
|
+
options.force = true;
|
|
78
|
+
} else if (arg === "--no-health-checks") {
|
|
79
|
+
options.healthChecks = false;
|
|
80
|
+
} else if (arg === "--volumes") {
|
|
81
|
+
options.volumes = true;
|
|
82
|
+
} else if (arg === "--purge") {
|
|
83
|
+
options.purge = true;
|
|
84
|
+
} else if (arg === "--") {
|
|
85
|
+
options.composeArgs = argv.slice(index + 1);
|
|
86
|
+
break;
|
|
87
|
+
} else if (command === "logs") {
|
|
88
|
+
options.composeArgs.push(arg);
|
|
89
|
+
} else if (command === "help") {
|
|
90
|
+
break;
|
|
91
|
+
} else {
|
|
92
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
93
|
+
}
|
|
94
|
+
index += 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { command, options };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function requireValue(flag, value) {
|
|
101
|
+
if (!value || value.startsWith("-")) {
|
|
102
|
+
throw new Error(`Missing value for ${flag}`);
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ensureCommandAvailable(command, helpText) {
|
|
108
|
+
const result = spawnSync(command, ["--version"], { stdio: "ignore" });
|
|
109
|
+
if (result.error || result.status !== 0) {
|
|
110
|
+
throw new Error(helpText);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function ensureDockerAvailable() {
|
|
115
|
+
ensureCommandAvailable(
|
|
116
|
+
"docker",
|
|
117
|
+
"Docker is required. Install Docker Desktop or another Docker-compatible runtime first."
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const compose = spawnSync("docker", ["compose", "version"], {
|
|
121
|
+
stdio: "ignore"
|
|
122
|
+
});
|
|
123
|
+
if (compose.error || compose.status !== 0) {
|
|
124
|
+
throw new Error("Docker Compose v2 is required.");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const info = spawnSync("docker", ["info"], { stdio: "ignore" });
|
|
128
|
+
if (info.error || info.status !== 0) {
|
|
129
|
+
throw new Error("Docker daemon is not reachable. Start Docker Desktop or the Docker service first.");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ensureDeploymentAssets(options) {
|
|
134
|
+
fs.mkdirSync(options.dir, { recursive: true });
|
|
135
|
+
|
|
136
|
+
const templateEnvPath = path.join(PACKAGE_ROOT, ".env.release.example");
|
|
137
|
+
const templateComposePath = path.join(PACKAGE_ROOT, "docker-compose.release.yml");
|
|
138
|
+
const targetEnvPath = envFilePath(options);
|
|
139
|
+
const targetComposePath = composeFilePath(options);
|
|
140
|
+
|
|
141
|
+
if (options.force || !fs.existsSync(targetEnvPath)) {
|
|
142
|
+
fs.copyFileSync(templateEnvPath, targetEnvPath);
|
|
143
|
+
}
|
|
144
|
+
if (options.force || !fs.existsSync(targetComposePath)) {
|
|
145
|
+
fs.copyFileSync(templateComposePath, targetComposePath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const envMap = readEnvFile(targetEnvPath);
|
|
149
|
+
ensureDatabasePassword(envMap);
|
|
150
|
+
if (options.tag) {
|
|
151
|
+
pinImageTags(envMap, options.tag);
|
|
152
|
+
}
|
|
153
|
+
writeEnvFile(targetEnvPath, envMap);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function envFilePath(options) {
|
|
157
|
+
return path.join(options.dir, ".env");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function composeFilePath(options) {
|
|
161
|
+
return path.join(options.dir, "docker-compose.yml");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readEnvFile(filePath) {
|
|
165
|
+
const envMap = new Map();
|
|
166
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
167
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
168
|
+
const line = rawLine.trim();
|
|
169
|
+
if (!line || line.startsWith("#")) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const separator = rawLine.indexOf("=");
|
|
173
|
+
if (separator < 0) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const key = rawLine.slice(0, separator).trim();
|
|
177
|
+
const value = rawLine.slice(separator + 1);
|
|
178
|
+
envMap.set(key, value);
|
|
179
|
+
}
|
|
180
|
+
return envMap;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function writeEnvFile(filePath, envMap) {
|
|
184
|
+
const lines = [];
|
|
185
|
+
for (const [key, value] of envMap.entries()) {
|
|
186
|
+
lines.push(`${key}=${value}`);
|
|
187
|
+
}
|
|
188
|
+
fs.writeFileSync(filePath, `${lines.join("\n")}\n`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function ensureDatabasePassword(envMap) {
|
|
192
|
+
const placeholder = "replace-with-strong-password";
|
|
193
|
+
const current = envMap.get("WATTSWARM_PG_PASSWORD");
|
|
194
|
+
if (!current || current === placeholder) {
|
|
195
|
+
envMap.set("WATTSWARM_PG_PASSWORD", randomPassword());
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function pinImageTags(envMap, tag) {
|
|
200
|
+
for (const key of IMAGE_KEYS) {
|
|
201
|
+
if (!envMap.has(key)) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const current = envMap.get(key);
|
|
205
|
+
const index = current.lastIndexOf(":");
|
|
206
|
+
if (index <= current.lastIndexOf("/")) {
|
|
207
|
+
envMap.set(key, `${current}:${tag}`);
|
|
208
|
+
} else {
|
|
209
|
+
envMap.set(key, `${current.slice(0, index + 1)}${tag}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function randomPassword() {
|
|
215
|
+
return crypto
|
|
216
|
+
.randomBytes(24)
|
|
217
|
+
.toString("base64")
|
|
218
|
+
.replace(/\+/g, "A")
|
|
219
|
+
.replace(/\//g, "B")
|
|
220
|
+
.replace(/=/g, "");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function runCompose(options, args, capture = false) {
|
|
224
|
+
const result = spawnSync(
|
|
225
|
+
"docker",
|
|
226
|
+
[
|
|
227
|
+
"compose",
|
|
228
|
+
"--project-name",
|
|
229
|
+
options.projectName,
|
|
230
|
+
"--env-file",
|
|
231
|
+
envFilePath(options),
|
|
232
|
+
"-f",
|
|
233
|
+
composeFilePath(options),
|
|
234
|
+
...args
|
|
235
|
+
],
|
|
236
|
+
{
|
|
237
|
+
stdio: capture ? "pipe" : "inherit",
|
|
238
|
+
encoding: capture ? "utf8" : undefined
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
if (result.error) {
|
|
242
|
+
throw result.error;
|
|
243
|
+
}
|
|
244
|
+
if (result.status !== 0) {
|
|
245
|
+
if (capture && result.stderr) {
|
|
246
|
+
throw new Error(result.stderr.trim() || `docker compose ${args.join(" ")} failed`);
|
|
247
|
+
}
|
|
248
|
+
throw new Error(`docker compose ${args.join(" ")} failed`);
|
|
249
|
+
}
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function waitForHttp(name, url, maxAttempts = 60, delayMs = 2000) {
|
|
254
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
255
|
+
try {
|
|
256
|
+
const response = await fetch(url, { method: "GET" });
|
|
257
|
+
if (response.ok) {
|
|
258
|
+
console.log(`[ok] ${name}: ${url}`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
// retry
|
|
263
|
+
}
|
|
264
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
265
|
+
}
|
|
266
|
+
throw new Error(`Timed out waiting for ${name}: ${url}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getEnvValue(envMap, key, defaultValue) {
|
|
270
|
+
const value = envMap.get(key);
|
|
271
|
+
return value && value.trim() ? value : defaultValue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function runHealthChecks(options) {
|
|
275
|
+
const envMap = readEnvFile(envFilePath(options));
|
|
276
|
+
const kernelHost = getEnvValue(envMap, "WATTETHERIA_CONTROL_PLANE_BIND_HOST", "127.0.0.1");
|
|
277
|
+
const kernelPort = getEnvValue(envMap, "WATTETHERIA_CONTROL_PLANE_PORT", "7777");
|
|
278
|
+
const uiHost = getEnvValue(envMap, "WATTSWARM_UI_BIND_HOST", "127.0.0.1");
|
|
279
|
+
const uiPort = getEnvValue(envMap, "WATTSWARM_UI_PORT", "7788");
|
|
280
|
+
const observatoryHost = getEnvValue(envMap, "WATTETHERIA_OBSERVATORY_BIND_HOST", "127.0.0.1");
|
|
281
|
+
const observatoryPort = getEnvValue(envMap, "WATTETHERIA_OBSERVATORY_PORT", "8780");
|
|
282
|
+
|
|
283
|
+
await waitForHttp("kernel health", `http://${kernelHost}:${kernelPort}/v1/health`);
|
|
284
|
+
await waitForHttp("wattswarm ui", `http://${uiHost}:${uiPort}/`);
|
|
285
|
+
await waitForHttp("observatory health", `http://${observatoryHost}:${observatoryPort}/healthz`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function printSummary(options) {
|
|
289
|
+
const envMap = readEnvFile(envFilePath(options));
|
|
290
|
+
const kernelHost = getEnvValue(envMap, "WATTETHERIA_CONTROL_PLANE_BIND_HOST", "127.0.0.1");
|
|
291
|
+
const kernelPort = getEnvValue(envMap, "WATTETHERIA_CONTROL_PLANE_PORT", "7777");
|
|
292
|
+
const uiHost = getEnvValue(envMap, "WATTSWARM_UI_BIND_HOST", "127.0.0.1");
|
|
293
|
+
const uiPort = getEnvValue(envMap, "WATTSWARM_UI_PORT", "7788");
|
|
294
|
+
const observatoryHost = getEnvValue(envMap, "WATTETHERIA_OBSERVATORY_BIND_HOST", "127.0.0.1");
|
|
295
|
+
const observatoryPort = getEnvValue(envMap, "WATTETHERIA_OBSERVATORY_PORT", "8780");
|
|
296
|
+
|
|
297
|
+
console.log("");
|
|
298
|
+
console.log("Deployment complete.");
|
|
299
|
+
console.log(`Kernel: http://${kernelHost}:${kernelPort}`);
|
|
300
|
+
console.log(`Wattswarm UI: http://${uiHost}:${uiPort}`);
|
|
301
|
+
console.log(`Observatory: http://${observatoryHost}:${observatoryPort}`);
|
|
302
|
+
console.log(`Deploy dir: ${options.dir}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function install(options) {
|
|
306
|
+
ensureDockerAvailable();
|
|
307
|
+
ensureDeploymentAssets(options);
|
|
308
|
+
runCompose(options, ["config"], true);
|
|
309
|
+
console.log("Pulling release images...");
|
|
310
|
+
runCompose(options, ["pull"]);
|
|
311
|
+
console.log("Starting release stack...");
|
|
312
|
+
runCompose(options, ["up", "-d"]);
|
|
313
|
+
if (options.healthChecks) {
|
|
314
|
+
await runHealthChecks(options);
|
|
315
|
+
}
|
|
316
|
+
printSummary(options);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function start(options) {
|
|
320
|
+
ensureDockerAvailable();
|
|
321
|
+
if (!fs.existsSync(composeFilePath(options)) || !fs.existsSync(envFilePath(options))) {
|
|
322
|
+
throw new Error("Deployment is not initialized. Run install first.");
|
|
323
|
+
}
|
|
324
|
+
runCompose(options, ["up", "-d"]);
|
|
325
|
+
if (options.healthChecks) {
|
|
326
|
+
await runHealthChecks(options);
|
|
327
|
+
}
|
|
328
|
+
printSummary(options);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function status(options) {
|
|
332
|
+
ensureDockerAvailable();
|
|
333
|
+
runCompose(options, ["ps"]);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function update(options) {
|
|
337
|
+
ensureDockerAvailable();
|
|
338
|
+
if (!fs.existsSync(composeFilePath(options)) || !fs.existsSync(envFilePath(options))) {
|
|
339
|
+
throw new Error("Deployment is not initialized. Run install first.");
|
|
340
|
+
}
|
|
341
|
+
ensureDeploymentAssets(options);
|
|
342
|
+
console.log("Pulling updated images...");
|
|
343
|
+
runCompose(options, ["pull"]);
|
|
344
|
+
console.log("Restarting release stack...");
|
|
345
|
+
runCompose(options, ["up", "-d"]);
|
|
346
|
+
if (options.healthChecks) {
|
|
347
|
+
await runHealthChecks(options);
|
|
348
|
+
}
|
|
349
|
+
printSummary(options);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function stop(options) {
|
|
353
|
+
ensureDockerAvailable();
|
|
354
|
+
runCompose(options, ["down"]);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function uninstall(options) {
|
|
358
|
+
ensureDockerAvailable();
|
|
359
|
+
const args = ["down"];
|
|
360
|
+
if (options.volumes) {
|
|
361
|
+
args.push("-v");
|
|
362
|
+
}
|
|
363
|
+
runCompose(options, args);
|
|
364
|
+
if (options.purge && fs.existsSync(options.dir)) {
|
|
365
|
+
fs.rmSync(options.dir, { recursive: true, force: true });
|
|
366
|
+
console.log(`Removed deployment directory: ${options.dir}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function logs(options) {
|
|
371
|
+
ensureDockerAvailable();
|
|
372
|
+
runCompose(options, ["logs", ...options.composeArgs]);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function doctor() {
|
|
376
|
+
ensureDockerAvailable();
|
|
377
|
+
console.log("Docker runtime is available.");
|
|
378
|
+
console.log(`Node.js ${process.version} is available.`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function run(argv) {
|
|
382
|
+
const { command, options } = parseArgs(argv);
|
|
383
|
+
|
|
384
|
+
switch (command) {
|
|
385
|
+
case "install":
|
|
386
|
+
await install(options);
|
|
387
|
+
return;
|
|
388
|
+
case "start":
|
|
389
|
+
case "up":
|
|
390
|
+
await start(options);
|
|
391
|
+
return;
|
|
392
|
+
case "status":
|
|
393
|
+
status(options);
|
|
394
|
+
return;
|
|
395
|
+
case "update":
|
|
396
|
+
await update(options);
|
|
397
|
+
return;
|
|
398
|
+
case "stop":
|
|
399
|
+
case "down":
|
|
400
|
+
stop(options);
|
|
401
|
+
return;
|
|
402
|
+
case "uninstall":
|
|
403
|
+
uninstall(options);
|
|
404
|
+
return;
|
|
405
|
+
case "logs":
|
|
406
|
+
logs(options);
|
|
407
|
+
return;
|
|
408
|
+
case "doctor":
|
|
409
|
+
doctor();
|
|
410
|
+
return;
|
|
411
|
+
case "help":
|
|
412
|
+
case "--help":
|
|
413
|
+
case "-h":
|
|
414
|
+
printHelp();
|
|
415
|
+
return;
|
|
416
|
+
default:
|
|
417
|
+
throw new Error(`Unknown command: ${command}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
module.exports = {
|
|
422
|
+
run
|
|
423
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wattetheria",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Wattetheria deployment CLI",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"wattetheria": "./bin/wattetheria.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"lib/",
|
|
13
|
+
"docker-compose.release.yml",
|
|
14
|
+
".env.release.example",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"check": "node --check bin/wattetheria.js && node --check lib/cli.js"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"wattetheria",
|
|
29
|
+
"wattswarm",
|
|
30
|
+
"docker",
|
|
31
|
+
"cli",
|
|
32
|
+
"deploy"
|
|
33
|
+
]
|
|
34
|
+
}
|