vm-tool 1.0.41__py3-none-any.whl → 1.0.43__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.
vm_tool/cli.py CHANGED
@@ -6,7 +6,7 @@ def main():
6
6
  parser = argparse.ArgumentParser(
7
7
  description="VM Tool: Setup, Provision, and Manage VMs"
8
8
  )
9
- parser.add_argument("--version", action="version", version="1.0.41")
9
+ parser.add_argument("--version", action="version", version="1.0.43")
10
10
  parser.add_argument(
11
11
  "--verbose", "-v", action="store_true", help="Enable verbose output"
12
12
  )
@@ -168,6 +168,29 @@ def main():
168
168
  "--inventory", type=str, default="inventory.yml", help="Inventory file to use"
169
169
  )
170
170
 
171
+ # Hydrate Env command
172
+ hydrate_parser = subparsers.add_parser(
173
+ "hydrate-env", help="Hydrate missing env files from secrets"
174
+ )
175
+ hydrate_parser.add_argument(
176
+ "--compose-file",
177
+ type=str,
178
+ default="docker-compose.yml",
179
+ help="Path to docker-compose.yml",
180
+ )
181
+ hydrate_parser.add_argument(
182
+ "--secrets",
183
+ type=str,
184
+ required=True,
185
+ help="JSON string of GitHub secrets",
186
+ )
187
+ hydrate_parser.add_argument(
188
+ "--project-root",
189
+ type=str,
190
+ default=".",
191
+ help="Project root directory",
192
+ )
193
+
171
194
  # Docker Deploy command
172
195
  docker_parser = subparsers.add_parser(
173
196
  "deploy-docker", help="Deploy using Docker Compose"
@@ -271,6 +294,23 @@ def main():
271
294
  help="CI/CD Platform",
272
295
  )
273
296
 
297
+ # Secrets command
298
+ secrets_parser = subparsers.add_parser("secrets", help="Manage secrets")
299
+ secrets_subparsers = secrets_parser.add_subparsers(
300
+ dest="secrets_command", help="Secrets operations"
301
+ )
302
+
303
+ # secrets sync
304
+ sync_parser = secrets_subparsers.add_parser(
305
+ "sync", help="Sync local .env to GitHub Secrets"
306
+ )
307
+ sync_parser.add_argument(
308
+ "--env-file", type=str, default=".env", help="Path to .env file"
309
+ )
310
+ sync_parser.add_argument(
311
+ "--repo", type=str, help="Target GitHub repository (owner/repo)"
312
+ )
313
+
274
314
  args = parser.parse_args()
275
315
 
276
316
  # Configure logging based on flags
@@ -637,6 +677,25 @@ def main():
637
677
  print(f"Error: {e}")
638
678
  sys.exit(1)
639
679
 
680
+ elif args.command == "hydrate-env":
681
+ try:
682
+ import json
683
+ from vm_tool.runner import SetupRunner, SetupRunnerConfig
684
+
685
+ secrets_map = json.loads(args.secrets)
686
+ config = SetupRunnerConfig(github_project_url="dummy")
687
+ runner = SetupRunner(config)
688
+
689
+ print("💧 Hydrating environment files from secrets...")
690
+ runner.hydrate_env_from_secrets(
691
+ compose_file=args.compose_file,
692
+ secrets_map=secrets_map,
693
+ project_root=args.project_root,
694
+ )
695
+ except Exception as e:
696
+ print(f"❌ Failed to hydrate env: {e}")
697
+ sys.exit(1)
698
+
640
699
  elif args.command == "completion":
641
700
  from vm_tool.completion import print_completion, install_completion
642
701
 
@@ -760,6 +819,63 @@ def main():
760
819
  except Exception as e:
761
820
  print(f"Error: {e}")
762
821
  sys.exit(1)
822
+ elif args.command == "secrets":
823
+ if args.secrets_command == "sync":
824
+ from vm_tool.secrets import SecretsManager
825
+ import os
826
+
827
+ if not os.path.exists(args.env_file):
828
+ print(f"❌ Env file not found: {args.env_file}")
829
+ sys.exit(1)
830
+
831
+ try:
832
+ manager = SecretsManager.from_github(repo=args.repo)
833
+
834
+ print(f"📖 Reading secrets from {args.env_file}...")
835
+ secrets_to_sync = {}
836
+ with open(args.env_file, "r") as f:
837
+ for line in f:
838
+ line = line.strip()
839
+ if not line or line.startswith("#"):
840
+ continue
841
+ # Basic env parsing
842
+ if "=" in line:
843
+ key, value = line.split("=", 1)
844
+ key = key.strip()
845
+ value = value.strip().strip("'").strip('"')
846
+ secrets_to_sync[key] = value
847
+
848
+ if not secrets_to_sync:
849
+ print("⚠️ No secrets found to sync.")
850
+ sys.exit(0)
851
+
852
+ print(f"🚀 Syncing {len(secrets_to_sync)} secrets to GitHub...")
853
+ # Confirm with user
854
+ confirm = (
855
+ input(
856
+ f"Proceed to upload {len(secrets_to_sync)} secrets? (yes/no): "
857
+ )
858
+ .strip()
859
+ .lower()
860
+ )
861
+ if confirm != "yes":
862
+ print("❌ Operation cancelled.")
863
+ sys.exit(0)
864
+
865
+ success_count = 0
866
+ for key, value in secrets_to_sync.items():
867
+ print(f" Uploading {key}...")
868
+ if manager.set(key, value):
869
+ success_count += 1
870
+
871
+ print(
872
+ f"\n✨ Successfully synced {success_count}/{len(secrets_to_sync)} secrets!"
873
+ )
874
+
875
+ except Exception as e:
876
+ print(f"❌ Error syncing secrets: {e}")
877
+ sys.exit(1)
878
+
763
879
  else:
