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 +117 -1
- vm_tool/generator.py +121 -37
- vm_tool/runner.py +146 -0
- vm_tool/secrets.py +75 -0
- vm_tool/vm_setup/push_code_tasks.yml +25 -0
- {vm_tool-1.0.41.dist-info → vm_tool-1.0.43.dist-info}/METADATA +1 -1
- {vm_tool-1.0.41.dist-info → vm_tool-1.0.43.dist-info}/RECORD +11 -11
- {vm_tool-1.0.41.dist-info → vm_tool-1.0.43.dist-info}/WHEEL +0 -0
- {vm_tool-1.0.41.dist-info → vm_tool-1.0.43.dist-info}/entry_points.txt +0 -0
- {vm_tool-1.0.41.dist-info → vm_tool-1.0.43.dist-info}/licenses/LICENSE +0 -0
- {vm_tool-1.0.41.dist-info → vm_tool-1.0.43.dist-info}/top_level.txt +0 -0
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.
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
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.
|
|
180
|
-
MISSING_SECRETS+=("
|
|
188
|
+
if [ -z "${{ secrets.SSH_HOSTNAME }}" ]; then
|
|
189
|
+
MISSING_SECRETS+=("SSH_HOSTNAME")
|
|
181
190
|
fi
|
|
182
191
|
|
|
183
|
-
if [ -z "${{ secrets.
|
|
184
|
-
MISSING_SECRETS+=("
|
|
192
|
+
if [ -z "${{ secrets.SSH_USERNAME }}" ]; then
|
|
193
|
+
MISSING_SECRETS+=("SSH_USERNAME")
|
|
185
194
|
fi
|
|
186
195
|
|
|
187
|
-
if [ -z "${{ secrets.
|
|
188
|
-
MISSING_SECRETS+=("
|
|
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[*]} " =~ "
|
|
203
|
-
echo "
|
|
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[*]} " =~ "
|
|
207
|
-
echo "
|
|
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[*]} " =~ "
|
|
211
|
-
echo "
|
|
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.
|
|
305
|
+
echo "${{ secrets.SSH_ID_RSA }}" > ~/.ssh/deploy_key
|
|
297
306
|
chmod 600 ~/.ssh/deploy_key
|
|
298
|
-
ssh-keyscan -H ${{ secrets.
|
|
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.
|
|
366
|
-
ansible_user: ${{ secrets.
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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=
|
|
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=
|
|
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=
|
|
45
|
-
vm_tool/secrets.py,sha256=
|
|
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=
|
|
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.
|
|
70
|
-
vm_tool-1.0.
|
|
71
|
-
vm_tool-1.0.
|
|
72
|
-
vm_tool-1.0.
|
|
73
|
-
vm_tool-1.0.
|
|
74
|
-
vm_tool-1.0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|