remdb 0.3.14__py3-none-any.whl → 0.3.157__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.
- rem/agentic/README.md +76 -0
- rem/agentic/__init__.py +15 -0
- rem/agentic/agents/__init__.py +32 -2
- rem/agentic/agents/agent_manager.py +310 -0
- rem/agentic/agents/sse_simulator.py +502 -0
- rem/agentic/context.py +51 -27
- rem/agentic/context_builder.py +5 -3
- rem/agentic/llm_provider_models.py +301 -0
- rem/agentic/mcp/tool_wrapper.py +155 -18
- rem/agentic/otel/setup.py +93 -4
- rem/agentic/providers/phoenix.py +371 -108
- rem/agentic/providers/pydantic_ai.py +280 -57
- rem/agentic/schema.py +361 -21
- rem/agentic/tools/rem_tools.py +3 -3
- rem/api/README.md +215 -1
- rem/api/deps.py +255 -0
- rem/api/main.py +132 -40
- rem/api/mcp_router/resources.py +1 -1
- rem/api/mcp_router/server.py +28 -5
- rem/api/mcp_router/tools.py +555 -7
- rem/api/routers/admin.py +494 -0
- rem/api/routers/auth.py +278 -4
- rem/api/routers/chat/completions.py +402 -20
- rem/api/routers/chat/models.py +88 -10
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +542 -0
- rem/api/routers/chat/streaming.py +697 -45
- rem/api/routers/dev.py +81 -0
- rem/api/routers/feedback.py +268 -0
- rem/api/routers/messages.py +473 -0
- rem/api/routers/models.py +78 -0
- rem/api/routers/query.py +360 -0
- rem/api/routers/shared_sessions.py +406 -0
- rem/auth/__init__.py +13 -3
- rem/auth/middleware.py +186 -22
- rem/auth/providers/__init__.py +4 -1
- rem/auth/providers/email.py +215 -0
- rem/cli/commands/README.md +237 -64
- rem/cli/commands/cluster.py +1808 -0
- rem/cli/commands/configure.py +4 -7
- rem/cli/commands/db.py +386 -143
- rem/cli/commands/experiments.py +468 -76
- rem/cli/commands/process.py +14 -8
- rem/cli/commands/schema.py +97 -50
- rem/cli/commands/session.py +336 -0
- rem/cli/dreaming.py +2 -2
- rem/cli/main.py +29 -6
- rem/config.py +10 -3
- rem/models/core/core_model.py +7 -1
- rem/models/core/experiment.py +58 -14
- rem/models/core/rem_query.py +5 -2
- rem/models/entities/__init__.py +25 -0
- rem/models/entities/domain_resource.py +38 -0
- rem/models/entities/feedback.py +123 -0
- rem/models/entities/message.py +30 -1
- rem/models/entities/ontology.py +1 -1
- rem/models/entities/ontology_config.py +1 -1
- rem/models/entities/session.py +83 -0
- rem/models/entities/shared_session.py +180 -0
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +1 -0
- rem/registry.py +10 -4
- rem/schemas/agents/core/agent-builder.yaml +134 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
- rem/schemas/agents/examples/contract-extractor.yaml +1 -1
- rem/schemas/agents/examples/cv-parser.yaml +1 -1
- rem/schemas/agents/rem.yaml +7 -3
- rem/services/__init__.py +3 -1
- rem/services/content/service.py +92 -19
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +459 -0
- rem/services/email/templates.py +360 -0
- rem/services/embeddings/api.py +4 -4
- rem/services/embeddings/worker.py +16 -16
- rem/services/phoenix/client.py +154 -14
- rem/services/postgres/README.md +197 -15
- rem/services/postgres/__init__.py +2 -1
- rem/services/postgres/diff_service.py +547 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +470 -140
- rem/services/postgres/repository.py +132 -0
- rem/services/postgres/schema_generator.py +205 -4
- rem/services/postgres/service.py +6 -6
- rem/services/rem/parser.py +44 -9
- rem/services/rem/service.py +36 -2
- rem/services/session/compression.py +137 -51
- rem/services/session/reload.py +15 -8
- rem/settings.py +515 -27
- rem/sql/background_indexes.sql +21 -16
- rem/sql/migrations/001_install.sql +387 -54
- rem/sql/migrations/002_install_models.sql +2304 -377
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/sql/migrations/004_cache_system.sql +548 -0
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/utils/README.md +45 -0
- rem/utils/__init__.py +18 -0
- rem/utils/date_utils.py +2 -2
- rem/utils/files.py +157 -1
- rem/utils/model_helpers.py +156 -1
- rem/utils/schema_loader.py +220 -22
- rem/utils/sql_paths.py +146 -0
- rem/utils/sql_types.py +3 -1
- rem/utils/vision.py +1 -1
- rem/workers/__init__.py +3 -1
- rem/workers/db_listener.py +579 -0
- rem/workers/unlogged_maintainer.py +463 -0
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/METADATA +340 -229
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/RECORD +109 -80
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/WHEEL +1 -1
- rem/sql/002_install_models.sql +0 -1068
- rem/sql/install_models.sql +0 -1051
- rem/sql/migrations/003_seed_default_user.sql +0 -48
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,1808 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cluster management commands for deploying REM to Kubernetes.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
rem cluster init # Initialize cluster config
|
|
6
|
+
rem cluster generate # Generate all manifests (including SQL ConfigMap)
|
|
7
|
+
rem cluster setup-ssm # Create required SSM parameters
|
|
8
|
+
rem cluster validate # Validate deployment prerequisites
|
|
9
|
+
rem cluster env check # Validate .env for cluster deployment
|
|
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")
|
|
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
|
+
Reads API keys from environment variables if set:
|
|
328
|
+
- ANTHROPIC_API_KEY
|
|
329
|
+
- OPENAI_API_KEY
|
|
330
|
+
- GOOGLE_CLIENT_ID (optional)
|
|
331
|
+
- GOOGLE_CLIENT_SECRET (optional)
|
|
332
|
+
|
|
333
|
+
Creates the following parameters under the configured SSM prefix:
|
|
334
|
+
- /postgres/username (String: remuser)
|
|
335
|
+
- /postgres/password (SecureString, auto-generated)
|
|
336
|
+
- /llm/anthropic-api-key (SecureString, from env or placeholder)
|
|
337
|
+
- /llm/openai-api-key (SecureString, from env or placeholder)
|
|
338
|
+
- /auth/session-secret (SecureString, auto-generated)
|
|
339
|
+
- /auth/google-client-id (String, from env or placeholder)
|
|
340
|
+
- /auth/google-client-secret (SecureString, from env or placeholder)
|
|
341
|
+
- /phoenix/api-key (SecureString, auto-generated)
|
|
342
|
+
- /phoenix/secret (SecureString, auto-generated)
|
|
343
|
+
- /phoenix/admin-secret (SecureString, auto-generated)
|
|
344
|
+
|
|
345
|
+
Examples:
|
|
346
|
+
# With environment variables set
|
|
347
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
348
|
+
export OPENAI_API_KEY=sk-proj-...
|
|
349
|
+
rem cluster setup-ssm
|
|
350
|
+
|
|
351
|
+
# Using config file
|
|
352
|
+
rem cluster setup-ssm --config my-cluster.yaml
|
|
353
|
+
|
|
354
|
+
# Preview without creating
|
|
355
|
+
rem cluster setup-ssm --dry-run
|
|
356
|
+
"""
|
|
357
|
+
import secrets
|
|
358
|
+
|
|
359
|
+
cfg = load_cluster_config(config)
|
|
360
|
+
prefix = cfg.get("aws", {}).get("ssmPrefix", "/rem")
|
|
361
|
+
region = cfg.get("aws", {}).get("region", "us-east-1")
|
|
362
|
+
|
|
363
|
+
# Read API keys from environment
|
|
364
|
+
anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
|
365
|
+
openai_key = os.environ.get("OPENAI_API_KEY", "")
|
|
366
|
+
google_client_id = os.environ.get("GOOGLE_CLIENT_ID", "placeholder")
|
|
367
|
+
google_client_secret = os.environ.get("GOOGLE_CLIENT_SECRET", "placeholder")
|
|
368
|
+
|
|
369
|
+
click.echo()
|
|
370
|
+
click.echo("SSM Parameter Setup")
|
|
371
|
+
click.echo("=" * 60)
|
|
372
|
+
click.echo(f"Prefix: {prefix}")
|
|
373
|
+
click.echo(f"Region: {region}")
|
|
374
|
+
click.echo()
|
|
375
|
+
|
|
376
|
+
# Show env var status
|
|
377
|
+
click.echo("Environment variables:")
|
|
378
|
+
click.echo(f" ANTHROPIC_API_KEY: {'✓ set' if anthropic_key else '✗ not set (will use placeholder)'}")
|
|
379
|
+
click.echo(f" OPENAI_API_KEY: {'✓ set' if openai_key else '✗ not set (will use placeholder)'}")
|
|
380
|
+
click.echo(f" GOOGLE_CLIENT_ID: {'✓ set' if google_client_id != 'placeholder' else '⚠ not set (OAuth disabled)'}")
|
|
381
|
+
click.echo(f" GOOGLE_CLIENT_SECRET: {'✓ set' if google_client_secret != 'placeholder' else '⚠ not set (OAuth disabled)'}")
|
|
382
|
+
click.echo()
|
|
383
|
+
|
|
384
|
+
# Define parameters to create
|
|
385
|
+
parameters = [
|
|
386
|
+
# PostgreSQL - username MUST be remuser to match CNPG cluster owner spec
|
|
387
|
+
(f"{prefix}/postgres/username", "remuser", "String", "PostgreSQL username (must match CNPG owner)"),
|
|
388
|
+
(f"{prefix}/postgres/password", secrets.token_urlsafe(24), "SecureString", "PostgreSQL password"),
|
|
389
|
+
# LLM keys - from env or placeholder
|
|
390
|
+
(f"{prefix}/llm/anthropic-api-key", anthropic_key or "REPLACE_WITH_YOUR_KEY", "SecureString", "Anthropic API key"),
|
|
391
|
+
(f"{prefix}/llm/openai-api-key", openai_key or "REPLACE_WITH_YOUR_KEY", "SecureString", "OpenAI API key"),
|
|
392
|
+
# Auth secrets
|
|
393
|
+
(f"{prefix}/auth/session-secret", secrets.token_urlsafe(32), "SecureString", "Session signing secret"),
|
|
394
|
+
(f"{prefix}/auth/google-client-id", google_client_id, "String", "Google OAuth client ID"),
|
|
395
|
+
(f"{prefix}/auth/google-client-secret", google_client_secret, "SecureString", "Google OAuth client secret"),
|
|
396
|
+
# Phoenix - auto-generated
|
|
397
|
+
(f"{prefix}/phoenix/api-key", secrets.token_urlsafe(24), "SecureString", "Phoenix API key"),
|
|
398
|
+
(f"{prefix}/phoenix/secret", secrets.token_urlsafe(32), "SecureString", "Phoenix session secret"),
|
|
399
|
+
(f"{prefix}/phoenix/admin-secret", secrets.token_urlsafe(32), "SecureString", "Phoenix admin secret"),
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
created = 0
|
|
403
|
+
skipped = 0
|
|
404
|
+
failed = 0
|
|
405
|
+
|
|
406
|
+
for name, value, param_type, description in parameters:
|
|
407
|
+
# Check if exists
|
|
408
|
+
check_cmd = ["aws", "ssm", "get-parameter", "--name", name, "--region", region]
|
|
409
|
+
|
|
410
|
+
if not dry_run:
|
|
411
|
+
result = subprocess.run(check_cmd, capture_output=True)
|
|
412
|
+
exists = result.returncode == 0
|
|
413
|
+
|
|
414
|
+
if exists and not force:
|
|
415
|
+
click.echo(f" ⏭ {name} (exists, skipping)")
|
|
416
|
+
skipped += 1
|
|
417
|
+
continue
|
|
418
|
+
|
|
419
|
+
# Create/update parameter
|
|
420
|
+
put_cmd = [
|
|
421
|
+
"aws", "ssm", "put-parameter",
|
|
422
|
+
"--name", name,
|
|
423
|
+
"--value", value,
|
|
424
|
+
"--type", param_type,
|
|
425
|
+
"--region", region,
|
|
426
|
+
"--description", description,
|
|
427
|
+
]
|
|
428
|
+
if force:
|
|
429
|
+
put_cmd.append("--overwrite")
|
|
430
|
+
|
|
431
|
+
if dry_run:
|
|
432
|
+
display_value = "***" if param_type == "SecureString" else value
|
|
433
|
+
if "REPLACE" in value or value == "placeholder":
|
|
434
|
+
click.secho(f" Would create: {name} = {display_value} (PLACEHOLDER)", fg="yellow")
|
|
435
|
+
else:
|
|
436
|
+
click.echo(f" Would create: {name} = {display_value}")
|
|
437
|
+
else:
|
|
438
|
+
try:
|
|
439
|
+
subprocess.run(put_cmd, check=True, capture_output=True)
|
|
440
|
+
if "REPLACE" in value or value == "placeholder":
|
|
441
|
+
click.secho(f" ⚠ {name} (placeholder - update later)", fg="yellow")
|
|
442
|
+
else:
|
|
443
|
+
click.secho(f" ✓ {name}", fg="green")
|
|
444
|
+
created += 1
|
|
445
|
+
except subprocess.CalledProcessError as e:
|
|
446
|
+
if "ParameterAlreadyExists" in str(e.stderr):
|
|
447
|
+
click.echo(f" ⏭ {name} (exists)")
|
|
448
|
+
skipped += 1
|
|
449
|
+
else:
|
|
450
|
+
click.secho(f" ✗ {name}: {e.stderr.decode()}", fg="red")
|
|
451
|
+
failed += 1
|
|
452
|
+
|
|
453
|
+
click.echo()
|
|
454
|
+
if dry_run:
|
|
455
|
+
click.secho("Dry run - no parameters created", fg="yellow")
|
|
456
|
+
else:
|
|
457
|
+
click.secho(f"✓ SSM setup complete: {created} created, {skipped} skipped, {failed} failed", fg="green")
|
|
458
|
+
|
|
459
|
+
# Show update instructions if placeholders were used
|
|
460
|
+
if not anthropic_key or not openai_key:
|
|
461
|
+
click.echo()
|
|
462
|
+
click.secho("IMPORTANT: Update placeholder API keys:", fg="yellow")
|
|
463
|
+
if not anthropic_key:
|
|
464
|
+
click.echo(f" aws ssm put-parameter --name {prefix}/llm/anthropic-api-key --value 'sk-ant-...' --type SecureString --overwrite --region {region}")
|
|
465
|
+
if not openai_key:
|
|
466
|
+
click.echo(f" aws ssm put-parameter --name {prefix}/llm/openai-api-key --value 'sk-proj-...' --type SecureString --overwrite --region {region}")
|
|
467
|
+
click.echo()
|
|
468
|
+
click.echo("Or set environment variables and re-run with --force:")
|
|
469
|
+
click.echo(" export ANTHROPIC_API_KEY=sk-ant-...")
|
|
470
|
+
click.echo(" export OPENAI_API_KEY=sk-proj-...")
|
|
471
|
+
click.echo(" rem cluster setup-ssm --force")
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _generate_sql_configmap(project_name: str, namespace: str, output_dir: Path) -> None:
|
|
475
|
+
"""
|
|
476
|
+
Generate SQL init ConfigMap from migration files.
|
|
477
|
+
|
|
478
|
+
Called by `cluster generate` to include SQL migrations in the manifest generation.
|
|
479
|
+
"""
|
|
480
|
+
from ...utils.sql_paths import get_package_migrations_dir
|
|
481
|
+
|
|
482
|
+
sql_dir = get_package_migrations_dir()
|
|
483
|
+
|
|
484
|
+
if not sql_dir.exists():
|
|
485
|
+
click.secho(f" ⚠ SQL directory not found: {sql_dir}", fg="yellow")
|
|
486
|
+
click.echo(" Run 'rem db schema generate' to create migrations")
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
# Read all SQL files in sorted order
|
|
490
|
+
sql_files = {}
|
|
491
|
+
for sql_file in sorted(sql_dir.glob("*.sql")):
|
|
492
|
+
content = sql_file.read_text(encoding="utf-8")
|
|
493
|
+
sql_files[sql_file.name] = content
|
|
494
|
+
|
|
495
|
+
if not sql_files:
|
|
496
|
+
click.secho(" ⚠ No SQL files found in migrations directory", fg="yellow")
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
# Generate ConfigMap YAML
|
|
500
|
+
configmap = {
|
|
501
|
+
"apiVersion": "v1",
|
|
502
|
+
"kind": "ConfigMap",
|
|
503
|
+
"metadata": {
|
|
504
|
+
"name": f"{project_name}-postgres-init-sql",
|
|
505
|
+
"namespace": namespace,
|
|
506
|
+
"labels": {
|
|
507
|
+
"app.kubernetes.io/name": f"{project_name}-postgres",
|
|
508
|
+
"app.kubernetes.io/component": "init-sql",
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
"data": sql_files,
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
output = output_dir / "application" / "rem-stack" / "components" / "postgres" / "postgres-init-configmap.yaml"
|
|
515
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
516
|
+
|
|
517
|
+
with open(output, "w") as f:
|
|
518
|
+
f.write("# Auto-generated by: rem cluster generate\n")
|
|
519
|
+
f.write("# Do not edit manually - regenerate with 'rem cluster generate'\n")
|
|
520
|
+
f.write("#\n")
|
|
521
|
+
f.write("# Source files:\n")
|
|
522
|
+
for name in sql_files:
|
|
523
|
+
f.write(f"# - rem/sql/migrations/{name}\n")
|
|
524
|
+
f.write("#\n")
|
|
525
|
+
yaml.dump(configmap, f, default_flow_style=False, sort_keys=False)
|
|
526
|
+
|
|
527
|
+
click.secho(f" ✓ Generated {output.name} ({len(sql_files)} SQL files)", fg="green")
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@click.command()
|
|
531
|
+
@click.option(
|
|
532
|
+
"--config",
|
|
533
|
+
"-c",
|
|
534
|
+
type=click.Path(exists=True, path_type=Path),
|
|
535
|
+
help="Path to cluster config file",
|
|
536
|
+
)
|
|
537
|
+
@click.option(
|
|
538
|
+
"--pre-argocd",
|
|
539
|
+
is_flag=True,
|
|
540
|
+
help="Only check prerequisites needed before ArgoCD deployment",
|
|
541
|
+
)
|
|
542
|
+
def validate(config: Path | None, pre_argocd: bool):
|
|
543
|
+
"""
|
|
544
|
+
Validate deployment prerequisites.
|
|
545
|
+
|
|
546
|
+
Checks:
|
|
547
|
+
1. Required tools (kubectl, aws, openssl)
|
|
548
|
+
2. AWS credentials
|
|
549
|
+
3. Kubernetes connectivity
|
|
550
|
+
4. ArgoCD installation
|
|
551
|
+
5. Environment variables (for setup-ssm)
|
|
552
|
+
6. SSM parameters
|
|
553
|
+
7. Platform operators (ESO, CNPG, KEDA) - skipped with --pre-argocd
|
|
554
|
+
8. ClusterSecretStores - skipped with --pre-argocd
|
|
555
|
+
|
|
556
|
+
Use --pre-argocd to validate only prerequisites needed before
|
|
557
|
+
running 'rem cluster apply' for the first time.
|
|
558
|
+
|
|
559
|
+
Examples:
|
|
560
|
+
rem cluster validate # Full validation
|
|
561
|
+
rem cluster validate --pre-argocd # Pre-deployment checks only
|
|
562
|
+
rem cluster validate --config my-cluster.yaml
|
|
563
|
+
"""
|
|
564
|
+
cfg = load_cluster_config(config)
|
|
565
|
+
project_name = cfg.get("project", {}).get("name", "rem")
|
|
566
|
+
namespace = cfg.get("project", {}).get("namespace", project_name)
|
|
567
|
+
region = cfg.get("aws", {}).get("region", "us-east-1")
|
|
568
|
+
ssm_prefix = cfg.get("aws", {}).get("ssmPrefix", f"/{project_name}")
|
|
569
|
+
|
|
570
|
+
click.echo()
|
|
571
|
+
click.echo("REM Cluster Validation")
|
|
572
|
+
click.echo("=" * 60)
|
|
573
|
+
click.echo(f"Project: {project_name}")
|
|
574
|
+
click.echo(f"Namespace: {namespace}")
|
|
575
|
+
click.echo(f"Region: {region}")
|
|
576
|
+
if pre_argocd:
|
|
577
|
+
click.echo(f"Mode: Pre-ArgoCD (checking prerequisites only)")
|
|
578
|
+
click.echo()
|
|
579
|
+
|
|
580
|
+
errors = []
|
|
581
|
+
warnings = []
|
|
582
|
+
|
|
583
|
+
# 1. Check required tools
|
|
584
|
+
click.echo("1. Required tools")
|
|
585
|
+
tools = [
|
|
586
|
+
("kubectl", ["kubectl", "version", "--client", "-o", "json"]),
|
|
587
|
+
("aws", ["aws", "--version"]),
|
|
588
|
+
("openssl", ["openssl", "version"]),
|
|
589
|
+
]
|
|
590
|
+
|
|
591
|
+
for tool, cmd in tools:
|
|
592
|
+
if shutil.which(tool):
|
|
593
|
+
click.secho(f" ✓ {tool} installed", fg="green")
|
|
594
|
+
else:
|
|
595
|
+
errors.append(f"{tool} not installed")
|
|
596
|
+
click.secho(f" ✗ {tool} not installed", fg="red")
|
|
597
|
+
|
|
598
|
+
# 2. Check AWS credentials
|
|
599
|
+
click.echo()
|
|
600
|
+
click.echo("2. AWS credentials")
|
|
601
|
+
try:
|
|
602
|
+
result = subprocess.run(
|
|
603
|
+
["aws", "sts", "get-caller-identity", "--region", region],
|
|
604
|
+
capture_output=True,
|
|
605
|
+
timeout=10,
|
|
606
|
+
)
|
|
607
|
+
if result.returncode == 0:
|
|
608
|
+
import json
|
|
609
|
+
identity = json.loads(result.stdout.decode())
|
|
610
|
+
click.secho(f" ✓ AWS credentials valid (account: {identity.get('Account', 'unknown')})", fg="green")
|
|
611
|
+
else:
|
|
612
|
+
errors.append("AWS credentials not configured")
|
|
613
|
+
click.secho(" ✗ AWS credentials not configured", fg="red")
|
|
614
|
+
except Exception as e:
|
|
615
|
+
errors.append(f"AWS CLI error: {e}")
|
|
616
|
+
click.secho(f" ✗ AWS CLI error: {e}", fg="red")
|
|
617
|
+
|
|
618
|
+
# 3. Check kubectl connectivity
|
|
619
|
+
click.echo()
|
|
620
|
+
click.echo("3. Kubernetes connectivity")
|
|
621
|
+
try:
|
|
622
|
+
result = subprocess.run(
|
|
623
|
+
["kubectl", "cluster-info"],
|
|
624
|
+
capture_output=True,
|
|
625
|
+
timeout=10,
|
|
626
|
+
)
|
|
627
|
+
if result.returncode == 0:
|
|
628
|
+
# Get context name
|
|
629
|
+
ctx_result = subprocess.run(
|
|
630
|
+
["kubectl", "config", "current-context"],
|
|
631
|
+
capture_output=True,
|
|
632
|
+
)
|
|
633
|
+
context = ctx_result.stdout.decode().strip() if ctx_result.returncode == 0 else "unknown"
|
|
634
|
+
click.secho(f" ✓ kubectl connected (context: {context})", fg="green")
|
|
635
|
+
else:
|
|
636
|
+
errors.append("kubectl not connected to cluster")
|
|
637
|
+
click.secho(" ✗ kubectl not connected", fg="red")
|
|
638
|
+
except Exception as e:
|
|
639
|
+
errors.append(f"kubectl error: {e}")
|
|
640
|
+
click.secho(f" ✗ kubectl error: {e}", fg="red")
|
|
641
|
+
|
|
642
|
+
# 4. Check ArgoCD installation
|
|
643
|
+
click.echo()
|
|
644
|
+
click.echo("4. ArgoCD installation")
|
|
645
|
+
try:
|
|
646
|
+
# Check namespace
|
|
647
|
+
result = subprocess.run(
|
|
648
|
+
["kubectl", "get", "namespace", "argocd"],
|
|
649
|
+
capture_output=True,
|
|
650
|
+
)
|
|
651
|
+
if result.returncode == 0:
|
|
652
|
+
click.secho(" ✓ ArgoCD namespace exists", fg="green")
|
|
653
|
+
|
|
654
|
+
# Check server deployment
|
|
655
|
+
result = subprocess.run(
|
|
656
|
+
["kubectl", "get", "deployment", "argocd-server", "-n", "argocd", "-o", "jsonpath={.status.readyReplicas}"],
|
|
657
|
+
capture_output=True,
|
|
658
|
+
)
|
|
659
|
+
if result.returncode == 0 and result.stdout.decode().strip():
|
|
660
|
+
replicas = result.stdout.decode().strip()
|
|
661
|
+
click.secho(f" ✓ ArgoCD server running ({replicas} replica(s))", fg="green")
|
|
662
|
+
else:
|
|
663
|
+
warnings.append("ArgoCD server not ready")
|
|
664
|
+
click.secho(" ⚠ ArgoCD server not ready", fg="yellow")
|
|
665
|
+
else:
|
|
666
|
+
errors.append("ArgoCD not installed")
|
|
667
|
+
click.secho(" ✗ ArgoCD namespace not found", fg="red")
|
|
668
|
+
click.echo(" Install with:")
|
|
669
|
+
click.echo(" kubectl create namespace argocd")
|
|
670
|
+
click.echo(" kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml")
|
|
671
|
+
except Exception as e:
|
|
672
|
+
errors.append(f"Could not check ArgoCD: {e}")
|
|
673
|
+
click.secho(f" ✗ Could not check ArgoCD: {e}", fg="red")
|
|
674
|
+
|
|
675
|
+
# 5. Check environment variables
|
|
676
|
+
click.echo()
|
|
677
|
+
click.echo("5. Environment variables (for setup-ssm)")
|
|
678
|
+
env_vars = [
|
|
679
|
+
("ANTHROPIC_API_KEY", True),
|
|
680
|
+
("OPENAI_API_KEY", True),
|
|
681
|
+
("GITHUB_PAT", True),
|
|
682
|
+
("GITHUB_USERNAME", True),
|
|
683
|
+
("GITHUB_REPO_URL", True),
|
|
684
|
+
("GOOGLE_CLIENT_ID", False),
|
|
685
|
+
("GOOGLE_CLIENT_SECRET", False),
|
|
686
|
+
]
|
|
687
|
+
|
|
688
|
+
for var, required in env_vars:
|
|
689
|
+
value = os.environ.get(var, "")
|
|
690
|
+
if value:
|
|
691
|
+
click.secho(f" ✓ {var} is set", fg="green")
|
|
692
|
+
elif required:
|
|
693
|
+
warnings.append(f"Environment variable not set: {var}")
|
|
694
|
+
click.secho(f" ⚠ {var} not set (required for setup-ssm)", fg="yellow")
|
|
695
|
+
else:
|
|
696
|
+
click.echo(f" - {var} not set (optional)")
|
|
697
|
+
|
|
698
|
+
# 6. Check SSM parameters
|
|
699
|
+
click.echo()
|
|
700
|
+
click.echo("6. SSM parameters")
|
|
701
|
+
required_params = [
|
|
702
|
+
f"{ssm_prefix}/postgres/username",
|
|
703
|
+
f"{ssm_prefix}/postgres/password",
|
|
704
|
+
]
|
|
705
|
+
optional_params = [
|
|
706
|
+
f"{ssm_prefix}/llm/anthropic-api-key",
|
|
707
|
+
f"{ssm_prefix}/llm/openai-api-key",
|
|
708
|
+
]
|
|
709
|
+
|
|
710
|
+
ssm_ok = True
|
|
711
|
+
for param in required_params:
|
|
712
|
+
try:
|
|
713
|
+
result = subprocess.run(
|
|
714
|
+
["aws", "ssm", "get-parameter", "--name", param, "--region", region],
|
|
715
|
+
capture_output=True,
|
|
716
|
+
)
|
|
717
|
+
if result.returncode == 0:
|
|
718
|
+
click.secho(f" ✓ {param}", fg="green")
|
|
719
|
+
else:
|
|
720
|
+
if pre_argocd:
|
|
721
|
+
click.echo(f" - {param} (will be created by setup-ssm)")
|
|
722
|
+
else:
|
|
723
|
+
errors.append(f"Required SSM parameter missing: {param}")
|
|
724
|
+
click.secho(f" ✗ {param} (required)", fg="red")
|
|
725
|
+
ssm_ok = False
|
|
726
|
+
except Exception as e:
|
|
727
|
+
errors.append(f"Could not check SSM: {e}")
|
|
728
|
+
click.secho(f" ✗ AWS CLI error: {e}", fg="red")
|
|
729
|
+
ssm_ok = False
|
|
730
|
+
break
|
|
731
|
+
|
|
732
|
+
for param in optional_params:
|
|
733
|
+
try:
|
|
734
|
+
result = subprocess.run(
|
|
735
|
+
["aws", "ssm", "get-parameter", "--name", param, "--region", region],
|
|
736
|
+
capture_output=True,
|
|
737
|
+
)
|
|
738
|
+
if result.returncode == 0:
|
|
739
|
+
# Check if it's a placeholder
|
|
740
|
+
output = result.stdout.decode()
|
|
741
|
+
if "REPLACE_WITH" in output:
|
|
742
|
+
warnings.append(f"SSM parameter is placeholder: {param}")
|
|
743
|
+
click.secho(f" ⚠ {param} (placeholder)", fg="yellow")
|
|
744
|
+
else:
|
|
745
|
+
click.secho(f" ✓ {param}", fg="green")
|
|
746
|
+
else:
|
|
747
|
+
if pre_argocd:
|
|
748
|
+
click.echo(f" - {param} (will be created by setup-ssm)")
|
|
749
|
+
else:
|
|
750
|
+
warnings.append(f"Optional SSM parameter missing: {param}")
|
|
751
|
+
click.secho(f" ⚠ {param} (optional)", fg="yellow")
|
|
752
|
+
except Exception:
|
|
753
|
+
pass # Already reported AWS CLI issues
|
|
754
|
+
|
|
755
|
+
if not ssm_ok and pre_argocd:
|
|
756
|
+
click.echo(" Run 'rem cluster setup-ssm' to create parameters")
|
|
757
|
+
|
|
758
|
+
# Skip platform operator checks if --pre-argocd
|
|
759
|
+
if not pre_argocd:
|
|
760
|
+
# 7. Check platform operators
|
|
761
|
+
click.echo()
|
|
762
|
+
click.echo("7. Platform operators")
|
|
763
|
+
operators = [
|
|
764
|
+
("external-secrets-system", "external-secrets", "External Secrets Operator"),
|
|
765
|
+
("cnpg-system", "cnpg-controller-manager", "CloudNativePG"),
|
|
766
|
+
("keda", "keda-operator", "KEDA"),
|
|
767
|
+
("cert-manager", "cert-manager", "cert-manager"),
|
|
768
|
+
]
|
|
769
|
+
|
|
770
|
+
for ns, deployment, name in operators:
|
|
771
|
+
try:
|
|
772
|
+
result = subprocess.run(
|
|
773
|
+
["kubectl", "get", "deployment", deployment, "-n", ns],
|
|
774
|
+
capture_output=True,
|
|
775
|
+
)
|
|
776
|
+
if result.returncode == 0:
|
|
777
|
+
click.secho(f" ✓ {name}", fg="green")
|
|
778
|
+
else:
|
|
779
|
+
warnings.append(f"{name} not found in {ns}")
|
|
780
|
+
click.secho(f" ⚠ {name} not found", fg="yellow")
|
|
781
|
+
except Exception:
|
|
782
|
+
warnings.append(f"Could not check {name}")
|
|
783
|
+
click.secho(f" ⚠ Could not check {name}", fg="yellow")
|
|
784
|
+
|
|
785
|
+
# 8. Check ClusterSecretStores
|
|
786
|
+
click.echo()
|
|
787
|
+
click.echo("8. ClusterSecretStores")
|
|
788
|
+
stores = ["aws-parameter-store", "kubernetes-secrets"]
|
|
789
|
+
|
|
790
|
+
for store in stores:
|
|
791
|
+
try:
|
|
792
|
+
result = subprocess.run(
|
|
793
|
+
["kubectl", "get", "clustersecretstore", store],
|
|
794
|
+
capture_output=True,
|
|
795
|
+
)
|
|
796
|
+
if result.returncode == 0:
|
|
797
|
+
click.secho(f" ✓ {store}", fg="green")
|
|
798
|
+
else:
|
|
799
|
+
warnings.append(f"ClusterSecretStore {store} not found")
|
|
800
|
+
click.secho(f" ⚠ {store} not found", fg="yellow")
|
|
801
|
+
except Exception:
|
|
802
|
+
warnings.append(f"Could not check ClusterSecretStore {store}")
|
|
803
|
+
click.secho(f" ⚠ Could not check {store}", fg="yellow")
|
|
804
|
+
|
|
805
|
+
# Summary
|
|
806
|
+
click.echo()
|
|
807
|
+
click.echo("=" * 60)
|
|
808
|
+
|
|
809
|
+
if errors:
|
|
810
|
+
click.secho(f"✗ Validation failed with {len(errors)} error(s)", fg="red")
|
|
811
|
+
for error in errors:
|
|
812
|
+
click.echo(f" - {error}")
|
|
813
|
+
raise click.Abort()
|
|
814
|
+
elif warnings:
|
|
815
|
+
click.secho(f"⚠ Validation passed with {len(warnings)} warning(s)", fg="yellow")
|
|
816
|
+
for warning in warnings[:5]:
|
|
817
|
+
click.echo(f" - {warning}")
|
|
818
|
+
if len(warnings) > 5:
|
|
819
|
+
click.echo(f" ... and {len(warnings) - 5} more")
|
|
820
|
+
else:
|
|
821
|
+
click.secho("✓ All checks passed", fg="green")
|
|
822
|
+
|
|
823
|
+
click.echo()
|
|
824
|
+
if pre_argocd:
|
|
825
|
+
click.echo("Next steps:")
|
|
826
|
+
click.echo(" 1. rem cluster setup-ssm # Create SSM parameters")
|
|
827
|
+
click.echo(" 2. rem cluster apply # Deploy ArgoCD apps")
|
|
828
|
+
else:
|
|
829
|
+
click.echo("Ready to deploy:")
|
|
830
|
+
click.echo(" rem cluster apply")
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
@click.command()
|
|
834
|
+
@click.option(
|
|
835
|
+
"--config",
|
|
836
|
+
"-c",
|
|
837
|
+
type=click.Path(exists=True, path_type=Path),
|
|
838
|
+
help="Path to cluster config file",
|
|
839
|
+
)
|
|
840
|
+
@click.option(
|
|
841
|
+
"--output-dir",
|
|
842
|
+
"-o",
|
|
843
|
+
type=click.Path(path_type=Path),
|
|
844
|
+
default=None,
|
|
845
|
+
help="Output directory for generated manifests",
|
|
846
|
+
)
|
|
847
|
+
def generate(config: Path | None, output_dir: Path | None):
|
|
848
|
+
"""
|
|
849
|
+
Generate Kubernetes manifests from cluster config.
|
|
850
|
+
|
|
851
|
+
Reads cluster-config.yaml and generates/updates:
|
|
852
|
+
- ArgoCD Application manifests
|
|
853
|
+
- ClusterSecretStore configurations
|
|
854
|
+
- SQL init ConfigMap (from rem/sql/migrations/*.sql)
|
|
855
|
+
- Kustomization patches
|
|
856
|
+
|
|
857
|
+
Examples:
|
|
858
|
+
rem cluster generate
|
|
859
|
+
rem cluster generate --config my-cluster.yaml
|
|
860
|
+
"""
|
|
861
|
+
cfg = load_cluster_config(config)
|
|
862
|
+
project_name = cfg.get("project", {}).get("name", "rem")
|
|
863
|
+
namespace = cfg.get("project", {}).get("namespace", project_name)
|
|
864
|
+
region = cfg.get("aws", {}).get("region", "us-east-1")
|
|
865
|
+
git_repo = cfg.get("git", {}).get("repoURL", "")
|
|
866
|
+
git_branch = cfg.get("git", {}).get("targetRevision", "main")
|
|
867
|
+
|
|
868
|
+
if output_dir is None:
|
|
869
|
+
output_dir = get_manifests_dir()
|
|
870
|
+
|
|
871
|
+
click.echo()
|
|
872
|
+
click.echo("Generating Manifests from Config")
|
|
873
|
+
click.echo("=" * 60)
|
|
874
|
+
click.echo(f"Project: {project_name}")
|
|
875
|
+
click.echo(f"Namespace: {namespace}")
|
|
876
|
+
click.echo(f"Git: {git_repo}@{git_branch}")
|
|
877
|
+
click.echo(f"Output: {output_dir}")
|
|
878
|
+
click.echo()
|
|
879
|
+
|
|
880
|
+
# Update ArgoCD application
|
|
881
|
+
argocd_app = output_dir / "application" / "rem-stack" / "argocd-staging.yaml"
|
|
882
|
+
if argocd_app.exists():
|
|
883
|
+
with open(argocd_app) as f:
|
|
884
|
+
content = f.read()
|
|
885
|
+
|
|
886
|
+
# Update git repo URL
|
|
887
|
+
if "repoURL:" in content:
|
|
888
|
+
import re
|
|
889
|
+
content = re.sub(
|
|
890
|
+
r'repoURL:.*',
|
|
891
|
+
f'repoURL: {git_repo}',
|
|
892
|
+
content,
|
|
893
|
+
)
|
|
894
|
+
content = re.sub(
|
|
895
|
+
r'namespace: rem\b',
|
|
896
|
+
f'namespace: {namespace}',
|
|
897
|
+
content,
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
with open(argocd_app, "w") as f:
|
|
901
|
+
f.write(content)
|
|
902
|
+
click.secho(f" ✓ Updated {argocd_app.name}", fg="green")
|
|
903
|
+
|
|
904
|
+
# Update ClusterSecretStore region
|
|
905
|
+
css = output_dir / "platform" / "external-secrets" / "cluster-secret-store.yaml"
|
|
906
|
+
if css.exists():
|
|
907
|
+
with open(css) as f:
|
|
908
|
+
content = f.read()
|
|
909
|
+
|
|
910
|
+
if "region:" in content:
|
|
911
|
+
import re
|
|
912
|
+
content = re.sub(
|
|
913
|
+
r'region:.*',
|
|
914
|
+
f'region: {region}',
|
|
915
|
+
content,
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
with open(css, "w") as f:
|
|
919
|
+
f.write(content)
|
|
920
|
+
click.secho(f" ✓ Updated {css.name}", fg="green")
|
|
921
|
+
|
|
922
|
+
# Generate SQL init ConfigMap from migrations
|
|
923
|
+
_generate_sql_configmap(project_name, namespace, output_dir)
|
|
924
|
+
|
|
925
|
+
click.echo()
|
|
926
|
+
click.secho("✓ Manifests generated", fg="green")
|
|
927
|
+
click.echo()
|
|
928
|
+
click.echo("Next steps:")
|
|
929
|
+
click.echo(" 1. Review generated manifests")
|
|
930
|
+
click.echo(" 2. Commit changes to git")
|
|
931
|
+
click.echo(" 3. Deploy: rem cluster apply")
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
@click.command()
|
|
935
|
+
@click.option(
|
|
936
|
+
"--config",
|
|
937
|
+
"-c",
|
|
938
|
+
type=click.Path(exists=True, path_type=Path),
|
|
939
|
+
help="Path to cluster config file",
|
|
940
|
+
)
|
|
941
|
+
@click.option(
|
|
942
|
+
"--dry-run",
|
|
943
|
+
is_flag=True,
|
|
944
|
+
help="Show what would be deployed without executing",
|
|
945
|
+
)
|
|
946
|
+
@click.option(
|
|
947
|
+
"--skip-platform",
|
|
948
|
+
is_flag=True,
|
|
949
|
+
help="Skip deploying platform-apps (only deploy rem-stack)",
|
|
950
|
+
)
|
|
951
|
+
def apply(config: Path | None, dry_run: bool, skip_platform: bool):
|
|
952
|
+
"""
|
|
953
|
+
Deploy ArgoCD applications to the cluster.
|
|
954
|
+
|
|
955
|
+
This command:
|
|
956
|
+
1. Creates ArgoCD repository secret (for private repo access)
|
|
957
|
+
2. Creates the application namespace
|
|
958
|
+
3. Deploys platform-apps (app-of-apps for operators)
|
|
959
|
+
4. Deploys rem-stack application
|
|
960
|
+
|
|
961
|
+
Required environment variables:
|
|
962
|
+
- GITHUB_REPO_URL: Git repository URL
|
|
963
|
+
- GITHUB_PAT: GitHub Personal Access Token
|
|
964
|
+
- GITHUB_USERNAME: GitHub username
|
|
965
|
+
|
|
966
|
+
Examples:
|
|
967
|
+
# Full deployment
|
|
968
|
+
rem cluster apply
|
|
969
|
+
|
|
970
|
+
# Preview what would be deployed
|
|
971
|
+
rem cluster apply --dry-run
|
|
972
|
+
|
|
973
|
+
# Only deploy rem-stack (platform already exists)
|
|
974
|
+
rem cluster apply --skip-platform
|
|
975
|
+
"""
|
|
976
|
+
cfg = load_cluster_config(config)
|
|
977
|
+
project_name = cfg.get("project", {}).get("name", "rem")
|
|
978
|
+
namespace = cfg.get("project", {}).get("namespace", project_name)
|
|
979
|
+
git_repo = cfg.get("git", {}).get("repoURL", "")
|
|
980
|
+
|
|
981
|
+
# Get credentials from environment, with fallback to gh CLI
|
|
982
|
+
github_repo_url = os.environ.get("GITHUB_REPO_URL", git_repo)
|
|
983
|
+
github_pat = os.environ.get("GITHUB_PAT", "")
|
|
984
|
+
github_username = os.environ.get("GITHUB_USERNAME", "")
|
|
985
|
+
|
|
986
|
+
# Auto-detect from gh CLI if not set
|
|
987
|
+
if not github_pat or not github_username:
|
|
988
|
+
try:
|
|
989
|
+
# Try to get from gh CLI
|
|
990
|
+
gh_user = subprocess.run(
|
|
991
|
+
["gh", "api", "user", "--jq", ".login"],
|
|
992
|
+
capture_output=True, text=True, timeout=10
|
|
993
|
+
)
|
|
994
|
+
gh_token = subprocess.run(
|
|
995
|
+
["gh", "auth", "token"],
|
|
996
|
+
capture_output=True, text=True, timeout=10
|
|
997
|
+
)
|
|
998
|
+
if gh_user.returncode == 0 and gh_token.returncode == 0:
|
|
999
|
+
if not github_username:
|
|
1000
|
+
github_username = gh_user.stdout.strip()
|
|
1001
|
+
if not github_pat:
|
|
1002
|
+
github_pat = gh_token.stdout.strip()
|
|
1003
|
+
click.secho(" ℹ Using credentials from gh CLI", fg="cyan")
|
|
1004
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
1005
|
+
pass # gh CLI not available
|
|
1006
|
+
|
|
1007
|
+
# Info about token type
|
|
1008
|
+
if github_pat:
|
|
1009
|
+
if github_pat.startswith("gho_"):
|
|
1010
|
+
click.secho(" ℹ Using OAuth token from gh CLI", fg="cyan")
|
|
1011
|
+
elif github_pat.startswith("ghp_"):
|
|
1012
|
+
click.secho(" ℹ Using Personal Access Token", fg="cyan")
|
|
1013
|
+
elif github_pat.startswith("github_pat_"):
|
|
1014
|
+
click.secho(" ℹ Using Fine-grained Personal Access Token", fg="cyan")
|
|
1015
|
+
|
|
1016
|
+
# Auto-detect git remote if repo URL not set
|
|
1017
|
+
if not github_repo_url:
|
|
1018
|
+
try:
|
|
1019
|
+
result = subprocess.run(
|
|
1020
|
+
["git", "remote", "get-url", "origin"],
|
|
1021
|
+
capture_output=True, text=True, timeout=5
|
|
1022
|
+
)
|
|
1023
|
+
if result.returncode == 0:
|
|
1024
|
+
github_repo_url = result.stdout.strip()
|
|
1025
|
+
click.secho(f" ℹ Using repo URL from git remote: {github_repo_url}", fg="cyan")
|
|
1026
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
1027
|
+
pass
|
|
1028
|
+
|
|
1029
|
+
click.echo()
|
|
1030
|
+
click.echo("ArgoCD Application Deployment")
|
|
1031
|
+
click.echo("=" * 60)
|
|
1032
|
+
|
|
1033
|
+
# Pre-validation
|
|
1034
|
+
click.echo("Pre-flight checks:")
|
|
1035
|
+
errors = 0
|
|
1036
|
+
|
|
1037
|
+
# Check kubectl
|
|
1038
|
+
result = subprocess.run(["which", "kubectl"], capture_output=True)
|
|
1039
|
+
if result.returncode != 0:
|
|
1040
|
+
click.secho(" ✗ kubectl not found", fg="red")
|
|
1041
|
+
errors += 1
|
|
1042
|
+
else:
|
|
1043
|
+
click.secho(" ✓ kubectl available", fg="green")
|
|
1044
|
+
|
|
1045
|
+
# Check cluster access
|
|
1046
|
+
result = subprocess.run(
|
|
1047
|
+
["kubectl", "cluster-info"],
|
|
1048
|
+
capture_output=True,
|
|
1049
|
+
timeout=10,
|
|
1050
|
+
)
|
|
1051
|
+
if result.returncode != 0:
|
|
1052
|
+
click.secho(" ✗ Cannot connect to Kubernetes cluster", fg="red")
|
|
1053
|
+
click.echo(" Run: aws eks update-kubeconfig --name <cluster> --profile rem")
|
|
1054
|
+
errors += 1
|
|
1055
|
+
else:
|
|
1056
|
+
click.secho(" ✓ Kubernetes cluster accessible", fg="green")
|
|
1057
|
+
|
|
1058
|
+
# Check argocd namespace exists
|
|
1059
|
+
result = subprocess.run(
|
|
1060
|
+
["kubectl", "get", "namespace", "argocd"],
|
|
1061
|
+
capture_output=True,
|
|
1062
|
+
)
|
|
1063
|
+
if result.returncode != 0:
|
|
1064
|
+
click.secho(" ✗ argocd namespace not found", fg="red")
|
|
1065
|
+
click.echo(" ArgoCD should be installed by CDK (ENABLE_ARGOCD=true)")
|
|
1066
|
+
errors += 1
|
|
1067
|
+
else:
|
|
1068
|
+
click.secho(" ✓ argocd namespace exists", fg="green")
|
|
1069
|
+
|
|
1070
|
+
if errors > 0:
|
|
1071
|
+
click.echo()
|
|
1072
|
+
click.secho(f"Pre-flight failed with {errors} error(s)", fg="red")
|
|
1073
|
+
raise click.Abort()
|
|
1074
|
+
|
|
1075
|
+
click.echo()
|
|
1076
|
+
click.echo(f"Project: {project_name}")
|
|
1077
|
+
click.echo(f"Namespace: {namespace}")
|
|
1078
|
+
click.echo(f"Repository: {github_repo_url}")
|
|
1079
|
+
if dry_run:
|
|
1080
|
+
click.secho("Mode: DRY RUN (no changes will be made)", fg="yellow")
|
|
1081
|
+
click.echo()
|
|
1082
|
+
|
|
1083
|
+
# Validate required values
|
|
1084
|
+
if not github_repo_url:
|
|
1085
|
+
click.secho("✗ GITHUB_REPO_URL not set", fg="red")
|
|
1086
|
+
click.echo(" Set via environment variable or cluster-config.yaml")
|
|
1087
|
+
raise click.Abort()
|
|
1088
|
+
|
|
1089
|
+
if not github_pat or not github_username:
|
|
1090
|
+
click.secho("⚠ GITHUB_PAT or GITHUB_USERNAME not set", fg="yellow")
|
|
1091
|
+
click.echo(" Private repos will not be accessible without credentials")
|
|
1092
|
+
if not click.confirm("Continue without repo credentials?"):
|
|
1093
|
+
raise click.Abort()
|
|
1094
|
+
|
|
1095
|
+
manifests_dir = get_manifests_dir()
|
|
1096
|
+
|
|
1097
|
+
# Step 1: Create ArgoCD repository secret
|
|
1098
|
+
click.echo("1. ArgoCD repository secret")
|
|
1099
|
+
if github_pat and github_username:
|
|
1100
|
+
# Check if secret exists
|
|
1101
|
+
result = subprocess.run(
|
|
1102
|
+
["kubectl", "get", "secret", "repo-reminiscent", "-n", "argocd"],
|
|
1103
|
+
capture_output=True,
|
|
1104
|
+
)
|
|
1105
|
+
secret_exists = result.returncode == 0
|
|
1106
|
+
|
|
1107
|
+
if secret_exists:
|
|
1108
|
+
click.echo(" ⏭ Secret 'repo-reminiscent' exists (skipping)")
|
|
1109
|
+
else:
|
|
1110
|
+
if dry_run:
|
|
1111
|
+
click.echo(" Would create: secret/repo-reminiscent in argocd namespace")
|
|
1112
|
+
else:
|
|
1113
|
+
# Create the secret
|
|
1114
|
+
create_cmd = [
|
|
1115
|
+
"kubectl", "create", "secret", "generic", "repo-reminiscent",
|
|
1116
|
+
"--namespace", "argocd",
|
|
1117
|
+
f"--from-literal=url={github_repo_url}",
|
|
1118
|
+
f"--from-literal=username={github_username}",
|
|
1119
|
+
f"--from-literal=password={github_pat}",
|
|
1120
|
+
"--from-literal=type=git",
|
|
1121
|
+
"--dry-run=client", "-o", "yaml",
|
|
1122
|
+
]
|
|
1123
|
+
# Pipe to kubectl apply
|
|
1124
|
+
create_result = subprocess.run(create_cmd, capture_output=True)
|
|
1125
|
+
if create_result.returncode == 0:
|
|
1126
|
+
apply_result = subprocess.run(
|
|
1127
|
+
["kubectl", "apply", "-f", "-"],
|
|
1128
|
+
input=create_result.stdout,
|
|
1129
|
+
capture_output=True,
|
|
1130
|
+
)
|
|
1131
|
+
if apply_result.returncode == 0:
|
|
1132
|
+
# Label it as ArgoCD repo secret
|
|
1133
|
+
subprocess.run([
|
|
1134
|
+
"kubectl", "label", "secret", "repo-reminiscent",
|
|
1135
|
+
"-n", "argocd",
|
|
1136
|
+
"argocd.argoproj.io/secret-type=repository",
|
|
1137
|
+
"--overwrite",
|
|
1138
|
+
], capture_output=True)
|
|
1139
|
+
click.secho(" ✓ Created secret 'repo-reminiscent'", fg="green")
|
|
1140
|
+
else:
|
|
1141
|
+
click.secho(f" ✗ Failed to create secret: {apply_result.stderr.decode()}", fg="red")
|
|
1142
|
+
raise click.Abort()
|
|
1143
|
+
else:
|
|
1144
|
+
click.echo(" ⏭ Skipping (no credentials provided)")
|
|
1145
|
+
|
|
1146
|
+
# Step 2: Create namespace
|
|
1147
|
+
click.echo()
|
|
1148
|
+
click.echo("2. Application namespace")
|
|
1149
|
+
result = subprocess.run(
|
|
1150
|
+
["kubectl", "get", "namespace", namespace],
|
|
1151
|
+
capture_output=True,
|
|
1152
|
+
)
|
|
1153
|
+
if result.returncode == 0:
|
|
1154
|
+
click.echo(f" ⏭ Namespace '{namespace}' exists")
|
|
1155
|
+
else:
|
|
1156
|
+
if dry_run:
|
|
1157
|
+
click.echo(f" Would create: namespace/{namespace}")
|
|
1158
|
+
else:
|
|
1159
|
+
result = subprocess.run(
|
|
1160
|
+
["kubectl", "create", "namespace", namespace],
|
|
1161
|
+
capture_output=True,
|
|
1162
|
+
)
|
|
1163
|
+
if result.returncode == 0:
|
|
1164
|
+
click.secho(f" ✓ Created namespace '{namespace}'", fg="green")
|
|
1165
|
+
else:
|
|
1166
|
+
click.secho(f" ✗ Failed to create namespace: {result.stderr.decode()}", fg="red")
|
|
1167
|
+
raise click.Abort()
|
|
1168
|
+
|
|
1169
|
+
# Step 3: Deploy platform-apps (app-of-apps)
|
|
1170
|
+
if not skip_platform:
|
|
1171
|
+
click.echo()
|
|
1172
|
+
click.echo("3. Platform apps (app-of-apps)")
|
|
1173
|
+
platform_app = manifests_dir / "platform" / "argocd" / "app-of-apps.yaml"
|
|
1174
|
+
|
|
1175
|
+
if not platform_app.exists():
|
|
1176
|
+
click.secho(f" ✗ Not found: {platform_app}", fg="red")
|
|
1177
|
+
raise click.Abort()
|
|
1178
|
+
|
|
1179
|
+
if dry_run:
|
|
1180
|
+
click.echo(f" Would apply: {platform_app}")
|
|
1181
|
+
else:
|
|
1182
|
+
result = subprocess.run(
|
|
1183
|
+
["kubectl", "apply", "-f", str(platform_app)],
|
|
1184
|
+
capture_output=True,
|
|
1185
|
+
)
|
|
1186
|
+
if result.returncode == 0:
|
|
1187
|
+
click.secho(" ✓ Applied platform-apps", fg="green")
|
|
1188
|
+
else:
|
|
1189
|
+
click.secho(f" ✗ Failed: {result.stderr.decode()}", fg="red")
|
|
1190
|
+
raise click.Abort()
|
|
1191
|
+
|
|
1192
|
+
# Wait for critical platform apps
|
|
1193
|
+
if not dry_run:
|
|
1194
|
+
click.echo()
|
|
1195
|
+
click.echo(" Waiting for cert-manager...")
|
|
1196
|
+
for _ in range(30): # 5 minutes max
|
|
1197
|
+
result = subprocess.run(
|
|
1198
|
+
["kubectl", "get", "application", "cert-manager", "-n", "argocd",
|
|
1199
|
+
"-o", "jsonpath={.status.health.status}"],
|
|
1200
|
+
capture_output=True,
|
|
1201
|
+
)
|
|
1202
|
+
status = result.stdout.decode().strip()
|
|
1203
|
+
if status == "Healthy":
|
|
1204
|
+
click.secho(" ✓ cert-manager is healthy", fg="green")
|
|
1205
|
+
break
|
|
1206
|
+
click.echo(f" ... cert-manager status: {status or 'Unknown'}")
|
|
1207
|
+
import time
|
|
1208
|
+
time.sleep(10)
|
|
1209
|
+
else:
|
|
1210
|
+
click.secho(" ⚠ cert-manager not healthy yet (continuing anyway)", fg="yellow")
|
|
1211
|
+
|
|
1212
|
+
# Step 4: Deploy rem-stack
|
|
1213
|
+
click.echo()
|
|
1214
|
+
click.echo("4. REM stack application" if not skip_platform else "3. REM stack application")
|
|
1215
|
+
rem_stack_app = manifests_dir / "application" / "rem-stack" / "argocd-staging.yaml"
|
|
1216
|
+
|
|
1217
|
+
if not rem_stack_app.exists():
|
|
1218
|
+
click.secho(f" ✗ Not found: {rem_stack_app}", fg="red")
|
|
1219
|
+
raise click.Abort()
|
|
1220
|
+
|
|
1221
|
+
if dry_run:
|
|
1222
|
+
click.echo(f" Would apply: {rem_stack_app}")
|
|
1223
|
+
else:
|
|
1224
|
+
result = subprocess.run(
|
|
1225
|
+
["kubectl", "apply", "-f", str(rem_stack_app)],
|
|
1226
|
+
capture_output=True,
|
|
1227
|
+
)
|
|
1228
|
+
if result.returncode == 0:
|
|
1229
|
+
click.secho(" ✓ Applied rem-stack-staging", fg="green")
|
|
1230
|
+
else:
|
|
1231
|
+
click.secho(f" ✗ Failed: {result.stderr.decode()}", fg="red")
|
|
1232
|
+
raise click.Abort()
|
|
1233
|
+
|
|
1234
|
+
# Summary
|
|
1235
|
+
click.echo()
|
|
1236
|
+
click.echo("=" * 60)
|
|
1237
|
+
if dry_run:
|
|
1238
|
+
click.secho("Dry run complete - no changes made", fg="yellow")
|
|
1239
|
+
else:
|
|
1240
|
+
click.secho("✓ Deployment initiated", fg="green")
|
|
1241
|
+
click.echo()
|
|
1242
|
+
click.echo("Monitor progress:")
|
|
1243
|
+
click.echo(" kubectl get applications -n argocd")
|
|
1244
|
+
click.echo(" watch kubectl get pods -n " + namespace)
|
|
1245
|
+
click.echo()
|
|
1246
|
+
click.echo("ArgoCD UI:")
|
|
1247
|
+
click.echo(" kubectl port-forward svc/argocd-server -n argocd 8080:443")
|
|
1248
|
+
click.echo(" # Get password: kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d")
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
# =============================================================================
|
|
1252
|
+
# Environment Configuration Commands (rem cluster env ...)
|
|
1253
|
+
# =============================================================================
|
|
1254
|
+
|
|
1255
|
+
@click.group()
|
|
1256
|
+
def env():
|
|
1257
|
+
"""
|
|
1258
|
+
Environment configuration management.
|
|
1259
|
+
|
|
1260
|
+
Commands for validating and generating Kubernetes ConfigMaps
|
|
1261
|
+
from local .env files, ensuring consistency between local
|
|
1262
|
+
development and cluster deployments.
|
|
1263
|
+
|
|
1264
|
+
Examples:
|
|
1265
|
+
rem cluster env check # Validate .env for staging
|
|
1266
|
+
rem cluster env check --env prod # Validate for production
|
|
1267
|
+
rem cluster env generate # Generate ConfigMap from .env
|
|
1268
|
+
rem cluster env diff # Compare .env with cluster
|
|
1269
|
+
"""
|
|
1270
|
+
pass
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
# Patterns that indicate localhost/development values inappropriate for cluster
|
|
1274
|
+
LOCALHOST_PATTERNS = [
|
|
1275
|
+
"localhost",
|
|
1276
|
+
"127.0.0.1",
|
|
1277
|
+
"0.0.0.0",
|
|
1278
|
+
"host.docker.internal",
|
|
1279
|
+
]
|
|
1280
|
+
|
|
1281
|
+
# Required env vars for each environment
|
|
1282
|
+
# These align with rem-config ConfigMap structure in manifests/application/rem-stack/base/kustomization.yaml
|
|
1283
|
+
ENV_REQUIREMENTS = {
|
|
1284
|
+
"staging": {
|
|
1285
|
+
"required": [
|
|
1286
|
+
"ENVIRONMENT",
|
|
1287
|
+
"AWS_REGION",
|
|
1288
|
+
"S3__BUCKET_NAME",
|
|
1289
|
+
],
|
|
1290
|
+
"recommended": [
|
|
1291
|
+
"LLM__ANTHROPIC_API_KEY",
|
|
1292
|
+
"LLM__OPENAI_API_KEY",
|
|
1293
|
+
"LLM__DEFAULT_MODEL",
|
|
1294
|
+
"OTEL_COLLECTOR_ENDPOINT",
|
|
1295
|
+
"OTEL__ENABLED",
|
|
1296
|
+
"LOG_LEVEL",
|
|
1297
|
+
"AUTH__ENABLED",
|
|
1298
|
+
"MODELS__IMPORT_MODULES",
|
|
1299
|
+
],
|
|
1300
|
+
"no_localhost": [
|
|
1301
|
+
"POSTGRES__CONNECTION_STRING",
|
|
1302
|
+
"OTEL_COLLECTOR_ENDPOINT",
|
|
1303
|
+
"S3__ENDPOINT_URL",
|
|
1304
|
+
],
|
|
1305
|
+
},
|
|
1306
|
+
"prod": {
|
|
1307
|
+
"required": [
|
|
1308
|
+
"ENVIRONMENT",
|
|
1309
|
+
"AWS_REGION",
|
|
1310
|
+
"S3__BUCKET_NAME",
|
|
1311
|
+
"AUTH__ENABLED",
|
|
1312
|
+
],
|
|
1313
|
+
"recommended": [
|
|
1314
|
+
"LLM__ANTHROPIC_API_KEY",
|
|
1315
|
+
"LLM__OPENAI_API_KEY",
|
|
1316
|
+
"LLM__DEFAULT_MODEL",
|
|
1317
|
+
"OTEL_COLLECTOR_ENDPOINT",
|
|
1318
|
+
"OTEL__ENABLED",
|
|
1319
|
+
"LOG_LEVEL",
|
|
1320
|
+
"AUTH__SESSION_SECRET",
|
|
1321
|
+
"MODELS__IMPORT_MODULES",
|
|
1322
|
+
],
|
|
1323
|
+
"no_localhost": [
|
|
1324
|
+
"POSTGRES__CONNECTION_STRING",
|
|
1325
|
+
"OTEL_COLLECTOR_ENDPOINT",
|
|
1326
|
+
"S3__ENDPOINT_URL",
|
|
1327
|
+
"AUTH__GOOGLE__REDIRECT_URI",
|
|
1328
|
+
"AUTH__MICROSOFT__REDIRECT_URI",
|
|
1329
|
+
],
|
|
1330
|
+
},
|
|
1331
|
+
"local": {
|
|
1332
|
+
"required": [
|
|
1333
|
+
"ENVIRONMENT",
|
|
1334
|
+
],
|
|
1335
|
+
"recommended": [
|
|
1336
|
+
"LLM__ANTHROPIC_API_KEY",
|
|
1337
|
+
"LLM__OPENAI_API_KEY",
|
|
1338
|
+
"MODELS__IMPORT_MODULES",
|
|
1339
|
+
],
|
|
1340
|
+
"no_localhost": [], # localhost is fine for local
|
|
1341
|
+
},
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def load_env_file(env_path: Path) -> dict[str, str]:
|
|
1346
|
+
"""Load environment variables from a .env file."""
|
|
1347
|
+
env_vars = {}
|
|
1348
|
+
if not env_path.exists():
|
|
1349
|
+
return env_vars
|
|
1350
|
+
|
|
1351
|
+
with open(env_path) as f:
|
|
1352
|
+
for line in f:
|
|
1353
|
+
line = line.strip()
|
|
1354
|
+
# Skip comments and empty lines
|
|
1355
|
+
if not line or line.startswith("#"):
|
|
1356
|
+
continue
|
|
1357
|
+
# Parse KEY=value
|
|
1358
|
+
if "=" in line:
|
|
1359
|
+
key, _, value = line.partition("=")
|
|
1360
|
+
key = key.strip()
|
|
1361
|
+
value = value.strip()
|
|
1362
|
+
# Remove quotes if present
|
|
1363
|
+
if value and value[0] in ('"', "'") and value[-1] == value[0]:
|
|
1364
|
+
value = value[1:-1]
|
|
1365
|
+
env_vars[key] = value
|
|
1366
|
+
|
|
1367
|
+
return env_vars
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def has_localhost(value: str) -> bool:
|
|
1371
|
+
"""Check if a value contains localhost-like patterns."""
|
|
1372
|
+
value_lower = value.lower()
|
|
1373
|
+
return any(pattern in value_lower for pattern in LOCALHOST_PATTERNS)
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
@env.command("check")
|
|
1377
|
+
@click.option(
|
|
1378
|
+
"--env-file",
|
|
1379
|
+
"-f",
|
|
1380
|
+
type=click.Path(exists=True, path_type=Path),
|
|
1381
|
+
default=None,
|
|
1382
|
+
help="Path to .env file (default: .env in current directory)",
|
|
1383
|
+
)
|
|
1384
|
+
@click.option(
|
|
1385
|
+
"--environment",
|
|
1386
|
+
"--env",
|
|
1387
|
+
"-e",
|
|
1388
|
+
type=click.Choice(["local", "staging", "prod"]),
|
|
1389
|
+
default="staging",
|
|
1390
|
+
help="Target environment to validate against (default: staging)",
|
|
1391
|
+
)
|
|
1392
|
+
@click.option(
|
|
1393
|
+
"--strict",
|
|
1394
|
+
is_flag=True,
|
|
1395
|
+
help="Treat warnings as errors",
|
|
1396
|
+
)
|
|
1397
|
+
def env_check(env_file: Path | None, environment: str, strict: bool):
|
|
1398
|
+
"""
|
|
1399
|
+
Validate .env file for a target environment.
|
|
1400
|
+
|
|
1401
|
+
Checks that environment variables are appropriate for the target
|
|
1402
|
+
deployment environment (local, staging, prod).
|
|
1403
|
+
|
|
1404
|
+
Validates:
|
|
1405
|
+
- Required variables are set
|
|
1406
|
+
- No localhost values in cluster configs
|
|
1407
|
+
- Recommended variables for the environment
|
|
1408
|
+
- Placeholder values that need updating
|
|
1409
|
+
|
|
1410
|
+
Examples:
|
|
1411
|
+
rem cluster env check # Check .env for staging
|
|
1412
|
+
rem cluster env check --env prod # Check for production
|
|
1413
|
+
rem cluster env check -f backend/.env # Check specific file
|
|
1414
|
+
rem cluster env check --strict # Fail on warnings
|
|
1415
|
+
"""
|
|
1416
|
+
# Find .env file
|
|
1417
|
+
if env_file is None:
|
|
1418
|
+
# Try common locations
|
|
1419
|
+
for candidate in [Path(".env"), Path("application/backend/.env"), Path("backend/.env")]:
|
|
1420
|
+
if candidate.exists():
|
|
1421
|
+
env_file = candidate
|
|
1422
|
+
break
|
|
1423
|
+
|
|
1424
|
+
if env_file is None or not env_file.exists():
|
|
1425
|
+
click.secho("✗ No .env file found", fg="red")
|
|
1426
|
+
click.echo()
|
|
1427
|
+
click.echo("Specify path with: rem cluster env check -f /path/to/.env")
|
|
1428
|
+
raise click.Abort()
|
|
1429
|
+
|
|
1430
|
+
click.echo()
|
|
1431
|
+
click.echo(f"Environment Config Check: {environment}")
|
|
1432
|
+
click.echo("=" * 60)
|
|
1433
|
+
click.echo(f"File: {env_file}")
|
|
1434
|
+
click.echo()
|
|
1435
|
+
|
|
1436
|
+
# Load env vars
|
|
1437
|
+
env_vars = load_env_file(env_file)
|
|
1438
|
+
|
|
1439
|
+
if not env_vars:
|
|
1440
|
+
click.secho("✗ No environment variables found in file", fg="red")
|
|
1441
|
+
raise click.Abort()
|
|
1442
|
+
|
|
1443
|
+
click.echo(f"Found {len(env_vars)} variables")
|
|
1444
|
+
click.echo()
|
|
1445
|
+
|
|
1446
|
+
requirements = ENV_REQUIREMENTS.get(environment, ENV_REQUIREMENTS["staging"])
|
|
1447
|
+
errors = []
|
|
1448
|
+
warnings = []
|
|
1449
|
+
|
|
1450
|
+
# Check required variables
|
|
1451
|
+
click.echo("Required variables:")
|
|
1452
|
+
for var in requirements["required"]:
|
|
1453
|
+
if var in env_vars and env_vars[var]:
|
|
1454
|
+
click.secho(f" ✓ {var}", fg="green")
|
|
1455
|
+
else:
|
|
1456
|
+
errors.append(f"Missing required: {var}")
|
|
1457
|
+
click.secho(f" ✗ {var} (missing or empty)", fg="red")
|
|
1458
|
+
|
|
1459
|
+
# Check for localhost in cluster configs
|
|
1460
|
+
if requirements["no_localhost"]:
|
|
1461
|
+
click.echo()
|
|
1462
|
+
click.echo("Localhost check (should not contain localhost for cluster):")
|
|
1463
|
+
for var in requirements["no_localhost"]:
|
|
1464
|
+
if var in env_vars:
|
|
1465
|
+
value = env_vars[var]
|
|
1466
|
+
if has_localhost(value):
|
|
1467
|
+
errors.append(f"Localhost value in {var}: {value}")
|
|
1468
|
+
click.secho(f" ✗ {var} contains localhost: {value[:50]}...", fg="red")
|
|
1469
|
+
else:
|
|
1470
|
+
click.secho(f" ✓ {var}", fg="green")
|
|
1471
|
+
else:
|
|
1472
|
+
click.echo(f" - {var} (not set)")
|
|
1473
|
+
|
|
1474
|
+
# Check recommended variables
|
|
1475
|
+
click.echo()
|
|
1476
|
+
click.echo("Recommended variables:")
|
|
1477
|
+
for var in requirements["recommended"]:
|
|
1478
|
+
if var in env_vars:
|
|
1479
|
+
value = env_vars[var]
|
|
1480
|
+
# Check for placeholder values
|
|
1481
|
+
if "REPLACE" in value or "YOUR_" in value or value == "":
|
|
1482
|
+
warnings.append(f"Placeholder value: {var}")
|
|
1483
|
+
click.secho(f" ⚠ {var} (placeholder value)", fg="yellow")
|
|
1484
|
+
else:
|
|
1485
|
+
click.secho(f" ✓ {var}", fg="green")
|
|
1486
|
+
else:
|
|
1487
|
+
warnings.append(f"Missing recommended: {var}")
|
|
1488
|
+
click.secho(f" ⚠ {var} (not set)", fg="yellow")
|
|
1489
|
+
|
|
1490
|
+
# Check ENVIRONMENT value matches target
|
|
1491
|
+
click.echo()
|
|
1492
|
+
click.echo("Environment consistency:")
|
|
1493
|
+
env_value = env_vars.get("ENVIRONMENT", "")
|
|
1494
|
+
if env_value == environment or (environment == "local" and env_value == "development"):
|
|
1495
|
+
click.secho(f" ✓ ENVIRONMENT={env_value} (matches target)", fg="green")
|
|
1496
|
+
elif env_value:
|
|
1497
|
+
warnings.append(f"ENVIRONMENT mismatch: {env_value} != {environment}")
|
|
1498
|
+
click.secho(f" ⚠ ENVIRONMENT={env_value} (target is {environment})", fg="yellow")
|
|
1499
|
+
|
|
1500
|
+
# Summary
|
|
1501
|
+
click.echo()
|
|
1502
|
+
click.echo("=" * 60)
|
|
1503
|
+
|
|
1504
|
+
if errors:
|
|
1505
|
+
click.secho(f"✗ Check failed with {len(errors)} error(s)", fg="red")
|
|
1506
|
+
for error in errors:
|
|
1507
|
+
click.echo(f" - {error}")
|
|
1508
|
+
raise click.Abort()
|
|
1509
|
+
elif warnings:
|
|
1510
|
+
if strict:
|
|
1511
|
+
click.secho(f"✗ Check failed with {len(warnings)} warning(s) (strict mode)", fg="red")
|
|
1512
|
+
for warning in warnings:
|
|
1513
|
+
click.echo(f" - {warning}")
|
|
1514
|
+
raise click.Abort()
|
|
1515
|
+
else:
|
|
1516
|
+
click.secho(f"⚠ Check passed with {len(warnings)} warning(s)", fg="yellow")
|
|
1517
|
+
else:
|
|
1518
|
+
click.secho(f"✓ All checks passed for {environment}", fg="green")
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
@env.command("generate")
|
|
1522
|
+
@click.option(
|
|
1523
|
+
"--env-file",
|
|
1524
|
+
"-f",
|
|
1525
|
+
type=click.Path(exists=True, path_type=Path),
|
|
1526
|
+
default=None,
|
|
1527
|
+
help="Path to .env file",
|
|
1528
|
+
)
|
|
1529
|
+
@click.option(
|
|
1530
|
+
"--output",
|
|
1531
|
+
"-o",
|
|
1532
|
+
type=click.Path(path_type=Path),
|
|
1533
|
+
default=None,
|
|
1534
|
+
help="Output path for ConfigMap YAML",
|
|
1535
|
+
)
|
|
1536
|
+
@click.option(
|
|
1537
|
+
"--name",
|
|
1538
|
+
default="rem-config",
|
|
1539
|
+
help="ConfigMap name (default: rem-config)",
|
|
1540
|
+
)
|
|
1541
|
+
@click.option(
|
|
1542
|
+
"--namespace",
|
|
1543
|
+
"-n",
|
|
1544
|
+
default="rem",
|
|
1545
|
+
help="Kubernetes namespace (default: rem)",
|
|
1546
|
+
)
|
|
1547
|
+
@click.option(
|
|
1548
|
+
"--exclude-secrets",
|
|
1549
|
+
is_flag=True,
|
|
1550
|
+
default=True,
|
|
1551
|
+
help="Exclude secret values (API keys, passwords) - default: True",
|
|
1552
|
+
)
|
|
1553
|
+
@click.option(
|
|
1554
|
+
"--apply",
|
|
1555
|
+
is_flag=True,
|
|
1556
|
+
help="Apply ConfigMap directly to cluster",
|
|
1557
|
+
)
|
|
1558
|
+
def env_generate(
|
|
1559
|
+
env_file: Path | None,
|
|
1560
|
+
output: Path | None,
|
|
1561
|
+
name: str,
|
|
1562
|
+
namespace: str,
|
|
1563
|
+
exclude_secrets: bool,
|
|
1564
|
+
apply: bool,
|
|
1565
|
+
):
|
|
1566
|
+
"""
|
|
1567
|
+
Generate Kubernetes ConfigMap from .env file.
|
|
1568
|
+
|
|
1569
|
+
Converts local .env file to a Kubernetes ConfigMap YAML,
|
|
1570
|
+
optionally excluding sensitive values (API keys, passwords).
|
|
1571
|
+
|
|
1572
|
+
Secret values should be managed via ExternalSecrets/SSM, not ConfigMaps.
|
|
1573
|
+
|
|
1574
|
+
Examples:
|
|
1575
|
+
rem cluster env generate # Generate from .env
|
|
1576
|
+
rem cluster env generate -o configmap.yaml # Custom output path
|
|
1577
|
+
rem cluster env generate --apply # Apply to cluster
|
|
1578
|
+
"""
|
|
1579
|
+
# Secret patterns to exclude
|
|
1580
|
+
secret_patterns = [
|
|
1581
|
+
"API_KEY",
|
|
1582
|
+
"SECRET",
|
|
1583
|
+
"PASSWORD",
|
|
1584
|
+
"TOKEN",
|
|
1585
|
+
"CREDENTIAL",
|
|
1586
|
+
]
|
|
1587
|
+
|
|
1588
|
+
# Find .env file
|
|
1589
|
+
if env_file is None:
|
|
1590
|
+
for candidate in [Path(".env"), Path("application/backend/.env"), Path("backend/.env")]:
|
|
1591
|
+
if candidate.exists():
|
|
1592
|
+
env_file = candidate
|
|
1593
|
+
break
|
|
1594
|
+
|
|
1595
|
+
if env_file is None or not env_file.exists():
|
|
1596
|
+
click.secho("✗ No .env file found", fg="red")
|
|
1597
|
+
raise click.Abort()
|
|
1598
|
+
|
|
1599
|
+
click.echo()
|
|
1600
|
+
click.echo("Generate ConfigMap from .env")
|
|
1601
|
+
click.echo("=" * 60)
|
|
1602
|
+
click.echo(f"Source: {env_file}")
|
|
1603
|
+
click.echo(f"ConfigMap: {name}")
|
|
1604
|
+
click.echo(f"Namespace: {namespace}")
|
|
1605
|
+
click.echo()
|
|
1606
|
+
|
|
1607
|
+
# Load env vars
|
|
1608
|
+
env_vars = load_env_file(env_file)
|
|
1609
|
+
|
|
1610
|
+
# Filter out secrets if requested
|
|
1611
|
+
config_data = {}
|
|
1612
|
+
excluded = []
|
|
1613
|
+
|
|
1614
|
+
for key, value in env_vars.items():
|
|
1615
|
+
# Check if this looks like a secret
|
|
1616
|
+
is_secret = any(pattern in key.upper() for pattern in secret_patterns)
|
|
1617
|
+
|
|
1618
|
+
if exclude_secrets and is_secret:
|
|
1619
|
+
excluded.append(key)
|
|
1620
|
+
else:
|
|
1621
|
+
config_data[key] = value
|
|
1622
|
+
|
|
1623
|
+
click.echo(f"Variables to include: {len(config_data)}")
|
|
1624
|
+
if excluded:
|
|
1625
|
+
click.echo(f"Excluded (secrets): {len(excluded)}")
|
|
1626
|
+
for key in excluded[:5]:
|
|
1627
|
+
click.echo(f" - {key}")
|
|
1628
|
+
if len(excluded) > 5:
|
|
1629
|
+
click.echo(f" ... and {len(excluded) - 5} more")
|
|
1630
|
+
|
|
1631
|
+
# Generate ConfigMap
|
|
1632
|
+
configmap = {
|
|
1633
|
+
"apiVersion": "v1",
|
|
1634
|
+
"kind": "ConfigMap",
|
|
1635
|
+
"metadata": {
|
|
1636
|
+
"name": name,
|
|
1637
|
+
"namespace": namespace,
|
|
1638
|
+
"labels": {
|
|
1639
|
+
"app.kubernetes.io/managed-by": "rem-cli",
|
|
1640
|
+
},
|
|
1641
|
+
},
|
|
1642
|
+
"data": config_data,
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
# Output
|
|
1646
|
+
if output is None:
|
|
1647
|
+
output = Path(f"{name}-configmap.yaml")
|
|
1648
|
+
|
|
1649
|
+
with open(output, "w") as f:
|
|
1650
|
+
f.write(f"# Generated by: rem cluster env generate\n")
|
|
1651
|
+
f.write(f"# Source: {env_file}\n")
|
|
1652
|
+
f.write(f"# Date: {__import__('datetime').datetime.utcnow().isoformat()}Z\n")
|
|
1653
|
+
f.write("#\n")
|
|
1654
|
+
if excluded:
|
|
1655
|
+
f.write("# Excluded secrets (use ExternalSecrets for these):\n")
|
|
1656
|
+
for key in excluded:
|
|
1657
|
+
f.write(f"# - {key}\n")
|
|
1658
|
+
f.write("#\n")
|
|
1659
|
+
yaml.dump(configmap, f, default_flow_style=False, sort_keys=False)
|
|
1660
|
+
|
|
1661
|
+
click.echo()
|
|
1662
|
+
click.secho(f"✓ Generated: {output}", fg="green")
|
|
1663
|
+
|
|
1664
|
+
if apply:
|
|
1665
|
+
click.echo()
|
|
1666
|
+
click.echo("Applying to cluster...")
|
|
1667
|
+
try:
|
|
1668
|
+
subprocess.run(["kubectl", "apply", "-f", str(output)], check=True)
|
|
1669
|
+
click.secho("✓ ConfigMap applied", fg="green")
|
|
1670
|
+
except subprocess.CalledProcessError as e:
|
|
1671
|
+
click.secho(f"✗ Failed to apply: {e}", fg="red")
|
|
1672
|
+
raise click.Abort()
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
@env.command("diff")
|
|
1676
|
+
@click.option(
|
|
1677
|
+
"--env-file",
|
|
1678
|
+
"-f",
|
|
1679
|
+
type=click.Path(exists=True, path_type=Path),
|
|
1680
|
+
default=None,
|
|
1681
|
+
help="Path to .env file",
|
|
1682
|
+
)
|
|
1683
|
+
@click.option(
|
|
1684
|
+
"--configmap",
|
|
1685
|
+
"-c",
|
|
1686
|
+
default="rem-config",
|
|
1687
|
+
help="ConfigMap name to compare (default: rem-config)",
|
|
1688
|
+
)
|
|
1689
|
+
@click.option(
|
|
1690
|
+
"--namespace",
|
|
1691
|
+
"-n",
|
|
1692
|
+
default="rem",
|
|
1693
|
+
help="Kubernetes namespace (default: rem)",
|
|
1694
|
+
)
|
|
1695
|
+
def env_diff(env_file: Path | None, configmap: str, namespace: str):
|
|
1696
|
+
"""
|
|
1697
|
+
Compare local .env with cluster ConfigMap.
|
|
1698
|
+
|
|
1699
|
+
Shows differences between local environment configuration
|
|
1700
|
+
and what's deployed in the Kubernetes cluster.
|
|
1701
|
+
|
|
1702
|
+
Examples:
|
|
1703
|
+
rem cluster env diff # Compare with rem-config
|
|
1704
|
+
rem cluster env diff -c my-config # Compare with custom ConfigMap
|
|
1705
|
+
rem cluster env diff -n production # Compare in different namespace
|
|
1706
|
+
"""
|
|
1707
|
+
# Find .env file
|
|
1708
|
+
if env_file is None:
|
|
1709
|
+
for candidate in [Path(".env"), Path("application/backend/.env"), Path("backend/.env")]:
|
|
1710
|
+
if candidate.exists():
|
|
1711
|
+
env_file = candidate
|
|
1712
|
+
break
|
|
1713
|
+
|
|
1714
|
+
if env_file is None or not env_file.exists():
|
|
1715
|
+
click.secho("✗ No .env file found", fg="red")
|
|
1716
|
+
raise click.Abort()
|
|
1717
|
+
|
|
1718
|
+
click.echo()
|
|
1719
|
+
click.echo("Compare .env with Cluster ConfigMap")
|
|
1720
|
+
click.echo("=" * 60)
|
|
1721
|
+
click.echo(f"Local: {env_file}")
|
|
1722
|
+
click.echo(f"Cluster: {configmap} (namespace: {namespace})")
|
|
1723
|
+
click.echo()
|
|
1724
|
+
|
|
1725
|
+
# Load local env
|
|
1726
|
+
local_vars = load_env_file(env_file)
|
|
1727
|
+
|
|
1728
|
+
# Get cluster ConfigMap
|
|
1729
|
+
try:
|
|
1730
|
+
result = subprocess.run(
|
|
1731
|
+
["kubectl", "get", "configmap", configmap, "-n", namespace, "-o", "yaml"],
|
|
1732
|
+
capture_output=True,
|
|
1733
|
+
check=True,
|
|
1734
|
+
)
|
|
1735
|
+
cluster_cm = yaml.safe_load(result.stdout.decode())
|
|
1736
|
+
cluster_vars = cluster_cm.get("data", {})
|
|
1737
|
+
except subprocess.CalledProcessError:
|
|
1738
|
+
click.secho(f"✗ ConfigMap {configmap} not found in {namespace}", fg="red")
|
|
1739
|
+
click.echo()
|
|
1740
|
+
click.echo("Generate and apply with:")
|
|
1741
|
+
click.echo(f" rem cluster env generate --name {configmap} --namespace {namespace} --apply")
|
|
1742
|
+
raise click.Abort()
|
|
1743
|
+
|
|
1744
|
+
# Compare
|
|
1745
|
+
local_keys = set(local_vars.keys())
|
|
1746
|
+
cluster_keys = set(cluster_vars.keys())
|
|
1747
|
+
|
|
1748
|
+
only_local = local_keys - cluster_keys
|
|
1749
|
+
only_cluster = cluster_keys - local_keys
|
|
1750
|
+
common = local_keys & cluster_keys
|
|
1751
|
+
|
|
1752
|
+
# Check for differences in common keys
|
|
1753
|
+
different = []
|
|
1754
|
+
for key in common:
|
|
1755
|
+
if local_vars[key] != cluster_vars[key]:
|
|
1756
|
+
different.append(key)
|
|
1757
|
+
|
|
1758
|
+
# Report
|
|
1759
|
+
if only_local:
|
|
1760
|
+
click.echo(f"Only in local .env ({len(only_local)}):")
|
|
1761
|
+
for key in sorted(only_local)[:10]:
|
|
1762
|
+
click.secho(f" + {key}", fg="green")
|
|
1763
|
+
if len(only_local) > 10:
|
|
1764
|
+
click.echo(f" ... and {len(only_local) - 10} more")
|
|
1765
|
+
click.echo()
|
|
1766
|
+
|
|
1767
|
+
if only_cluster:
|
|
1768
|
+
click.echo(f"Only in cluster ({len(only_cluster)}):")
|
|
1769
|
+
for key in sorted(only_cluster)[:10]:
|
|
1770
|
+
click.secho(f" - {key}", fg="red")
|
|
1771
|
+
if len(only_cluster) > 10:
|
|
1772
|
+
click.echo(f" ... and {len(only_cluster) - 10} more")
|
|
1773
|
+
click.echo()
|
|
1774
|
+
|
|
1775
|
+
if different:
|
|
1776
|
+
click.echo(f"Different values ({len(different)}):")
|
|
1777
|
+
for key in sorted(different)[:10]:
|
|
1778
|
+
click.secho(f" ~ {key}", fg="yellow")
|
|
1779
|
+
# Show truncated values (hide secrets)
|
|
1780
|
+
if "SECRET" not in key.upper() and "KEY" not in key.upper() and "PASSWORD" not in key.upper():
|
|
1781
|
+
local_val = local_vars[key][:30] + "..." if len(local_vars[key]) > 30 else local_vars[key]
|
|
1782
|
+
cluster_val = cluster_vars[key][:30] + "..." if len(cluster_vars[key]) > 30 else cluster_vars[key]
|
|
1783
|
+
click.echo(f" local: {local_val}")
|
|
1784
|
+
click.echo(f" cluster: {cluster_val}")
|
|
1785
|
+
if len(different) > 10:
|
|
1786
|
+
click.echo(f" ... and {len(different) - 10} more")
|
|
1787
|
+
click.echo()
|
|
1788
|
+
|
|
1789
|
+
# Summary
|
|
1790
|
+
click.echo("=" * 60)
|
|
1791
|
+
if not only_local and not only_cluster and not different:
|
|
1792
|
+
click.secho("✓ Local .env matches cluster ConfigMap", fg="green")
|
|
1793
|
+
else:
|
|
1794
|
+
total_diff = len(only_local) + len(only_cluster) + len(different)
|
|
1795
|
+
click.secho(f"⚠ Found {total_diff} difference(s)", fg="yellow")
|
|
1796
|
+
click.echo()
|
|
1797
|
+
click.echo("To sync local → cluster:")
|
|
1798
|
+
click.echo(f" rem cluster env generate --name {configmap} --namespace {namespace} --apply")
|
|
1799
|
+
|
|
1800
|
+
|
|
1801
|
+
def register_commands(cluster_group):
|
|
1802
|
+
"""Register all cluster commands."""
|
|
1803
|
+
cluster_group.add_command(init)
|
|
1804
|
+
cluster_group.add_command(setup_ssm)
|
|
1805
|
+
cluster_group.add_command(validate)
|
|
1806
|
+
cluster_group.add_command(generate)
|
|
1807
|
+
cluster_group.add_command(apply)
|
|
1808
|
+
cluster_group.add_command(env)
|