764
880
  parser.print_help()
765
881
 
vm_tool/generator.py CHANGED
@@ -22,6 +22,8 @@ class PipelineGenerator:
22
22
  health_url: Optional[str] = None,
23
23
  backup_paths: Optional[list] = None,
24
24
  app_port: int = 8000,
25
+ hydrate_env: bool = True,
26
+ combine_compose: bool = True,
25
27
  ):
26
28
  self.platform = platform
27
29
  self.strategy = strategy
@@ -37,6 +39,8 @@ class PipelineGenerator:
37
39
  )
38
40
  self.backup_paths = backup_paths or ["/app", "/etc/nginx"]
39
41
  self.app_port = app_port
42
+ self.hydrate_env = hydrate_env
43
+ self.combine_compose = combine_compose
40
44
 
41
45
  # New options
42
46
  self.run_linting = False
@@ -50,8 +54,7 @@ class PipelineGenerator:
50
54
  run_tests: bool = False,
51
55
  python_version: str = "3.11",
52
56
  branch: str = "main",
53
- ):
54
- """Set additional options for the pipeline."""
57
+ ): """Set additional options for the pipeline."""
55
58
  self.run_linting = run_linting
56
59
  self.run_tests = run_tests
57
60
  self.python_version = python_version
@@ -120,6 +123,12 @@ class PipelineGenerator:
120
123
  if self.enable_dry_run:
121
124
  steps.append(self._step_dry_run())
122
125
 
126
+ if self.hydrate_env:
127
+ steps.append(self._step_hydrate_env())
128
+
129
+ if self.combine_compose:
130
+ steps.append(self._step_combine_compose())
131
+
123
132
  # Main deployment
124
133
  steps.append(self._step_deploy())
125
134
 
@@ -143,7 +152,7 @@ class PipelineGenerator:
143
152
  # Combine all steps
144
153
  steps_yaml = "\n".join(steps)
145
154
 
146
- return f"""name: Deploy to EC2 with vm_tool
155
+ return f"""name: Deploy with vm_tool
147
156
 
148
157
  on:
149
158
  push:
@@ -153,8 +162,8 @@ on:
153
162
  workflow_dispatch:
154
163
 
155
164
  env:
156
- EC2_HOST: ${{{{ secrets.EC2_HOST }}}}
157
- EC2_USER: ${{{{ secrets.EC2_USER }}}}
165
+ SSH_HOSTNAME: ${{{{ secrets.SSH_HOSTNAME }}}}
166
+ SSH_USERNAME: ${{{{ secrets.SSH_USERNAME }}}}
158
167
  APP_PORT: {self.app_port}
159
168
 
160
169
  jobs:
@@ -176,16 +185,16 @@ jobs:
176
185
  echo "🔐 Validating GitHub Secrets..."
177
186
  MISSING_SECRETS=()
178
187
 
179
- if [ -z "${{ secrets.EC2_HOST }}" ]; then
180
- MISSING_SECRETS+=("EC2_HOST")
188
+ if [ -z "${{ secrets.SSH_HOSTNAME }}" ]; then
189
+ MISSING_SECRETS+=("SSH_HOSTNAME")
181
190
  fi
182
191
 
183
- if [ -z "${{ secrets.EC2_USER }}" ]; then
184
- MISSING_SECRETS+=("EC2_USER")
192
+ if [ -z "${{ secrets.SSH_USERNAME }}" ]; then
193
+ MISSING_SECRETS+=("SSH_USERNAME")
185
194
  fi
186
195
 
187
- if [ -z "${{ secrets.EC2_SSH_KEY }}" ]; then
188
- MISSING_SECRETS+=("EC2_SSH_KEY")
196
+ if [ -z "${{ secrets.SSH_ID_RSA }}" ]; then
197
+ MISSING_SECRETS+=("SSH_ID_RSA")
189
198
  fi
190
199
 
