flowmesh-cli-stack 0.1.0__py3-none-any.whl
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.
- flowmesh_cli_stack/__init__.py +13 -0
- flowmesh_cli_stack/assets/.env.example +204 -0
- flowmesh_cli_stack/assets/compose.yml +201 -0
- flowmesh_cli_stack/assets/docker-bake.hcl +110 -0
- flowmesh_cli_stack/bundle.py +384 -0
- flowmesh_cli_stack/env_schema.py +646 -0
- flowmesh_cli_stack/stack.py +789 -0
- flowmesh_cli_stack/utils.py +137 -0
- flowmesh_cli_stack/worker.py +235 -0
- flowmesh_cli_stack-0.1.0.dist-info/METADATA +25 -0
- flowmesh_cli_stack-0.1.0.dist-info/RECORD +14 -0
- flowmesh_cli_stack-0.1.0.dist-info/WHEEL +5 -0
- flowmesh_cli_stack-0.1.0.dist-info/licenses/LICENSE +202 -0
- flowmesh_cli_stack-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Bundle commands."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tarfile
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from flowmesh.models.nodes import NodeRole
|
|
12
|
+
from flowmesh_cli.core import logging
|
|
13
|
+
from flowmesh_cli.core.typer import get_typer
|
|
14
|
+
from packaging.version import InvalidVersion, Version
|
|
15
|
+
|
|
16
|
+
from . import stack as stack_module
|
|
17
|
+
from .utils import DEFAULT_ENV_FILE, parse_node_role, resolve_package_version
|
|
18
|
+
|
|
19
|
+
app = get_typer(
|
|
20
|
+
help="Package FlowMesh deployments into portable bundles for distribution."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_TLS_SERVER_SUBDIR = "secrets/tls/server"
|
|
24
|
+
_TLS_REDIS_SUBDIR = "secrets/tls/redis"
|
|
25
|
+
_WORKER_CONFIG_FILE = "configs/worker_config.yaml"
|
|
26
|
+
|
|
27
|
+
_SERVER_TLS_SOURCES = (
|
|
28
|
+
Path(_TLS_SERVER_SUBDIR) / "server-ca.pem",
|
|
29
|
+
Path(_TLS_SERVER_SUBDIR) / "server.key",
|
|
30
|
+
Path(_TLS_SERVER_SUBDIR) / "server.pem",
|
|
31
|
+
)
|
|
32
|
+
_REDIS_TLS_CA_SOURCE = Path(_TLS_REDIS_SUBDIR) / "redis-ca.pem"
|
|
33
|
+
_REDIS_TLS_CERT_SOURCES = (
|
|
34
|
+
Path(_TLS_REDIS_SUBDIR) / "redis-server.pem",
|
|
35
|
+
Path(_TLS_REDIS_SUBDIR) / "redis-server.key",
|
|
36
|
+
)
|
|
37
|
+
_WORKER_CONFIG_SOURCE = Path(_WORKER_CONFIG_FILE)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _copy_redis_tls_assets(dest: Path, include_tls: bool, *, ca_only: bool) -> None:
|
|
41
|
+
if not include_tls:
|
|
42
|
+
return
|
|
43
|
+
tls_dir = dest / _TLS_REDIS_SUBDIR
|
|
44
|
+
sources: tuple[Path, ...] = (_REDIS_TLS_CA_SOURCE,)
|
|
45
|
+
if not ca_only:
|
|
46
|
+
sources = sources + _REDIS_TLS_CERT_SOURCES
|
|
47
|
+
copied = False
|
|
48
|
+
missing: list[str] = []
|
|
49
|
+
for src in sources:
|
|
50
|
+
if src.exists():
|
|
51
|
+
tls_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
shutil.copy2(src, tls_dir / src.name)
|
|
53
|
+
copied = True
|
|
54
|
+
else:
|
|
55
|
+
missing.append(src.name)
|
|
56
|
+
if not copied:
|
|
57
|
+
logging.warning(
|
|
58
|
+
"Warning: Redis TLS assets not found; bundle created without TLS."
|
|
59
|
+
)
|
|
60
|
+
elif missing:
|
|
61
|
+
missing_str = ", ".join(missing)
|
|
62
|
+
logging.warning(f"Warning: Redis TLS assets missing: {missing_str}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _copy_server_assets(
|
|
66
|
+
dest: Path, include_tls: bool, role: NodeRole = NodeRole.ROOT
|
|
67
|
+
) -> None:
|
|
68
|
+
if include_tls:
|
|
69
|
+
tls_dir = dest / _TLS_SERVER_SUBDIR
|
|
70
|
+
copied = False
|
|
71
|
+
missing: list[str] = []
|
|
72
|
+
for src in _SERVER_TLS_SOURCES:
|
|
73
|
+
if src.exists():
|
|
74
|
+
tls_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
shutil.copy2(src, tls_dir / src.name)
|
|
76
|
+
copied = True
|
|
77
|
+
else:
|
|
78
|
+
missing.append(src.name)
|
|
79
|
+
if not copied:
|
|
80
|
+
logging.warning(
|
|
81
|
+
"Warning: TLS assets not found; bundle created without TLS."
|
|
82
|
+
)
|
|
83
|
+
elif missing:
|
|
84
|
+
missing_str = ", ".join(missing)
|
|
85
|
+
logging.warning(f"Warning: TLS assets missing: {missing_str}")
|
|
86
|
+
# Worker nodes don't host Redis (compose root profile gates it), so they
|
|
87
|
+
# only need the CA to verify the root's TLS.
|
|
88
|
+
_copy_redis_tls_assets(dest, include_tls, ca_only=role == NodeRole.WORKER)
|
|
89
|
+
|
|
90
|
+
if _WORKER_CONFIG_SOURCE.exists():
|
|
91
|
+
worker_config_dest = dest / _WORKER_CONFIG_FILE
|
|
92
|
+
worker_config_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
shutil.copy2(_WORKER_CONFIG_SOURCE, worker_config_dest)
|
|
94
|
+
else:
|
|
95
|
+
logging.warning(f"Warning: worker config not found: {_WORKER_CONFIG_SOURCE}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _scaffold_server_assets(dest: Path, include_tls: bool) -> None:
|
|
99
|
+
"""Scaffold the bundle directory layout in-place at ``dest``."""
|
|
100
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
|
|
102
|
+
if include_tls:
|
|
103
|
+
for subdir in (_TLS_SERVER_SUBDIR, _TLS_REDIS_SUBDIR):
|
|
104
|
+
target = dest / subdir
|
|
105
|
+
existed = target.is_dir()
|
|
106
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
logging.log(f"{'kept' if existed else 'created'} {subdir}/")
|
|
108
|
+
|
|
109
|
+
worker_config = dest / _WORKER_CONFIG_FILE
|
|
110
|
+
if worker_config.exists():
|
|
111
|
+
logging.log(f"kept {_WORKER_CONFIG_FILE}")
|
|
112
|
+
else:
|
|
113
|
+
worker_config.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
worker_config.touch()
|
|
115
|
+
logging.log(f"created {_WORKER_CONFIG_FILE}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _build_cli_wheels(wheel_dir: Path) -> None:
|
|
119
|
+
"""Build wheels for the CLI and SDK into wheel_dir."""
|
|
120
|
+
wheel_dir.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
repo_root = Path.cwd().resolve()
|
|
122
|
+
packages = [
|
|
123
|
+
repo_root / "sdk",
|
|
124
|
+
repo_root / "sdk" / "stack",
|
|
125
|
+
repo_root / "cli",
|
|
126
|
+
repo_root / "cli" / "stack",
|
|
127
|
+
]
|
|
128
|
+
for pkg in packages:
|
|
129
|
+
result = subprocess.run(
|
|
130
|
+
[
|
|
131
|
+
sys.executable,
|
|
132
|
+
"-m",
|
|
133
|
+
"pip",
|
|
134
|
+
"wheel",
|
|
135
|
+
"--no-deps",
|
|
136
|
+
"--wheel-dir",
|
|
137
|
+
str(wheel_dir),
|
|
138
|
+
str(pkg),
|
|
139
|
+
],
|
|
140
|
+
capture_output=True,
|
|
141
|
+
text=True,
|
|
142
|
+
check=False,
|
|
143
|
+
)
|
|
144
|
+
if result.returncode != 0:
|
|
145
|
+
logging.log(result.stdout)
|
|
146
|
+
logging.log(result.stderr, err=True)
|
|
147
|
+
raise typer.Exit(code=result.returncode)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _published_cli_spec() -> str:
|
|
151
|
+
"""Return the published FlowMesh CLI package spec for this release."""
|
|
152
|
+
package_version = resolve_package_version()
|
|
153
|
+
if package_version is None:
|
|
154
|
+
logging.error("Unable to resolve installed flowmesh-cli-stack version.")
|
|
155
|
+
raise typer.Exit(code=1) from None
|
|
156
|
+
try:
|
|
157
|
+
parsed = Version(package_version)
|
|
158
|
+
except InvalidVersion:
|
|
159
|
+
logging.error(
|
|
160
|
+
f"Installed flowmesh-cli-stack version {package_version!r} is not a "
|
|
161
|
+
"valid PEP 440 version."
|
|
162
|
+
)
|
|
163
|
+
raise typer.Exit(code=1) from None
|
|
164
|
+
if parsed.is_prerelease or parsed.is_devrelease or parsed.local is not None:
|
|
165
|
+
logging.error(
|
|
166
|
+
f"Installed flowmesh-cli-stack version {package_version!r} is not a "
|
|
167
|
+
"published release; the bundle's install.sh would fail on PyPI. "
|
|
168
|
+
"Install a release of flowmesh-cli-stack first, or pass "
|
|
169
|
+
"--include-wheels to bundle local wheels instead."
|
|
170
|
+
)
|
|
171
|
+
raise typer.Exit(code=1)
|
|
172
|
+
# Workspace versions are kept in lock-step by scripts/dev/bump_version.py,
|
|
173
|
+
# so flowmesh-cli-stack's version is also the matching flowmesh-metapackage
|
|
174
|
+
# version on PyPI.
|
|
175
|
+
return f"flowmesh[cli]=={package_version}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _write_install_script(
|
|
179
|
+
dest: Path,
|
|
180
|
+
*,
|
|
181
|
+
package_spec: str | None = None,
|
|
182
|
+
include_wheels: bool = False,
|
|
183
|
+
role: NodeRole = NodeRole.ROOT,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Write an install.sh script to set up a venv and install FlowMesh CLI."""
|
|
186
|
+
script_path = dest / "install.sh"
|
|
187
|
+
role_arg = "" if role == NodeRole.ROOT else f" --role {role.value}"
|
|
188
|
+
deploy_arg = " --deploy"
|
|
189
|
+
if include_wheels:
|
|
190
|
+
install_block = '"$UV_BIN" pip install ./wheels/*.whl'
|
|
191
|
+
else:
|
|
192
|
+
assert package_spec is not None
|
|
193
|
+
install_block = f"""\
|
|
194
|
+
FLOWMESH_PACKAGE_SPEC="${{FLOWMESH_PACKAGE_SPEC:-{package_spec}}}"
|
|
195
|
+
FLOWMESH_INDEX_URL="${{FLOWMESH_INDEX_URL:-}}"
|
|
196
|
+
FLOWMESH_EXTRA_INDEX_URL="${{FLOWMESH_EXTRA_INDEX_URL:-}}"
|
|
197
|
+
|
|
198
|
+
INSTALL_ARGS=("$FLOWMESH_PACKAGE_SPEC")
|
|
199
|
+
if [ -n "$FLOWMESH_INDEX_URL" ]; then
|
|
200
|
+
INSTALL_ARGS=(--index-url "$FLOWMESH_INDEX_URL" "${{INSTALL_ARGS[@]}}")
|
|
201
|
+
fi
|
|
202
|
+
if [ -n "$FLOWMESH_EXTRA_INDEX_URL" ]; then
|
|
203
|
+
INSTALL_ARGS=(--extra-index-url "$FLOWMESH_EXTRA_INDEX_URL" "${{INSTALL_ARGS[@]}}")
|
|
204
|
+
fi
|
|
205
|
+
"$UV_BIN" pip install "${{INSTALL_ARGS[@]}}"
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
script = f"""#!/usr/bin/env bash
|
|
209
|
+
set -euo pipefail
|
|
210
|
+
|
|
211
|
+
# Anchor all relative paths (./wheels, .venv, .env, secrets/, configs/) to
|
|
212
|
+
# the bundle directory so the operator can run this from anywhere.
|
|
213
|
+
cd "$(dirname "$0")"
|
|
214
|
+
|
|
215
|
+
VENV_DIR="${{VENV_DIR:-.venv}}"
|
|
216
|
+
UV_BIN="${{UV_BIN:-uv}}"
|
|
217
|
+
PYTHON_REQ="${{FLOWMESH_PYTHON:-3.12}}"
|
|
218
|
+
ENV_FILE=".env"
|
|
219
|
+
|
|
220
|
+
if ! command -v "$UV_BIN" >/dev/null 2>&1; then
|
|
221
|
+
echo "uv not found; installing..."
|
|
222
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
223
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
224
|
+
UV_BIN="${{UV_BIN:-uv}}"
|
|
225
|
+
fi
|
|
226
|
+
if ! command -v "$UV_BIN" >/dev/null 2>&1; then
|
|
227
|
+
echo "uv install failed or not found in PATH." >&2
|
|
228
|
+
exit 1
|
|
229
|
+
fi
|
|
230
|
+
|
|
231
|
+
"$UV_BIN" python install "$PYTHON_REQ"
|
|
232
|
+
|
|
233
|
+
if [ ! -d "$VENV_DIR" ]; then
|
|
234
|
+
"$UV_BIN" venv "$VENV_DIR" --python "$PYTHON_REQ"
|
|
235
|
+
fi
|
|
236
|
+
|
|
237
|
+
source "$VENV_DIR/bin/activate"
|
|
238
|
+
"$UV_BIN" pip install --upgrade pip
|
|
239
|
+
{install_block}
|
|
240
|
+
echo "Installed flowmesh CLI into $VENV_DIR."
|
|
241
|
+
echo "Activate it with 'source $VENV_DIR/bin/activate'."
|
|
242
|
+
if [ ! -f "$ENV_FILE" ]; then
|
|
243
|
+
flowmesh stack init --env-file "$ENV_FILE"{role_arg}{deploy_arg}
|
|
244
|
+
fi
|
|
245
|
+
echo "Configure $ENV_FILE before executing FlowMesh."
|
|
246
|
+
echo "Then run:"
|
|
247
|
+
echo " flowmesh stack pull"
|
|
248
|
+
echo " flowmesh stack up"
|
|
249
|
+
echo "To stop services:"
|
|
250
|
+
echo " flowmesh stack down"
|
|
251
|
+
"""
|
|
252
|
+
script_path.write_text(script)
|
|
253
|
+
script_path.chmod(script_path.stat().st_mode | 0o111)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _create_bundle_tarball(
|
|
257
|
+
tar_path: Path,
|
|
258
|
+
include_tls: bool,
|
|
259
|
+
*,
|
|
260
|
+
include_wheels: bool,
|
|
261
|
+
role: NodeRole = NodeRole.ROOT,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Create a deployable bundle as a tar.gz with a top-level prefix."""
|
|
264
|
+
tar_path.parent.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
prefix = "flowmesh_server_bundle"
|
|
266
|
+
with tempfile.TemporaryDirectory(prefix="flowmesh-bundle-") as tmp:
|
|
267
|
+
staging_root = Path(tmp) / prefix
|
|
268
|
+
staging_root.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
_copy_server_assets(staging_root, include_tls=include_tls, role=role)
|
|
270
|
+
if include_wheels:
|
|
271
|
+
wheel_dir = staging_root / "wheels"
|
|
272
|
+
_build_cli_wheels(wheel_dir)
|
|
273
|
+
_write_install_script(staging_root, include_wheels=True, role=role)
|
|
274
|
+
else:
|
|
275
|
+
_write_install_script(
|
|
276
|
+
staging_root,
|
|
277
|
+
package_spec=_published_cli_spec(),
|
|
278
|
+
include_wheels=False,
|
|
279
|
+
role=role,
|
|
280
|
+
)
|
|
281
|
+
with tarfile.open(tar_path, mode="w:gz") as tf:
|
|
282
|
+
# Ensure we archive the top-level prefix directory.
|
|
283
|
+
tf.add(staging_root, arcname=prefix)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@app.command("export")
|
|
287
|
+
def bundle_export(
|
|
288
|
+
role: str = typer.Argument(
|
|
289
|
+
NodeRole.ROOT.value,
|
|
290
|
+
help="Target NODE_ROLE for the bundle (root|worker).",
|
|
291
|
+
),
|
|
292
|
+
output: Path = typer.Option(
|
|
293
|
+
None,
|
|
294
|
+
"--output",
|
|
295
|
+
"-o",
|
|
296
|
+
help="Output tar.gz path (default: ./dist/flowmesh_server_bundle.tar.gz)",
|
|
297
|
+
),
|
|
298
|
+
no_tls: bool = typer.Option(False, "--no-tls", help="Exclude TLS assets"),
|
|
299
|
+
include_wheels: bool = typer.Option(
|
|
300
|
+
False,
|
|
301
|
+
"--include-wheels",
|
|
302
|
+
help=(
|
|
303
|
+
"Bundle local CLI/SDK wheels instead of installing "
|
|
304
|
+
"published flowmesh[cli]."
|
|
305
|
+
),
|
|
306
|
+
),
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Create a deployment bundle for the server."""
|
|
309
|
+
node_role = parse_node_role(role)
|
|
310
|
+
logging.info(f"Creating server bundle for role={node_role.value}...")
|
|
311
|
+
tar_path = output
|
|
312
|
+
if tar_path is None:
|
|
313
|
+
tar_path = Path("./dist/flowmesh_server_bundle.tar.gz")
|
|
314
|
+
tar_path.parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
_create_bundle_tarball(
|
|
316
|
+
tar_path,
|
|
317
|
+
include_tls=not no_tls,
|
|
318
|
+
include_wheels=include_wheels,
|
|
319
|
+
role=node_role,
|
|
320
|
+
)
|
|
321
|
+
logging.success(f"Bundle created at {tar_path}")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@app.command("init")
|
|
325
|
+
def bundle_init(
|
|
326
|
+
dest: Path = typer.Option(
|
|
327
|
+
Path("."),
|
|
328
|
+
"--dest",
|
|
329
|
+
help="Directory to scaffold the bundle layout in (default: current dir).",
|
|
330
|
+
),
|
|
331
|
+
no_tls: bool = typer.Option(
|
|
332
|
+
False, "--no-tls", help="Skip TLS placeholder directories."
|
|
333
|
+
),
|
|
334
|
+
env_file: Path = typer.Option(
|
|
335
|
+
DEFAULT_ENV_FILE,
|
|
336
|
+
"--env-file",
|
|
337
|
+
help="Env file to write under --dest (or absolute path).",
|
|
338
|
+
),
|
|
339
|
+
force: bool = typer.Option(
|
|
340
|
+
False,
|
|
341
|
+
"--force",
|
|
342
|
+
"-f",
|
|
343
|
+
help="Overwrite an existing env file without prompting.",
|
|
344
|
+
),
|
|
345
|
+
role: str = typer.Option(
|
|
346
|
+
NodeRole.ROOT.value,
|
|
347
|
+
"--role",
|
|
348
|
+
help="Target NODE_ROLE for the scaffolded .env (root|worker).",
|
|
349
|
+
),
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Scaffold an empty bundle layout in --dest."""
|
|
352
|
+
node_role = parse_node_role(role)
|
|
353
|
+
logging.info(f"Initializing server bundle layout in '{dest}'...")
|
|
354
|
+
_scaffold_server_assets(dest, include_tls=not no_tls)
|
|
355
|
+
resolved_env = env_file if env_file.is_absolute() else dest / env_file
|
|
356
|
+
resolved_env.parent.mkdir(parents=True, exist_ok=True)
|
|
357
|
+
stack_module.init(
|
|
358
|
+
env_file=resolved_env, force=force, role=node_role.value, deploy=True
|
|
359
|
+
)
|
|
360
|
+
# Paths in the next-steps block are intentionally relative to dest so
|
|
361
|
+
# they remain accurate after the user runs the cd line.
|
|
362
|
+
env_hint = env_file if not env_file.is_absolute() else resolved_env
|
|
363
|
+
cd_hint = "" if dest == Path(".") else f" cd {dest}\n"
|
|
364
|
+
env_arg = "" if env_file == DEFAULT_ENV_FILE else f" --env-file {env_hint}"
|
|
365
|
+
if no_tls:
|
|
366
|
+
tls_hint = ""
|
|
367
|
+
elif node_role == NodeRole.WORKER:
|
|
368
|
+
# Worker nodes don't host Redis, so only the CA is needed there.
|
|
369
|
+
tls_hint = (
|
|
370
|
+
f" drop server TLS certs into {_TLS_SERVER_SUBDIR}/ "
|
|
371
|
+
f"and the Redis CA into {_TLS_REDIS_SUBDIR}/redis-ca.pem\n"
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
tls_hint = (
|
|
375
|
+
f" drop TLS certs into {_TLS_SERVER_SUBDIR}/ and {_TLS_REDIS_SUBDIR}/\n"
|
|
376
|
+
)
|
|
377
|
+
logging.success(f"Bundle layout ready at '{dest}'.")
|
|
378
|
+
logging.log(
|
|
379
|
+
f"Next steps:\n{cd_hint}"
|
|
380
|
+
f" edit {env_hint} and {_WORKER_CONFIG_FILE}\n"
|
|
381
|
+
f"{tls_hint}"
|
|
382
|
+
f" flowmesh stack pull{env_arg}\n"
|
|
383
|
+
f" flowmesh stack up{env_arg}"
|
|
384
|
+
)
|