remdb 0.3.0__py3-none-any.whl → 0.3.114__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.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

Files changed (98) hide show
  1. rem/__init__.py +129 -2
  2. rem/agentic/README.md +76 -0
  3. rem/agentic/__init__.py +15 -0
  4. rem/agentic/agents/__init__.py +16 -2
  5. rem/agentic/agents/sse_simulator.py +500 -0
  6. rem/agentic/context.py +28 -22
  7. rem/agentic/llm_provider_models.py +301 -0
  8. rem/agentic/otel/setup.py +92 -4
  9. rem/agentic/providers/phoenix.py +32 -43
  10. rem/agentic/providers/pydantic_ai.py +142 -22
  11. rem/agentic/schema.py +358 -21
  12. rem/agentic/tools/rem_tools.py +3 -3
  13. rem/api/README.md +238 -1
  14. rem/api/deps.py +255 -0
  15. rem/api/main.py +151 -37
  16. rem/api/mcp_router/resources.py +1 -1
  17. rem/api/mcp_router/server.py +17 -2
  18. rem/api/mcp_router/tools.py +143 -7
  19. rem/api/middleware/tracking.py +172 -0
  20. rem/api/routers/admin.py +277 -0
  21. rem/api/routers/auth.py +124 -0
  22. rem/api/routers/chat/completions.py +152 -16
  23. rem/api/routers/chat/models.py +7 -3
  24. rem/api/routers/chat/sse_events.py +526 -0
  25. rem/api/routers/chat/streaming.py +608 -45
  26. rem/api/routers/dev.py +81 -0
  27. rem/api/routers/feedback.py +148 -0
  28. rem/api/routers/messages.py +473 -0
  29. rem/api/routers/models.py +78 -0
  30. rem/api/routers/query.py +357 -0
  31. rem/api/routers/shared_sessions.py +406 -0
  32. rem/auth/middleware.py +126 -27
  33. rem/cli/commands/README.md +201 -70
  34. rem/cli/commands/ask.py +13 -10
  35. rem/cli/commands/cluster.py +1359 -0
  36. rem/cli/commands/configure.py +4 -3
  37. rem/cli/commands/db.py +350 -137
  38. rem/cli/commands/experiments.py +76 -72
  39. rem/cli/commands/process.py +22 -15
  40. rem/cli/commands/scaffold.py +47 -0
  41. rem/cli/commands/schema.py +95 -49
  42. rem/cli/main.py +29 -6
  43. rem/config.py +2 -2
  44. rem/models/core/core_model.py +7 -1
  45. rem/models/core/rem_query.py +5 -2
  46. rem/models/entities/__init__.py +21 -0
  47. rem/models/entities/domain_resource.py +38 -0
  48. rem/models/entities/feedback.py +123 -0
  49. rem/models/entities/message.py +30 -1
  50. rem/models/entities/session.py +83 -0
  51. rem/models/entities/shared_session.py +180 -0
  52. rem/models/entities/user.py +10 -3
  53. rem/registry.py +373 -0
  54. rem/schemas/agents/rem.yaml +7 -3
  55. rem/services/content/providers.py +94 -140
  56. rem/services/content/service.py +92 -20
  57. rem/services/dreaming/affinity_service.py +2 -16
  58. rem/services/dreaming/moment_service.py +2 -15
  59. rem/services/embeddings/api.py +24 -17
  60. rem/services/embeddings/worker.py +16 -16
  61. rem/services/phoenix/EXPERIMENT_DESIGN.md +3 -3
  62. rem/services/phoenix/client.py +252 -19
  63. rem/services/postgres/README.md +159 -15
  64. rem/services/postgres/__init__.py +2 -1
  65. rem/services/postgres/diff_service.py +426 -0
  66. rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
  67. rem/services/postgres/repository.py +132 -0
  68. rem/services/postgres/schema_generator.py +86 -5
  69. rem/services/postgres/service.py +6 -6
  70. rem/services/rate_limit.py +113 -0
  71. rem/services/rem/README.md +14 -0
  72. rem/services/rem/parser.py +44 -9
  73. rem/services/rem/service.py +36 -2
  74. rem/services/session/compression.py +17 -1
  75. rem/services/session/reload.py +1 -1
  76. rem/services/user_service.py +98 -0
  77. rem/settings.py +169 -17
  78. rem/sql/background_indexes.sql +21 -16
  79. rem/sql/migrations/001_install.sql +231 -54
  80. rem/sql/migrations/002_install_models.sql +457 -393
  81. rem/sql/migrations/003_optional_extensions.sql +326 -0
  82. rem/utils/constants.py +97 -0
  83. rem/utils/date_utils.py +228 -0
  84. rem/utils/embeddings.py +17 -4
  85. rem/utils/files.py +167 -0
  86. rem/utils/mime_types.py +158 -0
  87. rem/utils/model_helpers.py +156 -1
  88. rem/utils/schema_loader.py +191 -35
  89. rem/utils/sql_types.py +3 -1
  90. rem/utils/vision.py +9 -14
  91. rem/workers/README.md +14 -14
  92. rem/workers/db_maintainer.py +74 -0
  93. {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/METADATA +303 -164
  94. {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/RECORD +96 -70
  95. {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/WHEEL +1 -1
  96. rem/sql/002_install_models.sql +0 -1068
  97. rem/sql/install_models.sql +0 -1038
  98. {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,1359 @@
1
+ """
2
+ Cluster management commands for deploying REM to Kubernetes.
3
+
4
+ Usage:
5
+ rem cluster init # Initialize cluster config
6
+ rem cluster generate --config file.yaml # Generate manifests from config
7
+ rem cluster setup-ssm # Create required SSM parameters
8
+ rem cluster generate-sql-configmap # Generate postgres init ConfigMap
9
+ rem cluster validate # Validate deployment prerequisites
10
+ """
11
+
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tarfile
17
+ import tempfile
18
+ from pathlib import Path
19
+ from urllib.error import HTTPError
20
+ from urllib.request import urlopen, Request
21
+
22
+ import click
23
+ import yaml
24
+ from loguru import logger
25
+
26
+ # Default GitHub repo for manifest releases
27
+ DEFAULT_MANIFESTS_REPO = "anthropics/remstack"
28
+ DEFAULT_MANIFESTS_ASSET = "manifests.tar.gz"
29
+
30
+
31
+ def get_current_version() -> str:
32
+ """Get current installed version of remdb."""
33
+ try:
34
+ from importlib.metadata import version
35
+ return version("remdb")
36
+ except Exception:
37
+ return "latest"
38
+
39
+
40
+ def download_manifests(version: str, output_dir: Path, repo: str = DEFAULT_MANIFESTS_REPO) -> bool:
41
+ """
42
+ Download manifests tarball from GitHub releases.
43
+
44
+ Args:
45
+ version: Release tag (e.g., "v1.2.3" or "latest")
46
+ output_dir: Directory to extract manifests to
47
+ repo: GitHub repo in "owner/repo" format
48
+
49
+ Returns:
50
+ True if successful, False otherwise
51
+ """
52
+ # Construct GitHub release URL
53
+ # For "latest", GitHub redirects to the actual latest release
54
+ if version == "latest":
55
+ base_url = f"https://github.com/{repo}/releases/latest/download"
56
+ else:
57
+ # Ensure version has 'v' prefix for GitHub tags
58
+ if not version.startswith("v"):
59
+ version = f"v{version}"
60
+ base_url = f"https://github.com/{repo}/releases/download/{version}"
61
+
62
+ url = f"{base_url}/{DEFAULT_MANIFESTS_ASSET}"
63
+
64
+ click.echo(f"Downloading manifests from: {url}")
65
+
66
+ try:
67
+ # Create request with user-agent (GitHub requires it)
68
+ request = Request(url, headers={"User-Agent": "remdb-cli"})
69
+
70
+ with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp_file:
71
+ tmp_path = Path(tmp_file.name)
72
+
73
+ # Download with progress indication
74
+ with urlopen(request, timeout=60) as response:
75
+ total_size = response.headers.get("Content-Length")
76
+ if total_size:
77
+ total_size = int(total_size)
78
+ click.echo(f"Size: {total_size / 1024 / 1024:.1f} MB")
79
+
80
+ # Read in chunks
81
+ chunk_size = 8192
82
+ downloaded = 0
83
+ while True:
84
+ chunk = response.read(chunk_size)
85
+ if not chunk:
86
+ break
87
+ tmp_file.write(chunk)
88
+ downloaded += len(chunk)
89
+ if total_size:
90
+ pct = (downloaded / total_size) * 100
91
+ click.echo(f"\r Downloading: {pct:.0f}%", nl=False)
92
+
93
+ click.echo() # Newline after progress
94
+
95
+ # Extract tarball
96
+ click.echo(f"Extracting to: {output_dir}")
97
+ output_dir.mkdir(parents=True, exist_ok=True)
98
+
99
+ with tarfile.open(tmp_path, "r:gz") as tar:
100
+ # Extract all files
101
+ tar.extractall(output_dir)
102
+
103
+ # Clean up temp file
104
+ tmp_path.unlink()
105
+
106
+ click.secho("✓ Manifests downloaded successfully", fg="green")
107
+ return True
108
+
109
+ except HTTPError as e:
110
+ if e.code == 404:
111
+ click.secho(f"✗ Release not found: {version}", fg="red")
112
+ click.echo(f" Check available releases at: https://github.com/{repo}/releases")
113
+ else:
114
+ click.secho(f"✗ Download failed: HTTP {e.code}", fg="red")
115
+ return False
116
+ except Exception as e:
117
+ click.secho(f"✗ Download failed: {e}", fg="red")
118
+ return False
119
+
120
+
121
+ def get_manifests_dir() -> Path:
122
+ """Get the manifests directory from the remstack repo."""
123
+ # Walk up from CLI to find manifests/
124
+ current = Path(__file__).resolve()
125
+ for parent in current.parents:
126
+ manifests = parent / "manifests"
127
+ if manifests.exists():
128
+ return manifests
129
+ # Try relative to cwd
130
+ cwd_manifests = Path.cwd() / "manifests"
131
+ if cwd_manifests.exists():
132
+ return cwd_manifests
133
+ raise click.ClickException("Could not find manifests directory. Run from remstack root.")
134
+
135
+
136
+ def load_cluster_config(config_path: Path | None) -> dict:
137
+ """Load cluster configuration from YAML file or defaults."""
138
+ if config_path and config_path.exists():
139
+ with open(config_path) as f:
140
+ return yaml.safe_load(f)
141
+
142
+ # Try default location
143
+ manifests = get_manifests_dir()
144
+ default_config = manifests / "cluster-config.yaml"
145
+ if default_config.exists():
146
+ with open(default_config) as f:
147
+ return yaml.safe_load(f)
148
+
149
+ # Return minimal defaults
150
+ return {
151
+ "project": {"name": "rem", "environment": "staging", "namespace": "rem"},
152
+ "aws": {"region": "us-east-1", "ssmPrefix": "/rem"},
153
+ }
154
+
155
+
156
+ @click.command()
157
+ @click.option(
158
+ "--output",
159
+ "-o",
160
+ type=click.Path(path_type=Path),
161
+ default=None,
162
+ help="Output path for config file (default: ./manifests/cluster-config.yaml)",
163
+ )
164
+ @click.option(
165
+ "--manifests-dir",
166
+ "-m",
167
+ type=click.Path(path_type=Path),
168
+ default=None,
169
+ help="Path to manifests directory (default: ./manifests)",
170
+ )
171
+ @click.option(
172
+ "--project-name",
173
+ default="rem",
174
+ help="Project name prefix (default: rem)",
175
+ )
176
+ @click.option(
177
+ "--git-repo",
178
+ default="https://github.com/anthropics/remstack.git",
179
+ help="Git repository URL for ArgoCD",
180
+ )
181
+ @click.option(
182
+ "--region",
183
+ default="us-east-1",
184
+ help="AWS region (default: us-east-1)",
185
+ )
186
+ @click.option(
187
+ "--manifest-version",
188
+ default=None,
189
+ help="Manifest release version to download (e.g., v0.5.0). Default: latest",
190
+ )
191
+ @click.option(
192
+ "-y", "--yes",
193
+ is_flag=True,
194
+ help="Auto-confirm manifest download without prompting",
195
+ )
196
+ def init(
197
+ output: Path | None,
198
+ manifests_dir: Path | None,
199
+ project_name: str,
200
+ git_repo: str,
201
+ region: str,
202
+ manifest_version: str | None,
203
+ yes: bool,
204
+ ):
205
+ """
206
+ Initialize a new cluster configuration file.
207
+
208
+ Creates a cluster-config.yaml with your project settings that can be
209
+ used with other `rem cluster` commands.
210
+
211
+ If manifests are not found locally, offers to download them from
212
+ the GitHub releases matching your installed remdb version.
213
+
214
+ Examples:
215
+ rem cluster init
216
+ rem cluster init --project-name myapp --git-repo https://github.com/myorg/myrepo.git
217
+ rem cluster init -o my-cluster.yaml
218
+ rem cluster init -y # Auto-download manifests without prompting
219
+ rem cluster init --manifest-version v0.5.0 # Download specific manifest version
220
+ """
221
+ # Determine manifests directory
222
+ if manifests_dir is None:
223
+ manifests_dir = Path.cwd() / "manifests"
224
+
225
+ # Check if manifests exist
226
+ manifests_exist = manifests_dir.exists() and (manifests_dir / "cluster-config.yaml").exists()
227
+
228
+ if not manifests_exist:
229
+ # Manifests not found - offer to download
230
+ click.echo()
231
+ click.echo(f"Manifests not found at: {manifests_dir}")
232
+ click.echo()
233
+
234
+ # Determine version to download
235
+ if manifest_version is None:
236
+ manifest_version = "latest"
237
+
238
+ click.echo(f"Manifest version: {manifest_version}")
239
+ click.echo(f"remdb version: {get_current_version()}")
240
+
241
+ # Prompt or auto-confirm
242
+ if yes or click.confirm(f"Download manifests ({manifest_version})?", default=True):
243
+ click.echo()
244
+ success = download_manifests(manifest_version, manifests_dir.parent)
245
+ if not success:
246
+ click.echo()
247
+ click.secho("Failed to download manifests. You can:", fg="yellow")
248
+ click.echo(" 1. Clone the repo: git clone https://github.com/anthropics/remstack.git")
249
+ click.echo(" 2. Download manually from: https://github.com/anthropics/remstack/releases")
250
+ click.echo(" 3. Specify existing manifests: rem cluster init --manifests-dir /path/to/manifests")
251
+ raise click.Abort()
252
+ click.echo()
253
+ else:
254
+ click.echo()
255
+ click.secho("Skipping manifest download.", fg="yellow")
256
+ click.echo("You can download later or specify a path with --manifests-dir")
257
+ click.echo()
258
+
259
+ # Set output path
260
+ if output is None:
261
+ output = manifests_dir / "cluster-config.yaml"
262
+
263
+ # Check if config file exists
264
+ if output.exists():
265
+ if not click.confirm(f"{output} already exists. Overwrite?"):
266
+ raise click.Abort()
267
+
268
+ # Read template if it exists
269
+ template_path = manifests_dir / "cluster-config.yaml"
270
+ if template_path.exists() and template_path != output:
271
+ with open(template_path) as f:
272
+ config = yaml.safe_load(f) or {}
273
+ else:
274
+ config = {}
275
+
276
+ # Update with provided values
277
+ if "project" not in config:
278
+ config["project"] = {}
279
+ config["project"]["name"] = project_name
280
+ config["project"]["namespace"] = project_name
281
+
282
+ if "git" not in config:
283
+ config["git"] = {}
284
+ config["git"]["repoURL"] = git_repo
285
+
286
+ if "aws" not in config:
287
+ config["aws"] = {}
288
+ config["aws"]["region"] = region
289
+ config["aws"]["ssmPrefix"] = f"/{project_name}"
290
+
291
+ # Write config
292
+ output.parent.mkdir(parents=True, exist_ok=True)
293
+ with open(output, "w") as f:
294
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
295
+
296
+ click.secho(f"✓ Created cluster config: {output}", fg="green")
297
+ click.echo()
298
+ click.echo("Next steps:")
299
+ click.echo(f" 1. Edit {output} to customize settings")
300
+ click.echo(" 2. Deploy CDK infrastructure: cd manifests/infra/cdk-eks && cdk deploy")
301
+ click.echo(" 3. Run: rem cluster setup-ssm")
302
+ click.echo(" 4. Run: rem cluster generate-sql-configmap --apply")
303
+ click.echo(" 5. Run: rem cluster validate")
304
+
305
+
306
+ @click.command("setup-ssm")
307
+ @click.option(
308
+ "--config",
309
+ "-c",
310
+ type=click.Path(exists=True, path_type=Path),
311
+ help="Path to cluster config file",
312
+ )
313
+ @click.option(
314
+ "--dry-run",
315
+ is_flag=True,
316
+ help="Show commands without executing",
317
+ )
318
+ @click.option(
319
+ "--force",
320
+ is_flag=True,
321
+ help="Overwrite existing parameters",
322
+ )
323
+ def setup_ssm(config: Path | None, dry_run: bool, force: bool):
324
+ """
325
+ Create required SSM parameters in AWS.
326
+
327
+ Creates the following parameters under the configured SSM prefix:
328
+ - /postgres/username (String)
329
+ - /postgres/password (SecureString, auto-generated)
330
+ - /llm/anthropic-api-key (SecureString, placeholder)
331
+ - /llm/openai-api-key (SecureString, placeholder)
332
+
333
+ Optional Phoenix parameters:
334
+ - /phoenix/api-key (SecureString, auto-generated)
335
+ - /phoenix/secret (SecureString, auto-generated)
336
+
337
+ Examples:
338
+ rem cluster setup-ssm
339
+ rem cluster setup-ssm --config my-cluster.yaml
340
+ rem cluster setup-ssm --dry-run
341
+ """
342
+ import secrets
343
+
344
+ cfg = load_cluster_config(config)
345
+ prefix = cfg.get("aws", {}).get("ssmPrefix", "/rem")
346
+ region = cfg.get("aws", {}).get("region", "us-east-1")
347
+
348
+ click.echo()
349
+ click.echo("SSM Parameter Setup")
350
+ click.echo("=" * 60)
351
+ click.echo(f"Prefix: {prefix}")
352
+ click.echo(f"Region: {region}")
353
+ click.echo()
354
+
355
+ # Define parameters to create
356
+ parameters = [
357
+ # Required
358
+ (f"{prefix}/postgres/username", "remuser", "String", "PostgreSQL username"),
359
+ (f"{prefix}/postgres/password", secrets.token_urlsafe(24), "SecureString", "PostgreSQL password"),
360
+ # LLM keys - placeholders that user must fill in
361
+ (f"{prefix}/llm/anthropic-api-key", "REPLACE_WITH_YOUR_KEY", "SecureString", "Anthropic API key"),
362
+ (f"{prefix}/llm/openai-api-key", "REPLACE_WITH_YOUR_KEY", "SecureString", "OpenAI API key"),
363
+ # Phoenix - auto-generated
364
+ (f"{prefix}/phoenix/api-key", secrets.token_hex(16), "SecureString", "Phoenix API key"),
365
+ (f"{prefix}/phoenix/secret", secrets.token_hex(32), "SecureString", "Phoenix session secret"),
366
+ ]
367
+
368
+ for name, value, param_type, description in parameters:
369
+ # Check if exists
370
+ check_cmd = ["aws", "ssm", "get-parameter", "--name", name, "--region", region]
371
+
372
+ if not dry_run:
373
+ result = subprocess.run(check_cmd, capture_output=True)
374
+ exists = result.returncode == 0
375
+
376
+ if exists and not force:
377
+ click.echo(f" ⏭ {name} (exists, skipping)")
378
+ continue
379
+
380
+ # Create/update parameter
381
+ put_cmd = [
382
+ "aws", "ssm", "put-parameter",
383
+ "--name", name,
384
+ "--value", value if "REPLACE" not in value else value,
385
+ "--type", param_type,
386
+ "--region", region,
387
+ "--overwrite" if force else "",
388
+ "--description", description,
389
+ ]
390
+ # Remove empty strings
391
+ put_cmd = [c for c in put_cmd if c]
392
+
393
+ if dry_run:
394
+ display_value = "***" if param_type == "SecureString" else value
395
+ click.echo(f" Would create: {name} = {display_value}")
396
+ else:
397
+ try:
398
+ subprocess.run(put_cmd, check=True, capture_output=True)
399
+ click.secho(f" ✓ {name}", fg="green")
400
+ except subprocess.CalledProcessError as e:
401
+ if "ParameterAlreadyExists" in str(e.stderr):
402
+ click.echo(f" ⏭ {name} (exists)")
403
+ else:
404
+ click.secho(f" ✗ {name}: {e.stderr.decode()}", fg="red")
405
+
406
+ click.echo()
407
+ if dry_run:
408
+ click.secho("Dry run - no parameters created", fg="yellow")
409
+ else:
410
+ click.secho("✓ SSM parameters configured", fg="green")
411
+ click.echo()
412
+ click.echo("IMPORTANT: Update placeholder API keys:")
413
+ click.echo(f" aws ssm put-parameter --name {prefix}/llm/anthropic-api-key --value 'sk-...' --type SecureString --overwrite")
414
+ click.echo(f" aws ssm put-parameter --name {prefix}/llm/openai-api-key --value 'sk-...' --type SecureString --overwrite")
415
+
416
+
417
+ @click.command("generate-sql-configmap")
418
+ @click.option(
419
+ "--config",
420
+ "-c",
421
+ type=click.Path(exists=True, path_type=Path),
422
+ help="Path to cluster config file",
423
+ )
424
+ @click.option(
425
+ "--output",
426
+ "-o",
427
+ type=click.Path(path_type=Path),
428
+ default=None,
429
+ help="Output path for ConfigMap YAML",
430
+ )
431
+ @click.option(
432
+ "--apply",
433
+ is_flag=True,
434
+ help="Apply ConfigMap directly to cluster",
435
+ )
436
+ def generate_sql_configmap(config: Path | None, output: Path | None, apply: bool):
437
+ """
438
+ Generate Kubernetes ConfigMap from SQL migration files.
439
+
440
+ Reads SQL files from rem/src/rem/sql/migrations/ and creates a ConfigMap
441
+ that can be used by CloudNativePG for database initialization.
442
+
443
+ The ConfigMap includes:
444
+ - 001_install.sql: Core infrastructure (extensions, functions)
445
+ - 002_install_models.sql: Entity tables (from Pydantic models)
446
+
447
+ Examples:
448
+ rem cluster generate-sql-configmap
449
+ rem cluster generate-sql-configmap --apply
450
+ rem cluster generate-sql-configmap -o custom-configmap.yaml
451
+ """
452
+ cfg = load_cluster_config(config)
453
+ project_name = cfg.get("project", {}).get("name", "rem")
454
+ namespace = cfg.get("project", {}).get("namespace", project_name)
455
+
456
+ # Find SQL directory
457
+ from ...settings import settings
458
+ sql_dir = Path(settings.sql_dir) / "migrations"
459
+
460
+ if not sql_dir.exists():
461
+ click.secho(f"✗ SQL directory not found: {sql_dir}", fg="red")
462
+ click.echo()
463
+ click.echo("Generate migrations first with:")
464
+ click.echo(" rem db schema generate")
465
+ raise click.Abort()
466
+
467
+ click.echo()
468
+ click.echo("Generating SQL ConfigMap")
469
+ click.echo("=" * 60)
470
+ click.echo(f"Source: {sql_dir}")
471
+ click.echo(f"Namespace: {namespace}")
472
+ click.echo()
473
+
474
+ # Read SQL files
475
+ sql_files = {}
476
+ for sql_file in sorted(sql_dir.glob("*.sql")):
477
+ if sql_file.name.startswith(("001_", "002_")):
478
+ content = sql_file.read_text(encoding="utf-8")
479
+ sql_files[sql_file.name] = content
480
+ click.echo(f" ✓ {sql_file.name} ({len(content)} bytes)")
481
+
482
+ if not sql_files:
483
+ click.secho("✗ No SQL files found (001_*.sql, 002_*.sql)", fg="red")
484
+ raise click.Abort()
485
+
486
+ # Generate ConfigMap YAML
487
+ configmap = {
488
+ "apiVersion": "v1",
489
+ "kind": "ConfigMap",
490
+ "metadata": {
491
+ "name": f"{project_name}-postgres-init-sql",
492
+ "namespace": namespace,
493
+ "labels": {
494
+ "app.kubernetes.io/name": f"{project_name}-postgres",
495
+ "app.kubernetes.io/component": "init-sql",
496
+ },
497
+ },
498
+ "data": sql_files,
499
+ }
500
+
501
+ # Output
502
+ if output is None:
503
+ output = get_manifests_dir() / "application" / "rem-stack" / "components" / "postgres" / "postgres-init-configmap.yaml"
504
+
505
+ output.parent.mkdir(parents=True, exist_ok=True)
506
+
507
+ with open(output, "w") as f:
508
+ f.write("# Auto-generated by: rem cluster generate-sql-configmap\n")
509
+ f.write("# Do not edit manually - regenerate from SQL migrations\n")
510
+ f.write("#\n")
511
+ f.write("# Source files:\n")
512
+ for name in sql_files:
513
+ f.write(f"# - rem/src/rem/sql/migrations/{name}\n")
514
+ f.write("#\n")
515
+ yaml.dump(configmap, f, default_flow_style=False, sort_keys=False)
516
+
517
+ click.echo()
518
+ click.secho(f"✓ Generated: {output}", fg="green")
519
+
520
+ # Apply if requested
521
+ if apply:
522
+ click.echo()
523
+ click.echo("Applying to cluster...")
524
+ try:
525
+ subprocess.run(
526
+ ["kubectl", "apply", "-f", str(output)],
527
+ check=True,
528
+ )
529
+ click.secho("✓ ConfigMap applied", fg="green")
530
+ except subprocess.CalledProcessError as e:
531
+ click.secho(f"✗ Failed to apply: {e}", fg="red")
532
+ raise click.Abort()
533
+
534
+
535
+ @click.command()
536
+ @click.option(
537
+ "--config",
538
+ "-c",
539
+ type=click.Path(exists=True, path_type=Path),
540
+ help="Path to cluster config file",
541
+ )
542
+ def validate(config: Path | None):
543
+ """
544
+ Validate deployment prerequisites.
545
+
546
+ Checks:
547
+ 1. kubectl connectivity
548
+ 2. Required namespaces exist
549
+ 3. Platform operators installed (ESO, CNPG, KEDA)
550
+ 4. ClusterSecretStores configured
551
+ 5. SSM parameters exist
552
+ 6. Pod Identity associations
553
+
554
+ Examples:
555
+ rem cluster validate
556
+ rem cluster validate --config my-cluster.yaml
557
+ """
558
+ cfg = load_cluster_config(config)
559
+ project_name = cfg.get("project", {}).get("name", "rem")
560
+ namespace = cfg.get("project", {}).get("namespace", project_name)
561
+ region = cfg.get("aws", {}).get("region", "us-east-1")
562
+ ssm_prefix = cfg.get("aws", {}).get("ssmPrefix", f"/{project_name}")
563
+
564
+ click.echo()
565
+ click.echo("REM Cluster Validation")
566
+ click.echo("=" * 60)
567
+ click.echo(f"Project: {project_name}")
568
+ click.echo(f"Namespace: {namespace}")
569
+ click.echo(f"Region: {region}")
570
+ click.echo()
571
+
572
+ errors = []
573
+ warnings = []
574
+
575
+ # 1. Check kubectl connectivity
576
+ click.echo("1. Kubernetes connectivity")
577
+ try:
578
+ result = subprocess.run(
579
+ ["kubectl", "cluster-info"],
580
+ capture_output=True,
581
+ timeout=10,
582
+ )
583
+ if result.returncode == 0:
584
+ click.secho(" ✓ kubectl connected", fg="green")
585
+ else:
586
+ errors.append("kubectl not connected to cluster")
587
+ click.secho(" ✗ kubectl not connected", fg="red")
588
+ except Exception as e:
589
+ errors.append(f"kubectl error: {e}")
590
+ click.secho(f" ✗ kubectl error: {e}", fg="red")
591
+
592
+ # 2. Check platform operators
593
+ click.echo()
594
+ click.echo("2. Platform operators")
595
+ operators = [
596
+ ("external-secrets-system", "external-secrets", "External Secrets Operator"),
597
+ ("cnpg-system", "cnpg-controller-manager", "CloudNativePG"),
598
+ ("keda", "keda-operator", "KEDA"),
599
+ ]
600
+
601
+ for ns, deployment, name in operators:
602
+ try:
603
+ result = subprocess.run(
604
+ ["kubectl", "get", "deployment", deployment, "-n", ns],
605
+ capture_output=True,
606
+ )
607
+ if result.returncode == 0:
608
+ click.secho(f" ✓ {name}", fg="green")
609
+ else:
610
+ warnings.append(f"{name} not found in {ns}")
611
+ click.secho(f" ⚠ {name} not found", fg="yellow")
612
+ except Exception:
613
+ warnings.append(f"Could not check {name}")
614
+ click.secho(f" ⚠ Could not check {name}", fg="yellow")
615
+
616
+ # 3. Check ClusterSecretStores
617
+ click.echo()
618
+ click.echo("3. ClusterSecretStores")
619
+ stores = ["aws-parameter-store", "kubernetes-secrets"]
620
+
621
+ for store in stores:
622
+ try:
623
+ result = subprocess.run(
624
+ ["kubectl", "get", "clustersecretstore", store],
625
+ capture_output=True,
626
+ )
627
+ if result.returncode == 0:
628
+ click.secho(f" ✓ {store}", fg="green")
629
+ else:
630
+ warnings.append(f"ClusterSecretStore {store} not found")
631
+ click.secho(f" ⚠ {store} not found", fg="yellow")
632
+ except Exception:
633
+ warnings.append(f"Could not check ClusterSecretStore {store}")
634
+ click.secho(f" ⚠ Could not check {store}", fg="yellow")
635
+
636
+ # 4. Check SSM parameters
637
+ click.echo()
638
+ click.echo("4. SSM parameters")
639
+ required_params = [
640
+ f"{ssm_prefix}/postgres/username",
641
+ f"{ssm_prefix}/postgres/password",
642
+ ]
643
+ optional_params = [
644
+ f"{ssm_prefix}/llm/anthropic-api-key",
645
+ f"{ssm_prefix}/llm/openai-api-key",
646
+ ]
647
+
648
+ for param in required_params:
649
+ try:
650
+ result = subprocess.run(
651
+ ["aws", "ssm", "get-parameter", "--name", param, "--region", region],
652
+ capture_output=True,
653
+ )
654
+ if result.returncode == 0:
655
+ click.secho(f" ✓ {param}", fg="green")
656
+ else:
657
+ errors.append(f"Required SSM parameter missing: {param}")
658
+ click.secho(f" ✗ {param} (required)", fg="red")
659
+ except Exception as e:
660
+ errors.append(f"Could not check SSM: {e}")
661
+ click.secho(f" ✗ AWS CLI error: {e}", fg="red")
662
+ break
663
+
664
+ for param in optional_params:
665
+ try:
666
+ result = subprocess.run(
667
+ ["aws", "ssm", "get-parameter", "--name", param, "--region", region],
668
+ capture_output=True,
669
+ )
670
+ if result.returncode == 0:
671
+ # Check if it's a placeholder
672
+ output = result.stdout.decode()
673
+ if "REPLACE_WITH" in output:
674
+ warnings.append(f"SSM parameter is placeholder: {param}")
675
+ click.secho(f" ⚠ {param} (placeholder)", fg="yellow")
676
+ else:
677
+ click.secho(f" ✓ {param}", fg="green")
678
+ else:
679
+ warnings.append(f"Optional SSM parameter missing: {param}")
680
+ click.secho(f" ⚠ {param} (optional)", fg="yellow")
681
+ except Exception:
682
+ pass # Already reported AWS CLI issues
683
+
684
+ # Summary
685
+ click.echo()
686
+ click.echo("=" * 60)
687
+
688
+ if errors:
689
+ click.secho(f"✗ Validation failed with {len(errors)} error(s)", fg="red")
690
+ for error in errors:
691
+ click.echo(f" - {error}")
692
+ raise click.Abort()
693
+ elif warnings:
694
+ click.secho(f"⚠ Validation passed with {len(warnings)} warning(s)", fg="yellow")
695
+ for warning in warnings:
696
+ click.echo(f" - {warning}")
697
+ else:
698
+ click.secho("✓ All checks passed", fg="green")
699
+
700
+ click.echo()
701
+ click.echo("Ready to deploy:")
702
+ click.echo(f" kubectl apply -f manifests/application/rem-stack/argocd-staging.yaml")
703
+
704
+
705
+ @click.command()
706
+ @click.option(
707
+ "--config",
708
+ "-c",
709
+ type=click.Path(exists=True, path_type=Path),
710
+ help="Path to cluster config file",
711
+ )
712
+ @click.option(
713
+ "--output-dir",
714
+ "-o",
715
+ type=click.Path(path_type=Path),
716
+ default=None,
717
+ help="Output directory for generated manifests",
718
+ )
719
+ def generate(config: Path | None, output_dir: Path | None):
720
+ """
721
+ Generate Kubernetes manifests from cluster config.
722
+
723
+ Reads cluster-config.yaml and generates/updates:
724
+ - ArgoCD Application manifests
725
+ - ClusterSecretStore configurations
726
+ - Kustomization patches
727
+
728
+ Examples:
729
+ rem cluster generate
730
+ rem cluster generate --config my-cluster.yaml
731
+ """
732
+ cfg = load_cluster_config(config)
733
+ project_name = cfg.get("project", {}).get("name", "rem")
734
+ namespace = cfg.get("project", {}).get("namespace", project_name)
735
+ region = cfg.get("aws", {}).get("region", "us-east-1")
736
+ git_repo = cfg.get("git", {}).get("repoURL", "")
737
+ git_branch = cfg.get("git", {}).get("targetRevision", "main")
738
+
739
+ if output_dir is None:
740
+ output_dir = get_manifests_dir()
741
+
742
+ click.echo()
743
+ click.echo("Generating Manifests from Config")
744
+ click.echo("=" * 60)
745
+ click.echo(f"Project: {project_name}")
746
+ click.echo(f"Namespace: {namespace}")
747
+ click.echo(f"Git: {git_repo}@{git_branch}")
748
+ click.echo(f"Output: {output_dir}")
749
+ click.echo()
750
+
751
+ # Update ArgoCD application
752
+ argocd_app = output_dir / "application" / "rem-stack" / "argocd-staging.yaml"
753
+ if argocd_app.exists():
754
+ with open(argocd_app) as f:
755
+ content = f.read()
756
+
757
+ # Update git repo URL
758
+ if "repoURL:" in content:
759
+ import re
760
+ content = re.sub(
761
+ r'repoURL:.*',
762
+ f'repoURL: {git_repo}',
763
+ content,
764
+ )
765
+ content = re.sub(
766
+ r'namespace: rem\b',
767
+ f'namespace: {namespace}',
768
+ content,
769
+ )
770
+
771
+ with open(argocd_app, "w") as f:
772
+ f.write(content)
773
+ click.secho(f" ✓ Updated {argocd_app.name}", fg="green")
774
+
775
+ # Update ClusterSecretStore region
776
+ css = output_dir / "platform" / "external-secrets" / "cluster-secret-store.yaml"
777
+ if css.exists():
778
+ with open(css) as f:
779
+ content = f.read()
780
+
781
+ if "region:" in content:
782
+ import re
783
+ content = re.sub(
784
+ r'region:.*',
785
+ f'region: {region}',
786
+ content,
787
+ )
788
+
789
+ with open(css, "w") as f:
790
+ f.write(content)
791
+ click.secho(f" ✓ Updated {css.name}", fg="green")
792
+
793
+ click.echo()
794
+ click.secho("✓ Manifests generated", fg="green")
795
+ click.echo()
796
+ click.echo("Next steps:")
797
+ click.echo(" 1. Review generated manifests")
798
+ click.echo(" 2. Commit changes to git")
799
+ click.echo(" 3. Deploy: kubectl apply -f manifests/application/rem-stack/argocd-staging.yaml")
800
+
801
+
802
+ # =============================================================================
803
+ # Environment Configuration Commands (rem cluster env ...)
804
+ # =============================================================================
805
+
806
+ @click.group()
807
+ def env():
808
+ """
809
+ Environment configuration management.
810
+
811
+ Commands for validating and generating Kubernetes ConfigMaps
812
+ from local .env files, ensuring consistency between local
813
+ development and cluster deployments.
814
+
815
+ Examples:
816
+ rem cluster env check # Validate .env for staging
817
+ rem cluster env check --env prod # Validate for production
818
+ rem cluster env generate # Generate ConfigMap from .env
819
+ rem cluster env diff # Compare .env with cluster
820
+ """
821
+ pass
822
+
823
+
824
+ # Patterns that indicate localhost/development values inappropriate for cluster
825
+ LOCALHOST_PATTERNS = [
826
+ "localhost",
827
+ "127.0.0.1",
828
+ "0.0.0.0",
829
+ "host.docker.internal",
830
+ ]
831
+
832
+ # Required env vars for each environment
833
+ # These align with rem-config ConfigMap structure in manifests/application/rem-stack/base/kustomization.yaml
834
+ ENV_REQUIREMENTS = {
835
+ "staging": {
836
+ "required": [
837
+ "ENVIRONMENT",
838
+ "AWS_REGION",
839
+ "S3__BUCKET_NAME",
840
+ ],
841
+ "recommended": [
842
+ "LLM__ANTHROPIC_API_KEY",
843
+ "LLM__OPENAI_API_KEY",
844
+ "LLM__DEFAULT_MODEL",
845
+ "OTEL_COLLECTOR_ENDPOINT",
846
+ "OTEL__ENABLED",
847
+ "LOG_LEVEL",
848
+ "AUTH__ENABLED",
849
+ "MODELS__IMPORT_MODULES",
850
+ ],
851
+ "no_localhost": [
852
+ "POSTGRES__CONNECTION_STRING",
853
+ "OTEL_COLLECTOR_ENDPOINT",
854
+ "S3__ENDPOINT_URL",
855
+ ],
856
+ },
857
+ "prod": {
858
+ "required": [
859
+ "ENVIRONMENT",
860
+ "AWS_REGION",
861
+ "S3__BUCKET_NAME",
862
+ "AUTH__ENABLED",
863
+ ],
864
+ "recommended": [
865
+ "LLM__ANTHROPIC_API_KEY",
866
+ "LLM__OPENAI_API_KEY",
867
+ "LLM__DEFAULT_MODEL",
868
+ "OTEL_COLLECTOR_ENDPOINT",
869
+ "OTEL__ENABLED",
870
+ "LOG_LEVEL",
871
+ "AUTH__SESSION_SECRET",
872
+ "MODELS__IMPORT_MODULES",
873
+ ],
874
+ "no_localhost": [
875
+ "POSTGRES__CONNECTION_STRING",
876
+ "OTEL_COLLECTOR_ENDPOINT",
877
+ "S3__ENDPOINT_URL",
878
+ "AUTH__GOOGLE__REDIRECT_URI",
879
+ "AUTH__MICROSOFT__REDIRECT_URI",
880
+ ],
881
+ },
882
+ "local": {
883
+ "required": [
884
+ "ENVIRONMENT",
885
+ ],
886
+ "recommended": [
887
+ "LLM__ANTHROPIC_API_KEY",
888
+ "LLM__OPENAI_API_KEY",
889
+ "MODELS__IMPORT_MODULES",
890
+ ],
891
+ "no_localhost": [], # localhost is fine for local
892
+ },
893
+ }
894
+
895
+
896
+ def load_env_file(env_path: Path) -> dict[str, str]:
897
+ """Load environment variables from a .env file."""
898
+ env_vars = {}
899
+ if not env_path.exists():
900
+ return env_vars
901
+
902
+ with open(env_path) as f:
903
+ for line in f:
904
+ line = line.strip()
905
+ # Skip comments and empty lines
906
+ if not line or line.startswith("#"):
907
+ continue
908
+ # Parse KEY=value
909
+ if "=" in line:
910
+ key, _, value = line.partition("=")
911
+ key = key.strip()
912
+ value = value.strip()
913
+ # Remove quotes if present
914
+ if value and value[0] in ('"', "'") and value[-1] == value[0]:
915
+ value = value[1:-1]
916
+ env_vars[key] = value
917
+
918
+ return env_vars
919
+
920
+
921
+ def has_localhost(value: str) -> bool:
922
+ """Check if a value contains localhost-like patterns."""
923
+ value_lower = value.lower()
924
+ return any(pattern in value_lower for pattern in LOCALHOST_PATTERNS)
925
+
926
+
927
+ @env.command("check")
928
+ @click.option(
929
+ "--env-file",
930
+ "-f",
931
+ type=click.Path(exists=True, path_type=Path),
932
+ default=None,
933
+ help="Path to .env file (default: .env in current directory)",
934
+ )
935
+ @click.option(
936
+ "--environment",
937
+ "--env",
938
+ "-e",
939
+ type=click.Choice(["local", "staging", "prod"]),
940
+ default="staging",
941
+ help="Target environment to validate against (default: staging)",
942
+ )
943
+ @click.option(
944
+ "--strict",
945
+ is_flag=True,
946
+ help="Treat warnings as errors",
947
+ )
948
+ def env_check(env_file: Path | None, environment: str, strict: bool):
949
+ """
950
+ Validate .env file for a target environment.
951
+
952
+ Checks that environment variables are appropriate for the target
953
+ deployment environment (local, staging, prod).
954
+
955
+ Validates:
956
+ - Required variables are set
957
+ - No localhost values in cluster configs
958
+ - Recommended variables for the environment
959
+ - Placeholder values that need updating
960
+
961
+ Examples:
962
+ rem cluster env check # Check .env for staging
963
+ rem cluster env check --env prod # Check for production
964
+ rem cluster env check -f backend/.env # Check specific file
965
+ rem cluster env check --strict # Fail on warnings
966
+ """
967
+ # Find .env file
968
+ if env_file is None:
969
+ # Try common locations
970
+ for candidate in [Path(".env"), Path("application/backend/.env"), Path("backend/.env")]:
971
+ if candidate.exists():
972
+ env_file = candidate
973
+ break
974
+
975
+ if env_file is None or not env_file.exists():
976
+ click.secho("✗ No .env file found", fg="red")
977
+ click.echo()
978
+ click.echo("Specify path with: rem cluster env check -f /path/to/.env")
979
+ raise click.Abort()
980
+
981
+ click.echo()
982
+ click.echo(f"Environment Config Check: {environment}")
983
+ click.echo("=" * 60)
984
+ click.echo(f"File: {env_file}")
985
+ click.echo()
986
+
987
+ # Load env vars
988
+ env_vars = load_env_file(env_file)
989
+
990
+ if not env_vars:
991
+ click.secho("✗ No environment variables found in file", fg="red")
992
+ raise click.Abort()
993
+
994
+ click.echo(f"Found {len(env_vars)} variables")
995
+ click.echo()
996
+
997
+ requirements = ENV_REQUIREMENTS.get(environment, ENV_REQUIREMENTS["staging"])
998
+ errors = []
999
+ warnings = []
1000
+
1001
+ # Check required variables
1002
+ click.echo("Required variables:")
1003
+ for var in requirements["required"]:
1004
+ if var in env_vars and env_vars[var]:
1005
+ click.secho(f" ✓ {var}", fg="green")
1006
+ else:
1007
+ errors.append(f"Missing required: {var}")
1008
+ click.secho(f" ✗ {var} (missing or empty)", fg="red")
1009
+
1010
+ # Check for localhost in cluster configs
1011
+ if requirements["no_localhost"]:
1012
+ click.echo()
1013
+ click.echo("Localhost check (should not contain localhost for cluster):")
1014
+ for var in requirements["no_localhost"]:
1015
+ if var in env_vars:
1016
+ value = env_vars[var]
1017
+ if has_localhost(value):
1018
+ errors.append(f"Localhost value in {var}: {value}")
1019
+ click.secho(f" ✗ {var} contains localhost: {value[:50]}...", fg="red")
1020
+ else:
1021
+ click.secho(f" ✓ {var}", fg="green")
1022
+ else:
1023
+ click.echo(f" - {var} (not set)")
1024
+
1025
+ # Check recommended variables
1026
+ click.echo()
1027
+ click.echo("Recommended variables:")
1028
+ for var in requirements["recommended"]:
1029
+ if var in env_vars:
1030
+ value = env_vars[var]
1031
+ # Check for placeholder values
1032
+ if "REPLACE" in value or "YOUR_" in value or value == "":
1033
+ warnings.append(f"Placeholder value: {var}")
1034
+ click.secho(f" ⚠ {var} (placeholder value)", fg="yellow")
1035
+ else:
1036
+ click.secho(f" ✓ {var}", fg="green")
1037
+ else:
1038
+ warnings.append(f"Missing recommended: {var}")
1039
+ click.secho(f" ⚠ {var} (not set)", fg="yellow")
1040
+
1041
+ # Check ENVIRONMENT value matches target
1042
+ click.echo()
1043
+ click.echo("Environment consistency:")
1044
+ env_value = env_vars.get("ENVIRONMENT", "")
1045
+ if env_value == environment or (environment == "local" and env_value == "development"):
1046
+ click.secho(f" ✓ ENVIRONMENT={env_value} (matches target)", fg="green")
1047
+ elif env_value:
1048
+ warnings.append(f"ENVIRONMENT mismatch: {env_value} != {environment}")
1049
+ click.secho(f" ⚠ ENVIRONMENT={env_value} (target is {environment})", fg="yellow")
1050
+
1051
+ # Summary
1052
+ click.echo()
1053
+ click.echo("=" * 60)
1054
+
1055
+ if errors:
1056
+ click.secho(f"✗ Check failed with {len(errors)} error(s)", fg="red")
1057
+ for error in errors:
1058
+ click.echo(f" - {error}")
1059
+ raise click.Abort()
1060
+ elif warnings:
1061
+ if strict:
1062
+ click.secho(f"✗ Check failed with {len(warnings)} warning(s) (strict mode)", fg="red")
1063
+ for warning in warnings:
1064
+ click.echo(f" - {warning}")
1065
+ raise click.Abort()
1066
+ else:
1067
+ click.secho(f"⚠ Check passed with {len(warnings)} warning(s)", fg="yellow")
1068
+ else:
1069
+ click.secho(f"✓ All checks passed for {environment}", fg="green")
1070
+
1071
+
1072
+ @env.command("generate")
1073
+ @click.option(
1074
+ "--env-file",
1075
+ "-f",
1076
+ type=click.Path(exists=True, path_type=Path),
1077
+ default=None,
1078
+ help="Path to .env file",
1079
+ )
1080
+ @click.option(
1081
+ "--output",
1082
+ "-o",
1083
+ type=click.Path(path_type=Path),
1084
+ default=None,
1085
+ help="Output path for ConfigMap YAML",
1086
+ )
1087
+ @click.option(
1088
+ "--name",
1089
+ default="rem-config",
1090
+ help="ConfigMap name (default: rem-config)",
1091
+ )
1092
+ @click.option(
1093
+ "--namespace",
1094
+ "-n",
1095
+ default="siggy",
1096
+ help="Kubernetes namespace (default: siggy)",
1097
+ )
1098
+ @click.option(
1099
+ "--exclude-secrets",
1100
+ is_flag=True,
1101
+ default=True,
1102
+ help="Exclude secret values (API keys, passwords) - default: True",
1103
+ )
1104
+ @click.option(
1105
+ "--apply",
1106
+ is_flag=True,
1107
+ help="Apply ConfigMap directly to cluster",
1108
+ )
1109
+ def env_generate(
1110
+ env_file: Path | None,
1111
+ output: Path | None,
1112
+ name: str,
1113
+ namespace: str,
1114
+ exclude_secrets: bool,
1115
+ apply: bool,
1116
+ ):
1117
+ """
1118
+ Generate Kubernetes ConfigMap from .env file.
1119
+
1120
+ Converts local .env file to a Kubernetes ConfigMap YAML,
1121
+ optionally excluding sensitive values (API keys, passwords).
1122
+
1123
+ Secret values should be managed via ExternalSecrets/SSM, not ConfigMaps.
1124
+
1125
+ Examples:
1126
+ rem cluster env generate # Generate from .env
1127
+ rem cluster env generate -o configmap.yaml # Custom output path
1128
+ rem cluster env generate --apply # Apply to cluster
1129
+ """
1130
+ # Secret patterns to exclude
1131
+ secret_patterns = [
1132
+ "API_KEY",
1133
+ "SECRET",
1134
+ "PASSWORD",
1135
+ "TOKEN",
1136
+ "CREDENTIAL",
1137
+ ]
1138
+
1139
+ # Find .env file
1140
+ if env_file is None:
1141
+ for candidate in [Path(".env"), Path("application/backend/.env"), Path("backend/.env")]:
1142
+ if candidate.exists():
1143
+ env_file = candidate
1144
+ break
1145
+
1146
+ if env_file is None or not env_file.exists():
1147
+ click.secho("✗ No .env file found", fg="red")
1148
+ raise click.Abort()
1149
+
1150
+ click.echo()
1151
+ click.echo("Generate ConfigMap from .env")
1152
+ click.echo("=" * 60)
1153
+ click.echo(f"Source: {env_file}")
1154
+ click.echo(f"ConfigMap: {name}")
1155
+ click.echo(f"Namespace: {namespace}")
1156
+ click.echo()
1157
+
1158
+ # Load env vars
1159
+ env_vars = load_env_file(env_file)
1160
+
1161
+ # Filter out secrets if requested
1162
+ config_data = {}
1163
+ excluded = []
1164
+
1165
+ for key, value in env_vars.items():
1166
+ # Check if this looks like a secret
1167
+ is_secret = any(pattern in key.upper() for pattern in secret_patterns)
1168
+
1169
+ if exclude_secrets and is_secret:
1170
+ excluded.append(key)
1171
+ else:
1172
+ config_data[key] = value
1173
+
1174
+ click.echo(f"Variables to include: {len(config_data)}")
1175
+ if excluded:
1176
+ click.echo(f"Excluded (secrets): {len(excluded)}")
1177
+ for key in excluded[:5]:
1178
+ click.echo(f" - {key}")
1179
+ if len(excluded) > 5:
1180
+ click.echo(f" ... and {len(excluded) - 5} more")
1181
+
1182
+ # Generate ConfigMap
1183
+ configmap = {
1184
+ "apiVersion": "v1",
1185
+ "kind": "ConfigMap",
1186
+ "metadata": {
1187
+ "name": name,
1188
+ "namespace": namespace,
1189
+ "labels": {
1190
+ "app.kubernetes.io/managed-by": "rem-cli",
1191
+ },
1192
+ },
1193
+ "data": config_data,
1194
+ }
1195
+
1196
+ # Output
1197
+ if output is None:
1198
+ output = Path(f"{name}-configmap.yaml")
1199
+
1200
+ with open(output, "w") as f:
1201
+ f.write(f"# Generated by: rem cluster env generate\n")
1202
+ f.write(f"# Source: {env_file}\n")
1203
+ f.write(f"# Date: {__import__('datetime').datetime.utcnow().isoformat()}Z\n")
1204
+ f.write("#\n")
1205
+ if excluded:
1206
+ f.write("# Excluded secrets (use ExternalSecrets for these):\n")
1207
+ for key in excluded:
1208
+ f.write(f"# - {key}\n")
1209
+ f.write("#\n")
1210
+ yaml.dump(configmap, f, default_flow_style=False, sort_keys=False)
1211
+
1212
+ click.echo()
1213
+ click.secho(f"✓ Generated: {output}", fg="green")
1214
+
1215
+ if apply:
1216
+ click.echo()
1217
+ click.echo("Applying to cluster...")
1218
+ try:
1219
+ subprocess.run(["kubectl", "apply", "-f", str(output)], check=True)
1220
+ click.secho("✓ ConfigMap applied", fg="green")
1221
+ except subprocess.CalledProcessError as e:
1222
+ click.secho(f"✗ Failed to apply: {e}", fg="red")
1223
+ raise click.Abort()
1224
+
1225
+
1226
+ @env.command("diff")
1227
+ @click.option(
1228
+ "--env-file",
1229
+ "-f",
1230
+ type=click.Path(exists=True, path_type=Path),
1231
+ default=None,
1232
+ help="Path to .env file",
1233
+ )
1234
+ @click.option(
1235
+ "--configmap",
1236
+ "-c",
1237
+ default="rem-config",
1238
+ help="ConfigMap name to compare (default: rem-config)",
1239
+ )
1240
+ @click.option(
1241
+ "--namespace",
1242
+ "-n",
1243
+ default="siggy",
1244
+ help="Kubernetes namespace (default: siggy)",
1245
+ )
1246
+ def env_diff(env_file: Path | None, configmap: str, namespace: str):
1247
+ """
1248
+ Compare local .env with cluster ConfigMap.
1249
+
1250
+ Shows differences between local environment configuration
1251
+ and what's deployed in the Kubernetes cluster.
1252
+
1253
+ Examples:
1254
+ rem cluster env diff # Compare with rem-config
1255
+ rem cluster env diff -c my-config # Compare with custom ConfigMap
1256
+ rem cluster env diff -n production # Compare in different namespace
1257
+ """
1258
+ # Find .env file
1259
+ if env_file is None:
1260
+ for candidate in [Path(".env"), Path("application/backend/.env"), Path("backend/.env")]:
1261
+ if candidate.exists():
1262
+ env_file = candidate
1263
+ break
1264
+
1265
+ if env_file is None or not env_file.exists():
1266
+ click.secho("✗ No .env file found", fg="red")
1267
+ raise click.Abort()
1268
+
1269
+ click.echo()
1270
+ click.echo("Compare .env with Cluster ConfigMap")
1271
+ click.echo("=" * 60)
1272
+ click.echo(f"Local: {env_file}")
1273
+ click.echo(f"Cluster: {configmap} (namespace: {namespace})")
1274
+ click.echo()
1275
+
1276
+ # Load local env
1277
+ local_vars = load_env_file(env_file)
1278
+
1279
+ # Get cluster ConfigMap
1280
+ try:
1281
+ result = subprocess.run(
1282
+ ["kubectl", "get", "configmap", configmap, "-n", namespace, "-o", "yaml"],
1283
+ capture_output=True,
1284
+ check=True,
1285
+ )
1286
+ cluster_cm = yaml.safe_load(result.stdout.decode())
1287
+ cluster_vars = cluster_cm.get("data", {})
1288
+ except subprocess.CalledProcessError:
1289
+ click.secho(f"✗ ConfigMap {configmap} not found in {namespace}", fg="red")
1290
+ click.echo()
1291
+ click.echo("Generate and apply with:")
1292
+ click.echo(f" rem cluster env generate --name {configmap} --namespace {namespace} --apply")
1293
+ raise click.Abort()
1294
+
1295
+ # Compare
1296
+ local_keys = set(local_vars.keys())
1297
+ cluster_keys = set(cluster_vars.keys())
1298
+
1299
+ only_local = local_keys - cluster_keys
1300
+ only_cluster = cluster_keys - local_keys
1301
+ common = local_keys & cluster_keys
1302
+
1303
+ # Check for differences in common keys
1304
+ different = []
1305
+ for key in common:
1306
+ if local_vars[key] != cluster_vars[key]:
1307
+ different.append(key)
1308
+
1309
+ # Report
1310
+ if only_local:
1311
+ click.echo(f"Only in local .env ({len(only_local)}):")
1312
+ for key in sorted(only_local)[:10]:
1313
+ click.secho(f" + {key}", fg="green")
1314
+ if len(only_local) > 10:
1315
+ click.echo(f" ... and {len(only_local) - 10} more")
1316
+ click.echo()
1317
+
1318
+ if only_cluster:
1319
+ click.echo(f"Only in cluster ({len(only_cluster)}):")
1320
+ for key in sorted(only_cluster)[:10]:
1321
+ click.secho(f" - {key}", fg="red")
1322
+ if len(only_cluster) > 10:
1323
+ click.echo(f" ... and {len(only_cluster) - 10} more")
1324
+ click.echo()
1325
+
1326
+ if different:
1327
+ click.echo(f"Different values ({len(different)}):")
1328
+ for key in sorted(different)[:10]:
1329
+ click.secho(f" ~ {key}", fg="yellow")
1330
+ # Show truncated values (hide secrets)
1331
+ if "SECRET" not in key.upper() and "KEY" not in key.upper() and "PASSWORD" not in key.upper():
1332
+ local_val = local_vars[key][:30] + "..." if len(local_vars[key]) > 30 else local_vars[key]
1333
+ cluster_val = cluster_vars[key][:30] + "..." if len(cluster_vars[key]) > 30 else cluster_vars[key]
1334
+ click.echo(f" local: {local_val}")
1335
+ click.echo(f" cluster: {cluster_val}")
1336
+ if len(different) > 10:
1337
+ click.echo(f" ... and {len(different) - 10} more")
1338
+ click.echo()
1339
+
1340
+ # Summary
1341
+ click.echo("=" * 60)
1342
+ if not only_local and not only_cluster and not different:
1343
+ click.secho("✓ Local .env matches cluster ConfigMap", fg="green")
1344
+ else:
1345
+ total_diff = len(only_local) + len(only_cluster) + len(different)
1346
+ click.secho(f"⚠ Found {total_diff} difference(s)", fg="yellow")
1347
+ click.echo()
1348
+ click.echo("To sync local → cluster:")
1349
+ click.echo(f" rem cluster env generate --name {configmap} --namespace {namespace} --apply")
1350
+
1351
+
1352
+ def register_commands(cluster_group):
1353
+ """Register all cluster commands."""
1354
+ cluster_group.add_command(init)
1355
+ cluster_group.add_command(setup_ssm)
1356
+ cluster_group.add_command(generate_sql_configmap)
1357
+ cluster_group.add_command(validate)
1358
+ cluster_group.add_command(generate)
1359
+ cluster_group.add_command(env)