191
200
  if [ ${#MISSING_SECRETS[@]} -ne 0 ]; then
@@ -199,16 +208,16 @@ jobs:
199
208
  echo "2. Add each secret:"
200
209
  echo ""
201
210
 
202
- if [[ " ${MISSING_SECRETS[*]} " =~ " EC2_HOST " ]]; then
203
- echo " EC2_HOST: Your EC2 IP (e.g., 54.123.45.67)"
211
+ if [[ " ${MISSING_SECRETS[*]} " =~ " SSH_HOSTNAME " ]]; then
212
+ echo " SSH_HOSTNAME: Your Server IP (e.g., 54.123.45.67)"
204
213
  fi
205
214
 
206
- if [[ " ${MISSING_SECRETS[*]} " =~ " EC2_USER " ]]; then
207
- echo " EC2_USER: SSH username (e.g., ubuntu)"
215
+ if [[ " ${MISSING_SECRETS[*]} " =~ " SSH_USERNAME " ]]; then
216
+ echo " SSH_USERNAME: SSH username (e.g., ubuntu)"
208
217
  fi
209
218
 
210
- if [[ " ${MISSING_SECRETS[*]} " =~ " EC2_SSH_KEY " ]]; then
211
- echo " EC2_SSH_KEY: Run 'cat ~/.ssh/id_rsa' and copy output"
219
+ if [[ " ${MISSING_SECRETS[*]} " =~ " SSH_ID_RSA " ]]; then
220
+ echo " SSH_ID_RSA: Run 'cat ~/.ssh/id_rsa' and copy output"
212
221
  fi
213
222
 
214
223
  echo ""
@@ -293,9 +302,54 @@ jobs:
293
302
  - name: Set up SSH
294
303
  run: |
295
304
  mkdir -p ~/.ssh
296
- echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/deploy_key
305
+ echo "${{ secrets.SSH_ID_RSA }}" > ~/.ssh/deploy_key
297
306
  chmod 600 ~/.ssh/deploy_key
298
- ssh-keyscan -H ${{ secrets.EC2_HOST }} >> ~/.ssh/known_hosts"""
307
+ ssh-keyscan -H ${{ secrets.SSH_HOSTNAME }} >> ~/.ssh/known_hosts
308
+
309
+ # Create SSH config to use the key for the specific host
310
+ cat > ~/.ssh/config <<EOF
311
+ Host ${{ secrets.SSH_HOSTNAME }}
312
+ User ${{ secrets.SSH_USERNAME }}
313
+ IdentityFile ~/.ssh/deploy_key
314
+ StrictHostKeyChecking no
315
+ EOF"""
316
+
317
+ def _step_hydrate_env(self) -> str:
318
+ return """
319
+ - name: Hydrate Environment Files from Secrets
320
+ env:
321
+ SECRETS_CONTEXT: ${{ toJSON(secrets) }}
322
+ run: |
323
+ # Use vm_tool to create local env files from secrets based on compose file
324
+ vm_tool hydrate-env \\
325
+ --compose-file docker-compose.yml \\
326
+ --secrets "$SECRETS_CONTEXT"
327
+ echo "✅ Hydrated environment files from secrets"
328
+ """
329
+
330
+ def _step_combine_compose(self) -> str:
331
+ return """
332
+ - name: Combine Docker Compose files
333
+ run: |
334
+ # Combine base and prod compose files if prod exists
335
+ if [ -f docker/docker-compose.prod.yml ]; then
336
+ echo "Merging docker-compose.yml and docker/docker-compose.prod.yml..."
337
+ docker compose -f docker-compose.yml -f docker/docker-compose.prod.yml config > docker-compose.released.yml
338
+ elif [ -f docker-compose.prod.yml ]; then
339
+ echo "Merging docker-compose.yml and docker-compose.prod.yml..."
340
+ docker compose -f docker-compose.yml -f docker-compose.prod.yml config > docker-compose.released.yml
341
+ else
342
+ echo "No prod overriding file found, using base file..."
343
+ docker compose -f docker-compose.yml config > docker-compose.released.yml
344
+ fi
345
+
346
+ # Replace absolute paths (from CI runner) with relative paths
347
+ # This assumes the structure: /home/runner/work/repo/repo/ -> ./
348
+ sed -i 's|/home/runner/work/[^/]*/[^/]*||g' docker-compose.released.yml
349
+
350
+ echo "✅ Generated combined Docker Compose file"
351
+ cat docker-compose.released.yml
352
+ """
299
353
 
300
354
  def _step_validate_ssh(self) -> str:
301
355
  return """
@@ -356,25 +410,52 @@ jobs:
356
410
  def _step_deploy(self) -> str:
357
411
  return """
358
412
  - name: Deploy with vm_tool (Ansible-based)
413
+ env:
414
+ # Define deployment command for the released file
415
+ DEPLOY_COMMAND: "docker compose -f docker-compose.released.yml up -d --remove-orphans"
359
416
  run: |
360
417
  # Create inventory file for Ansible
361
418
  cat > inventory.yml << EOF
362
419
  all:
363
420
  hosts:
364
421
  production:
365
- ansible_host: ${{ secrets.EC2_HOST }}
366
- ansible_user: ${{ secrets.EC2_USER }}
422
+ ansible_host: ${{ secrets.SSH_HOSTNAME }}
423
+ ansible_user: ${{ secrets.SSH_USERNAME }}
367
424
  ansible_ssh_private_key_file: ~/.ssh/deploy_key
368
425
  EOF
369
426
 
370
427
  # Deploy using vm_tool (uses Ansible under the hood)
371
428
  export GITHUB_REPOSITORY_OWNER=${{ github.repository_owner }}
372
- vm_tool deploy-docker \\
373
- --host ${{ secrets.EC2_HOST }} \\
374
- --user ${{ secrets.EC2_USER }} \\
375
- --compose-file ~/app/docker-compose.yml \\
376
- --inventory inventory.yml \\
377
- --force"""
429
+
430
+ # Determine Project Directory based on user (handle root case)
431
+ if [ "${{ secrets.SSH_USERNAME }}" = "root" ]; then
432
+ HOME_DIR="/root"
433
+ else
434
+ HOME_DIR="/home/${{ secrets.SSH_USERNAME }}"
435
+ fi
436
+ PROJECT_DIR="${HOME_DIR}/apps/${{ github.event.repository.name }}"
437
+
438
+ # Prepare arguments
439
+ ARGS=(
440
+ "--inventory" "inventory.yml"
441
+ "--compose-file" "docker-compose.released.yml"
442
+ "--project-dir" "$PROJECT_DIR"
443
+ "--deploy-command" "${{ env.DEPLOY_COMMAND }}"
444
+ "--force"
445
+ "--health-url" "http://${{ secrets.SSH_HOSTNAME }}:${{ env.APP_PORT }}/health"
446
+ "--host" "${{ secrets.SSH_HOSTNAME }}"
447
+ "--user" "${{ secrets.SSH_USERNAME }}"
448
+ )
449
+
450
+ # Move .env if it exists and add to args
451
+ if [ -f .env ]; then
452
+ mkdir -p env
453
+ mv .env env/.env
454
+ ARGS+=("--env-file" "env/.env")
455
+ fi
456
+
457
+ vm_tool deploy-docker "${ARGS[@]}"
458
+ """
378
459
 
379
460
  def _step_health_check(self) -> str:
380
461
  return f"""
@@ -394,10 +475,13 @@ jobs:
394
475
  return """
395
476
  - name: Verify
396
477
  run: |
397
- ssh -i ~/.ssh/deploy_key ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} << 'EOF'
398
- cd ~/app
399
- docker-compose ps
400
- docker-compose logs --tail=20
478
+ # Use absolute path for verify step too
479
+ PROJECT_DIR="/home/${{ secrets.SSH_USERNAME }}/apps/${{ github.event.repository.name }}"
480
+
481
+ ssh -i ~/.ssh/deploy_key ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOSTNAME }} << EOF
482
+ cd $PROJECT_DIR
483
+ docker compose ps
484
+ docker compose logs --tail=20
401
485
  EOF"""
402
486
 
403
487
  def _step_rollback(self) -> str:
@@ -406,12 +490,12 @@ jobs:
406
490
  if: failure()
407
491
  run: |
408
492
  echo "⚠️ Rolling back..."
409
- ssh -i ~/.ssh/deploy_key ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} << 'EOF'
493
+ # Note: Rollback logic might need adjustment for absolute paths, keeping simple for now
494
+ ssh -i ~/.ssh/deploy_key ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOSTNAME }} << 'EOF'
410
495
  BACKUP=$(ls -t ~/backups/*.tar.gz 2>/dev/null | head -1)
411
496
  if [ -n "$BACKUP" ]; then
412
- cd ~/app && tar -xzf $BACKUP
413
- docker-compose up -d
414
- echo "✅ Rolled back"
497
+ # TODO: Improve rollback to handle dynamic dirs
498
+ echo "Rollback not fully implemented for dynamic paths yet"
415
499
  fi
416
500
  EOF"""
