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.
@@ -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
+ )