417
501
 
@@ -420,7 +504,7 @@ jobs:
420
504
  - name: Cleanup
421
505
  if: success()
422
506
  run: |
423
- ssh -i ~/.ssh/deploy_key ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} << 'EOF'
507
+ ssh -i ~/.ssh/deploy_key ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOSTNAME }} << 'EOF'
424
508
  cd ~/backups 2>/dev/null || exit 0
425
509
  ls -t *.tar.gz 2>/dev/null | tail -n +6 | xargs rm -f || true
426
510
  EOF"""
@@ -431,7 +515,7 @@ jobs:
431
515
  if: always()
432
516
  run: |
433
517
  if [ "${{ job.status }}" == "success" ]; then
434
- echo "✅ Deployed to ${{ secrets.EC2_HOST }}:${{ env.APP_PORT }}"
518
+ echo "✅ Deployed to ${{ secrets.SSH_HOSTNAME }}:${{ env.APP_PORT }}"
435
519
  else
436
520
  echo "❌ Deployment failed"
437
521
  fi"""
vm_tool/runner.py CHANGED
@@ -171,6 +171,75 @@ class SetupRunner:
171
171
  self.env_path = config.env_path
172
172
  self.env_data = config.env_data
173
173
 
174
+ def _get_compose_dependencies(self, compose_file: str) -> List[dict]:
175
+ """
176
+ Parses docker-compose file to find local file dependencies (env_files, volumes).
177
+ Returns a list of dicts: {'src': 'local/path', 'dest': 'remote/path'}
178
+ """
179
+ dependencies = []
180
+ if not os.path.exists(compose_file):
181
+ return dependencies
182
+
183
+ try:
184
+ with open(compose_file, "r") as f:
185
+ data = yaml.safe_load(f)
186
+
187
+ if not data or "services" not in data:
188
+ return dependencies
189
+
190
+ found_paths = set()
191
+
192
+ for service in data.get("services", {}).values():
193
+ # Check env_file
194
+ env_files = service.get("env_file", [])
195
+ if isinstance(env_files, str):
196
+ env_files = [env_files]
197
+
198
+ for env_path in env_files:
199
+ # Normalize local path
200
+ if env_path.startswith("./"):
201
+ clean_path = env_path[2:]
202
+ else:
203
+ clean_path = env_path
204
+
205
+ # Only include relative paths that are files
206
+ if not clean_path.startswith("/") and os.path.exists(clean_path):
207
+ if clean_path not in found_paths:
208
+ found_paths.add(clean_path)
209
+ dependencies.append({"src": clean_path, "dest": clean_path})
210
+ elif not os.path.exists(clean_path):
211
+ logger.warning(
212
+ f"⚠️ Referenced env_file not found locally: {clean_path}"
213
+ )
214
+
215
+ # Check volumes (bind mounts)
216
+ volumes = service.get("volumes", [])
217
+ for vol in volumes:
218
+ if isinstance(vol, str):
219
+ parts = vol.split(":")
220
+ if len(parts) >= 2:
221
+ host_path = parts[0]
222
+ # Check if it's a relative path bind mount
223
+ if (
224
+ host_path.startswith("./")
225
+ or host_path.startswith("../")
226
+ ) and os.path.exists(host_path):
227
+ if host_path.startswith("./"):
228
+ clean_path = host_path[2:]
229
+ else:
230
+ clean_path = host_path
231
+
232
+ if clean_path not in found_paths:
233
+ found_paths.add(clean_path)
234
+ dependencies.append(
235
+ {"src": clean_path, "dest": clean_path}
236
+ )
237
+
238
+ except Exception as e:
239
+ logger.warning(f"Failed to parse compose file for dependencies: {e}")
240
+
241
+ return dependencies
242
+
174
243
  def _get_git_commit(self) -> Optional[str]:
175
244
  """Get current git commit hash if in a git repository."""
176
245
  import subprocess
@@ -225,6 +294,65 @@ class SetupRunner:
225
294
  f"An error occurred while running the Ansible playbook: {str(e)}"
226
295
  )
227
296
 
297
+ def hydrate_env_from_secrets(
298
+ self, compose_file: str, secrets_map: dict, project_root: str = "."
299
+ ):
300
+ """
301
+ Scans compose file for env_files. If a file is missing locally,
302
+ checks secrets_map for a matching key (FILENAME_EXT -> FILENAME_EXT output as SNAKE_CASE)
303
+ and creates the file.
304
+ Example: env/backend.env -> checks secrets_map['BACKEND_ENV']
305
+ """
306
+ dependencies = self._get_compose_dependencies(compose_file)
307
+
308
+ # We need to find *potential* dependencies that might not exist yet.
309
+ # _get_compose_dependencies only returns existing ones.
310
+ # So we need to parse again broadly or just rely on what we find.
311
+ # Actually, _get_compose_dependencies skips missing files.
312
+ # So we should parse manually here to find *missing* ones.
313
+
314
+ try:
315
+ with open(compose_file, "r") as f:
316
+ data = yaml.safe_load(f)
317
+
318
+ if not data or "services" not in data:
319
+ return
320
+
321
+ for service in data.get("services", {}).values():
322
+ env_files = service.get("env_file", [])
323
+ if isinstance(env_files, str):
324
+ env_files = [env_files]
325
+
326
+ for env_path in env_files:
327
+ # Resolve path relative to project root
328
+ full_path = os.path.join(project_root, env_path)
329
+
330
+ if not os.path.exists(full_path):
331
+ # Attempt to hydrate
332
+ filename = os.path.basename(env_path)
333
+ # Normalize keys: backend.env -> BACKEND_ENV
334
+ secret_key = filename.replace(".", "_").upper()
335
+
336
+ secret_value = secrets_map.get(secret_key)
337
+ if secret_value:
338
+ logger.info(
339
+ f"💧 Hydrating {env_path} from secret {secret_key}"
340
+ )
341
+ # Ensure directory exists
342
+ os.makedirs(os.path.dirname(full_path), exist_ok=True)
343
+ with open(full_path, "w") as out:
344
+ out.write(secret_value)
345
+ else:
346
+ logger.warning(
347
+ f"⚠️ Missing env file {env_path} and no secret found for {secret_key}"
348
+ )
349
+ else:
350
+ logger.debug(f"✅ Env file exists: {env_path}")
351
+
352
+ except Exception as e:
353
+ logger.error(f"Failed to hydrate env files: {e}")
354
+ raise
355
+
228
356
  def run_setup(self):
229
357
  """Runs the setup process using Ansible."""
230
358
  extravars = {
@@ -402,6 +530,24 @@ class SetupRunner:
402
530
  if deploy_command:
403
531
  extravars["DEPLOY_COMMAND"] = deploy_command
404
532
 
533
+ # Dynamic Dependency Detection
534
+ dependencies = self._get_compose_dependencies(compose_file)
535
+
536
+ # Ensure CLI-provided env_file is included in detection logic
537
+ if env_file:
538
+ # Normalize and check existence
539
+ clean_env_path = env_file[2:] if env_file.startswith("./") else env_file
540
+ if os.path.exists(clean_env_path):
541
+ # Check if already in dependencies
542
+ if not any(d["src"] == clean_env_path for d in dependencies):
543
+ dependencies.append({"src": clean_env_path, "dest": clean_env_path})
544
+
545
+ if dependencies:
546
+ logger.info(
547
+ f"📦 Detected dependencies to copy: {[d['src'] for d in dependencies]}"
548
+ )
549
+ extravars["FILES_TO_COPY"] = dependencies
550
+
405
551
  playbook_path = os.path.join(
406
552
  os.path.dirname(__file__), "vm_setup", "push_code.yml"
407
553
  )
vm_tool/secrets.py CHANGED
@@ -246,6 +246,76 @@ class EncryptedFileBackend(SecretsBackend):
246
246
  return False
247
247
 
248
248
 
249
+ class GitHubSecretsBackend(SecretsBackend):
250
+ """GitHub Actions Secrets backend using 'gh' CLI."""
251
+
252
+ def __init__(self, repo: Optional[str] = None):
253
+ import shutil
254
+
255
+ self.gh_cli = shutil.which("gh")
256
+ if not self.gh_cli:
257
+ raise RuntimeError("GitHub CLI ('gh') is not installed.")
258
+ self.repo = repo
259
+
260
+ def get_secret(self, key: str) -> Optional[str]:
261
+ """
262
+ Get secret from GitHub.
263
+ Note: GitHub Secrets are write-only. We can't retrieve the value.
264
+ """
265
+ logger.warning("Cannot retrieve secret value from GitHub (write-only).")
266
+ return None
267
+
268
+ def set_secret(self, key: str, value: str) -> bool:
269
+ """Set secret in GitHub Actions."""
270
+ import subprocess
271
+
272
+ cmd = ["gh", "secret", "set", key]
273
+ if self.repo:
274
+ cmd.extend(["--repo", self.repo])
275
+
276
+ try:
277
+ # Pass value via stdin
278
+ subprocess.run(cmd, input=value, check=True, text=True, capture_output=True)
279
+ logger.info(f"Secret '{key}' stored in GitHub Secrets")
280
+ return True
281
+ except subprocess.CalledProcessError as e:
282
+ logger.error(f"Failed to set secret in GitHub: {e.stderr.strip()}")
283
+ return False
284
+
285
+ def delete_secret(self, key: str) -> bool:
286
+ """Delete secret from GitHub Actions."""
287
+ import subprocess
288
+
289
+ cmd = ["gh", "secret", "delete", key]
290
+ if self.repo:
291
+ cmd.extend(["--repo", self.repo])
292
+
293
+ try:
294
+ subprocess.run(cmd, check=True, text=True, capture_output=True)
295
+ logger.info(f"Secret '{key}' deleted from GitHub Secrets")
296
+ return True
297
+ except subprocess.CalledProcessError as e:
298
+ logger.error(f"Failed to delete secret from GitHub: {e.stderr.strip()}")
299
+ return False
300
+
301
+ def list_secrets(self) -> list:
302
+ """List all secret keys in GitHub Actions."""
303
+ import subprocess
304
+ import json
305
+
306
+ cmd = ["gh", "secret", "list", "--json", "name"]
307
+ if self.repo:
308
+ cmd.extend(["--repo", self.repo])
309
+
310
+ try:
311
+ result = subprocess.run(cmd, check=True, text=True, capture_output=True)
312
+ secrets = json.loads(result.stdout)
313
+ return [s["name"] for s in secrets]
314
+ except Exception as e:
315
+ logger.error(f"Failed to list secrets from GitHub: {e}")
316
+ return []
317
+
318
+
249
319
  class SecretsManager:
250
320
  """Unified secrets manager supporting multiple backends."""
251
321
 
@@ -283,3 +353,8 @@ class SecretsManager:
283
353
  def from_file(cls, secrets_file: str = ".secrets.enc", **kwargs):
284
354
  """Create secrets manager with encrypted file backend."""
285
355
  return cls(EncryptedFileBackend(secrets_file, **kwargs))
356
+
357
+ @classmethod
358
+ def from_github(cls, repo: Optional[str] = None):
359
+ """Create secrets manager with GitHub backend."""
360
+ return cls(GitHubSecretsBackend(repo))
@@ -31,10 +31,35 @@
31
31
  src: "{{ SOURCE_PATH }}/{{ DOCKER_COMPOSE_FILE_PATH }}"
32
32
  dest: "{{ project_dest_dir }}/{{ DOCKER_COMPOSE_FILE_PATH }}"
33
33
 
34
+ # Handle dynamic dependencies (new method)
35
+ - name: Ensure parent directories for dependencies exist
36
+ file:
37
+ path: "{{ project_dest_dir }}/{{ item.dest | dirname }}"
38
+ state: directory
39
+ mode: '0755'
40
+ loop: "{{ FILES_TO_COPY | default([]) }}"
41
+ when: FILES_TO_COPY is defined and (item.dest | dirname) != ''
42
+
43
+ - name: Copy dependency files
44
+ copy:
45
+ src: "{{ SOURCE_PATH }}/{{ item.src }}"
46
+ dest: "{{ project_dest_dir }}/{{ item.dest }}"
47
+ loop: "{{ FILES_TO_COPY | default([]) }}"
48
+ when: FILES_TO_COPY is defined
49
+
50
+ # Backward compatibility for single env file
51
+ - name: Ensure Env file parent directory exists
52
+ file:
53
+ path: "{{ project_dest_dir }}/{{ ENV_FILE_PATH | default('.env') | dirname }}"
54
+ state: directory
55
+ mode: '0755'
56
+ when: FILES_TO_COPY is not defined and ENV_FILE_PATH is defined and (ENV_FILE_PATH | dirname) != ''
57
+
34
58
  - name: Copy Env file
35
59
  copy:
36
60
  src: "{{ SOURCE_PATH }}/{{ ENV_FILE_PATH | default('.env') }}"
37
61
  dest: "{{ project_dest_dir }}/{{ ENV_FILE_PATH | default('.env') }}"
62
+ when: FILES_TO_COPY is not defined
38
63
  failed_when: false
39
64
 
40
65
  - name: Deploy application
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vm_tool
3
- Version: 1.0.41
3
+ Version: 1.0.43
4
4
  Summary: A Comprehensive Tool for Setting Up Virtual Machines.
5
5
  Home-page: https://github.com/thesunnysinha/vm_tool
6
6
  Author: Sunny Sinha
@@ -24,13 +24,13 @@ vm_tool/alerting.py,sha256=6bNfQNSAQ_SJW9dQfB8s8gDZ-7ocYw4Q211VM6FWiXU,8269
24
24
  vm_tool/audit.py,sha256=spVtMcwadTkD9lELH1tur7Dr3OHjajk44ZatKYEbeVI,3388
25
25
  vm_tool/backup.py,sha256=WhwCXXJDYCQgoexroksNhWgB5ki6HUly0JhQ9ybCdMg,4130
26
26
  vm_tool/benchmarking.py,sha256=9AZ4JwKd5aEjwtKnhsuFa77y6WSO_fSEIqgMh3w_Btw,6212
27
- vm_tool/cli.py,sha256=hGejO4g8lYRaqInDbYeBkvwTJaKRRjXdGxx4UaJVVB8,28828
27
+ vm_tool/cli.py,sha256=tnyBOQIJ-ahT52UAgaisXHnkUewTZifju4q9gqfO4XU,32844
28
28
  vm_tool/cloud.py,sha256=1fQV1o3wCth55B1hSenME_j2n5gCJDo9gtUcqDxCs7s,4047
29
29
  vm_tool/completion.py,sha256=U8W8mmNdR0Ci0WjlEIa8CLOUtaVLZ5tWYpqViBBIBF8,7431
30
30
  vm_tool/compliance.py,sha256=7yXrvRoPxJMW0DTVhJ-ZxbaJo7quDSVgae2_mYbVAmE,3173
31
31
  vm_tool/config.py,sha256=F589SFyxTWmXyvZLWgvvMypJjTO9PeH9sR_g655tgJo,3270
32
32
  vm_tool/drift.py,sha256=Rzw5TItsfqVMqgV8JFbqJzJe-qAb1x1G_2ue8wzNCAs,3301
33
- vm_tool/generator.py,sha256=m_8XgAVVMbCK9g0XXYmSvlnVafAbl3KnvfkDnKxMFxc,14610
33
+ vm_tool/generator.py,sha256=2JDOQ84mEWsnTum1JEtb8NLrE7rcrR_XUe36Pp2tLDU,18317
34
34
  vm_tool/health.py,sha256=CupB1JAJprlarcCmP2WJwtAekNxPCPcUGipZQcRiZg4,6468
35
35
  vm_tool/history.py,sha256=6Nh0-L4rrKTKfunJXvovOzO1uHyuDCQvZIb7PgZaBh8,4207
36
36
  vm_tool/kubernetes.py,sha256=0CABF2Wyc9y56ueFGFduShe7T6GlIOqmg8MGG-Ro2BY,3137
@@ -41,8 +41,8 @@ vm_tool/policy.py,sha256=Z05TLSqMu-tSM0_SnCdsIZqebHGRtXNSRLwkOBDthtY,6072
41
41
  vm_tool/rbac.py,sha256=hdFzh4QhUuQmGmMXBuG-o21FdEp1fb8WSJrCWAz-drI,3999
42
42
  vm_tool/recovery.py,sha256=fesho6Pi_ItRkdtiNK2bh-8Q_T98kSn2LZwKovMFsWI,5473
43
43
  vm_tool/reporting.py,sha256=yiLdArTfu7A8-PWL3dmFSRNpcMvBSq1iHNw6YJRO1hI,7419
44
- vm_tool/runner.py,sha256=JoksxK31e2zVd4t-vHA8_GFrVvgeUngoMU9AFNxoxw4,17666
45
- vm_tool/secrets.py,sha256=bvbunxt4dLupFZQs3VUHkxDOASoBgtpggO0oavTuzOk,9397
44
+ vm_tool/runner.py,sha256=V5ssrj6z1RwCC3GJhlnwxn-CqoFm7NfkzJpUr5U9CS8,24049
45
+ vm_tool/secrets.py,sha256=9P0ENQE7zak1b6BV_3KLFWlPxFnokLew6-Eo04xk1zY,11991
46
46
  vm_tool/ssh.py,sha256=c9Nxs20VEq--qWCLZyc6uRqgny2-OCSwnTlcx9bZJtg,4905
47
47
  vm_tool/state.py,sha256=DxJoRLITwT8mWcs_eAgFNTDfcrGh4PMdQuFjdGb1n6Y,4079
48
48
  vm_tool/validation.py,sha256=DdfCzTsMU9-Br0MnhV8ZjIjYKBjSYLko30U0nXGzF1g,8990
@@ -58,7 +58,7 @@ vm_tool/vm_setup/main.yml,sha256=QUWUFf7W1y9dvfL-sfYIgh491cxRBTm521jI4he24Jg,657
58
58
  vm_tool/vm_setup/monitoring.yml,sha256=u4WhkO2P6O2Mrqo02z8G_FshcFjAbY1Avve5mb5sxZs,1090
59
59
  vm_tool/vm_setup/project_service.yml,sha256=YlvRx9XgW2X1MsKa9XWr7LWK-BET2zFly8Q-7cNedH4,545
60
60
  vm_tool/vm_setup/push_code.yml,sha256=U5zqx04oV2S4U4VsXaecn8S9_UrZGEcTjKbhIZdCgs8,112
61
- vm_tool/vm_setup/push_code_tasks.yml,sha256=dgPgcn3WAxff50GiWB7z2cWpndoqPZWOvij7DZzBxwM,1773
61
+ vm_tool/vm_setup/push_code_tasks.yml,sha256=jG_4MTJAvwslx42Jy4Q4JqECJyUGHzM59hLNVsK9H0c,2663
62
62
  vm_tool/vm_setup/setup.yml,sha256=D3qsBkzWEWEgeAmxO7bk99wxUaFImeSbZNVa3I2BXR8,659
63
63
  vm_tool/vm_setup/setup_project_env.yml,sha256=Q4K6ZgGfgP8pW-QKV3Wkvb0V82AGjHoWB9TM8xq2Nd0,274
64
64
  vm_tool/vm_setup/docker/create_docker_service.yml,sha256=C4nZpFBVzA7_xn0PEMIpYjXcYyA73ClxdMQgTh319eI,2140
@@ -66,9 +66,9 @@ vm_tool/vm_setup/docker/docker_setup.yml,sha256=kg6Abu3TjcLwV0JHnCAVUkj5l2HaGf7O
66
66
  vm_tool/vm_setup/docker/install_docker_and_compose.yml,sha256=NxPm4YkOekRH0dpJQVgxjcSqrmT6f912Dv0owYsLBxE,3402
67
67
  vm_tool/vm_setup/docker/login_to_docker_hub.yml,sha256=y9WaWLb1f1SuHVa1NNm24Gvf5bbwQ8eqEsImGLHGHGU,223
68
68
  vm_tool/vm_setup/github/git_configuration.yml,sha256=hKuzOFsVlYRoBAXujREfrLiqV7j4RnQYbL5s63AEAqk,2355
69
- vm_tool-1.0.41.dist-info/licenses/LICENSE,sha256=4OO6Zd_hYEOemzTNdgUR_E7oNvUJLuN2E7ZAgm7DcSM,1063
70
- vm_tool-1.0.41.dist-info/METADATA,sha256=KA8Bf6CN-LevNXhQQocYBSuyGn7kL8Xc5-6YzmwB9h4,6191
71
- vm_tool-1.0.41.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
72
- vm_tool-1.0.41.dist-info/entry_points.txt,sha256=A2EAvw95ftFXzVAWfHKIM-SsxQVxrIrByRNe-ArOp2k,45
73
- vm_tool-1.0.41.dist-info/top_level.txt,sha256=jTLckJpKvJplpmKoMhPz2YKp_sQB9Q5-cCGmUHCp06M,17
74
- vm_tool-1.0.41.dist-info/RECORD,,
69
+ vm_tool-1.0.43.dist-info/licenses/LICENSE,sha256=4OO6Zd_hYEOemzTNdgUR_E7oNvUJLuN2E7ZAgm7DcSM,1063
70
+ vm_tool-1.0.43.dist-info/METADATA,sha256=1DmAVhK16JHzjPIgDwXMaTq7doMsC-BiuPlxYLcii_E,6191
71
+ vm_tool-1.0.43.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
72
+ vm_tool-1.0.43.dist-info/entry_points.txt,sha256=A2EAvw95ftFXzVAWfHKIM-SsxQVxrIrByRNe-ArOp2k,45
73
+ vm_tool-1.0.43.dist-info/top_level.txt,sha256=jTLckJpKvJplpmKoMhPz2YKp_sQB9Q5-cCGmUHCp06M,17
74
+ vm_tool-1.0.43.dist-info/RECORD,,