xenfra 0.4.3__py3-none-any.whl → 0.4.5__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.
- xenfra/blueprints/__init__.py +55 -0
- xenfra/blueprints/base.py +85 -0
- xenfra/blueprints/cache.py +286 -0
- xenfra/blueprints/dry_run.py +251 -0
- xenfra/blueprints/e2b.py +101 -0
- xenfra/blueprints/factory.py +113 -0
- xenfra/blueprints/railpack.py +319 -0
- xenfra/blueprints/validation.py +182 -0
- xenfra/commands/__init__.py +3 -3
- xenfra/commands/auth.py +144 -144
- xenfra/commands/auth_device.py +164 -164
- xenfra/commands/deployments.py +1358 -973
- xenfra/commands/intelligence.py +503 -412
- xenfra/commands/projects.py +204 -204
- xenfra/commands/security_cmd.py +233 -233
- xenfra/main.py +79 -75
- xenfra/utils/__init__.py +3 -3
- xenfra/utils/auth.py +374 -374
- xenfra/utils/codebase.py +169 -169
- xenfra/utils/config.py +459 -436
- xenfra/utils/errors.py +116 -116
- xenfra/utils/file_sync.py +286 -286
- xenfra/utils/security.py +336 -336
- xenfra/utils/validation.py +234 -234
- xenfra-0.4.5.dist-info/METADATA +113 -0
- xenfra-0.4.5.dist-info/RECORD +29 -0
- {xenfra-0.4.3.dist-info → xenfra-0.4.5.dist-info}/WHEEL +1 -1
- xenfra-0.4.3.dist-info/METADATA +0 -118
- xenfra-0.4.3.dist-info/RECORD +0 -21
- {xenfra-0.4.3.dist-info → xenfra-0.4.5.dist-info}/entry_points.txt +0 -0
xenfra/commands/deployments.py
CHANGED
|
@@ -1,973 +1,1358 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Deployment commands for Xenfra CLI.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
from rich.
|
|
10
|
-
from rich.
|
|
11
|
-
from
|
|
12
|
-
from xenfra_sdk
|
|
13
|
-
from xenfra_sdk.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
from ..utils.
|
|
17
|
-
from ..utils.
|
|
18
|
-
from ..utils.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
from
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
console.print(
|
|
54
|
-
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
console.print(
|
|
64
|
-
console.print(
|
|
65
|
-
console.print()
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
#
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
console.print(f"
|
|
126
|
-
elif
|
|
127
|
-
console.print(f"[
|
|
128
|
-
|
|
129
|
-
console.print(f"[
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
elif event_type == "
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
#
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
progress.console.print(f"
|
|
224
|
-
elif
|
|
225
|
-
progress.console.print(f"[
|
|
226
|
-
|
|
227
|
-
progress.console.print(f"[
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
logs
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
#
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
for
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
console.print(
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
# Display
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
"
|
|
726
|
-
|
|
727
|
-
"
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
console.print(
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1
|
+
"""
|
|
2
|
+
Deployment commands for Xenfra CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from xenfra_sdk import XenfraClient
|
|
13
|
+
from xenfra_sdk.exceptions import XenfraAPIError, XenfraError
|
|
14
|
+
from xenfra_sdk.privacy import scrub_logs
|
|
15
|
+
|
|
16
|
+
from ..utils.auth import API_BASE_URL, get_auth_token
|
|
17
|
+
from ..utils.codebase import has_xenfra_config
|
|
18
|
+
from ..utils.config import apply_patch, read_xenfra_yaml
|
|
19
|
+
from ..blueprints import run_dry_run
|
|
20
|
+
from ..utils.validation import (
|
|
21
|
+
validate_branch_name,
|
|
22
|
+
validate_deployment_id,
|
|
23
|
+
validate_framework,
|
|
24
|
+
validate_git_repo_url,
|
|
25
|
+
validate_project_id,
|
|
26
|
+
validate_project_name,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
import time
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
|
|
32
|
+
from rich.live import Live
|
|
33
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
|
34
|
+
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
# Maximum number of retry attempts for auto-healing
|
|
38
|
+
MAX_RETRY_ATTEMPTS = 3
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_client() -> XenfraClient:
|
|
42
|
+
"""Get authenticated SDK client."""
|
|
43
|
+
token = get_auth_token()
|
|
44
|
+
if not token:
|
|
45
|
+
console.print("[bold red]Not logged in. Run 'xenfra auth login' first.[/bold red]")
|
|
46
|
+
raise click.Abort()
|
|
47
|
+
|
|
48
|
+
return XenfraClient(token=token, api_url=API_BASE_URL)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def show_diagnosis_panel(diagnosis: str, suggestion: str):
|
|
52
|
+
"""Display diagnosis and suggestion in formatted panels."""
|
|
53
|
+
console.print()
|
|
54
|
+
console.print(Panel(diagnosis, title="[bold red]🔍 Diagnosis[/bold red]", border_style="red"))
|
|
55
|
+
console.print()
|
|
56
|
+
console.print(
|
|
57
|
+
Panel(suggestion, title="[bold yellow]💡 Suggestion[/bold yellow]", border_style="yellow")
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def show_patch_preview(patch_data: dict):
|
|
62
|
+
"""Show a preview of the patch that will be applied."""
|
|
63
|
+
console.print()
|
|
64
|
+
console.print("[bold green]🔧 Automatic Fix Available[/bold green]")
|
|
65
|
+
console.print(f" [cyan]File:[/cyan] {patch_data.get('file')}")
|
|
66
|
+
console.print(f" [cyan]Operation:[/cyan] {patch_data.get('operation')}")
|
|
67
|
+
console.print(f" [cyan]Value:[/cyan] {patch_data.get('value')}")
|
|
68
|
+
console.print()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _stream_deployment(client: XenfraClient, project_name: str, git_repo: str, branch: str, framework: str, region: str, size_slug: str, is_dockerized: bool = True, port: int = None, command: str = None, entrypoint: str = None, database: str = None, package_manager: str = None, dependency_file: str = None, file_manifest: list = None, cleanup_on_failure: bool = False, services: list = None, mode: str = None):
|
|
72
|
+
"""
|
|
73
|
+
Creates deployment with real-time SSE streaming (no polling needed).
|
|
74
|
+
|
|
75
|
+
Returns tuple of (status, deployment_id, logs_buffer)
|
|
76
|
+
"""
|
|
77
|
+
console.print(Panel(
|
|
78
|
+
f"[bold cyan]Project:[/bold cyan] {project_name}\n"
|
|
79
|
+
f"[bold cyan]Mode:[/bold cyan] Real-time Streaming Deployment",
|
|
80
|
+
title="[bold green]🚀 Deployment Starting[/bold green]",
|
|
81
|
+
border_style="green"
|
|
82
|
+
))
|
|
83
|
+
|
|
84
|
+
deployment_id = None
|
|
85
|
+
logs_buffer = []
|
|
86
|
+
status_val = "PENDING"
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
for event in client.deployments.create_stream(
|
|
90
|
+
project_name=project_name,
|
|
91
|
+
git_repo=git_repo,
|
|
92
|
+
branch=branch,
|
|
93
|
+
framework=framework,
|
|
94
|
+
region=region,
|
|
95
|
+
size_slug=size_slug,
|
|
96
|
+
is_dockerized=is_dockerized,
|
|
97
|
+
port=port,
|
|
98
|
+
command=command,
|
|
99
|
+
entrypoint=entrypoint, # Pass entrypoint to deployment API
|
|
100
|
+
database=database,
|
|
101
|
+
package_manager=package_manager,
|
|
102
|
+
dependency_file=dependency_file,
|
|
103
|
+
file_manifest=file_manifest,
|
|
104
|
+
cleanup_on_failure=cleanup_on_failure,
|
|
105
|
+
services=services, # Microservices support
|
|
106
|
+
mode=mode, # Deployment mode
|
|
107
|
+
):
|
|
108
|
+
|
|
109
|
+
event_type = event.get("event", "message")
|
|
110
|
+
data = event.get("data", "")
|
|
111
|
+
|
|
112
|
+
if event_type == "deployment_created":
|
|
113
|
+
# Extract deployment ID
|
|
114
|
+
if isinstance(data, dict):
|
|
115
|
+
deployment_id = data.get("deployment_id")
|
|
116
|
+
console.print(f"[bold green]✓[/bold green] Deployment created: [cyan]{deployment_id}[/cyan]\n")
|
|
117
|
+
|
|
118
|
+
elif event_type == "log":
|
|
119
|
+
# Real-time log output
|
|
120
|
+
log_line = str(data)
|
|
121
|
+
logs_buffer.append(log_line)
|
|
122
|
+
|
|
123
|
+
# Colorize output
|
|
124
|
+
if any(x in log_line for x in ["ERROR", "FAILED", "✗"]):
|
|
125
|
+
console.print(f"[bold red]{log_line}[/bold red]")
|
|
126
|
+
elif any(x in log_line for x in ["WARN", "WARNING", "⚠"]):
|
|
127
|
+
console.print(f"[yellow]{log_line}[/yellow]")
|
|
128
|
+
elif any(x in log_line for x in ["SUCCESS", "COMPLETED", "✓", "passed!"]):
|
|
129
|
+
console.print(f"[bold green]{log_line}[/bold green]")
|
|
130
|
+
elif "PHASE" in log_line:
|
|
131
|
+
console.print(f"\n[bold blue]{log_line}[/bold blue]")
|
|
132
|
+
elif "[InfraEngine]" in log_line or "[INFO]" in log_line:
|
|
133
|
+
console.print(f"[cyan]›[/cyan] {log_line}")
|
|
134
|
+
else:
|
|
135
|
+
console.print(f"[dim]{log_line}[/dim]")
|
|
136
|
+
|
|
137
|
+
elif event_type == "error":
|
|
138
|
+
error_msg = str(data)
|
|
139
|
+
logs_buffer.append(f"ERROR: {error_msg}")
|
|
140
|
+
console.print(f"\n[bold red]❌ Error: {error_msg}[/bold red]")
|
|
141
|
+
status_val = "FAILED"
|
|
142
|
+
|
|
143
|
+
elif event_type == "deployment_complete":
|
|
144
|
+
# Final status
|
|
145
|
+
if isinstance(data, dict):
|
|
146
|
+
status_val = data.get("status", "UNKNOWN")
|
|
147
|
+
ip_address = data.get("ip_address")
|
|
148
|
+
|
|
149
|
+
console.print()
|
|
150
|
+
if status_val == "SUCCESS":
|
|
151
|
+
console.print("[bold green]✨ SUCCESS: Your application is live![/bold green]")
|
|
152
|
+
if ip_address and ip_address != "unknown":
|
|
153
|
+
console.print(f"[bold]Accessible at:[/bold] [link=http://{ip_address}]http://{ip_address}[/link]")
|
|
154
|
+
elif status_val == "FAILED":
|
|
155
|
+
console.print("[bold red]❌ DEPLOYMENT FAILED[/bold red]")
|
|
156
|
+
error = data.get("error")
|
|
157
|
+
if error:
|
|
158
|
+
console.print(f"[red]Error: {error}[/red]")
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
except Exception as e:
|
|
162
|
+
console.print(f"\n[bold red]❌ Streaming error: {e}[/bold red]")
|
|
163
|
+
status_val = "FAILED"
|
|
164
|
+
logs_buffer.append(f"Streaming error: {e}")
|
|
165
|
+
|
|
166
|
+
return (status_val, deployment_id, "\n".join(logs_buffer))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _follow_deployment(client: XenfraClient, deployment_id: str):
|
|
170
|
+
"""
|
|
171
|
+
Polls logs and status in real-time until completion with CI/CD style output.
|
|
172
|
+
(LEGACY - Used for backward compatibility)
|
|
173
|
+
"""
|
|
174
|
+
console.print(Panel(
|
|
175
|
+
f"[bold cyan]Deployment ID:[/bold cyan] {deployment_id}\n"
|
|
176
|
+
f"[bold cyan]Mode:[/bold cyan] Streaming Real-time Infrastructure Logs",
|
|
177
|
+
title="[bold green]🚀 Deployment Monitor[/bold green]",
|
|
178
|
+
border_style="green"
|
|
179
|
+
))
|
|
180
|
+
|
|
181
|
+
last_log_len = 0
|
|
182
|
+
status_val = "PENDING"
|
|
183
|
+
|
|
184
|
+
# Use a live display for the progress bar at the bottom
|
|
185
|
+
with Progress(
|
|
186
|
+
SpinnerColumn(),
|
|
187
|
+
TextColumn("[bold blue]{task.description}"),
|
|
188
|
+
BarColumn(bar_width=40),
|
|
189
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
190
|
+
console=console,
|
|
191
|
+
transient=False,
|
|
192
|
+
) as progress:
|
|
193
|
+
task = progress.add_task("Waiting for server response...", total=100)
|
|
194
|
+
|
|
195
|
+
while status_val not in ["SUCCESS", "FAILED", "CANCELLED"]:
|
|
196
|
+
try:
|
|
197
|
+
# 1. Update Status
|
|
198
|
+
dep_status = client.deployments.get_status(deployment_id)
|
|
199
|
+
status_val = dep_status.get("status", "PENDING")
|
|
200
|
+
progress_val = dep_status.get("progress", 0)
|
|
201
|
+
state = dep_status.get("state", "preparing")
|
|
202
|
+
|
|
203
|
+
# Use a more descriptive description for the progress task
|
|
204
|
+
desc = f"Phase: {state}"
|
|
205
|
+
if status_val == "FAILED":
|
|
206
|
+
desc = "[bold red]FAILED[/bold red]"
|
|
207
|
+
elif status_val == "SUCCESS":
|
|
208
|
+
desc = "[bold green]SUCCESS[/bold green]"
|
|
209
|
+
|
|
210
|
+
progress.update(task, completed=progress_val, description=desc)
|
|
211
|
+
|
|
212
|
+
# 2. Update Logs
|
|
213
|
+
log_content = client.deployments.get_logs(deployment_id)
|
|
214
|
+
if log_content and len(log_content) > last_log_len:
|
|
215
|
+
new_logs = log_content[last_log_len:].strip()
|
|
216
|
+
for line in new_logs.split("\n"):
|
|
217
|
+
# Process and colorize lines
|
|
218
|
+
clean_line = line.strip()
|
|
219
|
+
if not clean_line:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
if any(x in clean_line for x in ["ERROR", "FAILED", "✗"]):
|
|
223
|
+
progress.console.print(f"[bold red]{clean_line}[/bold red]")
|
|
224
|
+
elif any(x in clean_line for x in ["WARN", "WARNING", "⚠"]):
|
|
225
|
+
progress.console.print(f"[yellow]{clean_line}[/yellow]")
|
|
226
|
+
elif any(x in clean_line for x in ["SUCCESS", "COMPLETED", "✓", "passed!"]):
|
|
227
|
+
progress.console.print(f"[bold green]{clean_line}[/bold green]")
|
|
228
|
+
elif "PHASE" in clean_line:
|
|
229
|
+
progress.console.print(f"\n[bold blue]{clean_line}[/bold blue]")
|
|
230
|
+
elif "[InfraEngine]" in clean_line:
|
|
231
|
+
progress.console.print(f"[dim]{clean_line}[/dim]")
|
|
232
|
+
else:
|
|
233
|
+
progress.console.print(f"[cyan]›[/cyan] {clean_line}")
|
|
234
|
+
|
|
235
|
+
last_log_len = len(log_content)
|
|
236
|
+
|
|
237
|
+
if status_val in ["SUCCESS", "FAILED", "CANCELLED"]:
|
|
238
|
+
break
|
|
239
|
+
|
|
240
|
+
time.sleep(1.5) # Slightly faster polling for better feel
|
|
241
|
+
except Exception as e:
|
|
242
|
+
# progress.console.print(f"[dim]Transient connection issue: {e}[/dim]")
|
|
243
|
+
time.sleep(3)
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
console.print()
|
|
247
|
+
if status_val == "SUCCESS":
|
|
248
|
+
console.print("[bold green]✨ SUCCESS: Your application is live![/bold green]")
|
|
249
|
+
# Try to get the IP address
|
|
250
|
+
try:
|
|
251
|
+
final_status = client.deployments.get_status(deployment_id)
|
|
252
|
+
ip = final_status.get("ip_address")
|
|
253
|
+
if ip:
|
|
254
|
+
console.print(f"[bold]Accessible at:[/bold] [link=http://{ip}]http://{ip}[/link]")
|
|
255
|
+
except:
|
|
256
|
+
pass
|
|
257
|
+
elif status_val == "FAILED":
|
|
258
|
+
console.print("\n[bold red]❌ FAILURE DETECTED: Entering AI Diagnosis Mode...[/bold red]")
|
|
259
|
+
|
|
260
|
+
return status_val
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def zen_nod_workflow(
|
|
264
|
+
logs: str,
|
|
265
|
+
client: XenfraClient,
|
|
266
|
+
attempt: int,
|
|
267
|
+
package_manager: str = None,
|
|
268
|
+
dependency_file: str = None,
|
|
269
|
+
services: list = None
|
|
270
|
+
) -> bool:
|
|
271
|
+
"""
|
|
272
|
+
Execute the Zen Nod auto-healing workflow.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
logs: Deployment error logs
|
|
276
|
+
client: Authenticated SDK client
|
|
277
|
+
attempt: Current attempt number
|
|
278
|
+
package_manager: Project package manager
|
|
279
|
+
dependency_file: Project dependency file
|
|
280
|
+
services: List of services in the project (for multi-service context)
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
True if patch was applied and user wants to retry, False otherwise
|
|
284
|
+
"""
|
|
285
|
+
console.print()
|
|
286
|
+
console.print(f"[cyan]🤖 Analyzing failure (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]")
|
|
287
|
+
|
|
288
|
+
# Slice logs to last 300 lines for focused diagnosis (Fix #26)
|
|
289
|
+
log_lines = logs.split("\n")
|
|
290
|
+
if len(log_lines) > 300:
|
|
291
|
+
logs = "\n".join(log_lines[-300:])
|
|
292
|
+
console.print("[dim]Note: Analyzing only the last 300 lines of logs for efficiency.[/dim]")
|
|
293
|
+
|
|
294
|
+
# Scrub sensitive data from logs
|
|
295
|
+
scrubbed_logs = scrub_logs(logs)
|
|
296
|
+
|
|
297
|
+
# Diagnose with AI
|
|
298
|
+
try:
|
|
299
|
+
diagnosis_result = client.intelligence.diagnose(
|
|
300
|
+
logs=scrubbed_logs,
|
|
301
|
+
package_manager=package_manager,
|
|
302
|
+
dependency_file=dependency_file,
|
|
303
|
+
services=services
|
|
304
|
+
)
|
|
305
|
+
except Exception as e:
|
|
306
|
+
console.print(f"[yellow]Could not diagnose failure: {e}[/yellow]")
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
# Show diagnosis
|
|
310
|
+
show_diagnosis_panel(diagnosis_result.diagnosis, diagnosis_result.suggestion)
|
|
311
|
+
|
|
312
|
+
# Check if there's an automatic patch
|
|
313
|
+
if diagnosis_result.patch and diagnosis_result.patch.file:
|
|
314
|
+
show_patch_preview(diagnosis_result.patch.model_dump())
|
|
315
|
+
|
|
316
|
+
# Zen Nod confirmation
|
|
317
|
+
if click.confirm("Apply this fix and retry deployment?", default=True):
|
|
318
|
+
try:
|
|
319
|
+
# Apply patch (with automatic backup)
|
|
320
|
+
backup_path = apply_patch(diagnosis_result.patch.model_dump())
|
|
321
|
+
console.print("[bold green]✓ Patch applied[/bold green]")
|
|
322
|
+
if backup_path:
|
|
323
|
+
console.print(f"[dim]Backup saved: {backup_path}[/dim]")
|
|
324
|
+
return True # Signal to retry
|
|
325
|
+
except Exception as e:
|
|
326
|
+
console.print(f"[bold red]Failed to apply patch: {e}[/bold red]")
|
|
327
|
+
return False
|
|
328
|
+
else:
|
|
329
|
+
console.print()
|
|
330
|
+
console.print("[yellow]❌ Patch declined. Follow the manual steps above.[/yellow]")
|
|
331
|
+
return False
|
|
332
|
+
else:
|
|
333
|
+
console.print()
|
|
334
|
+
console.print(
|
|
335
|
+
"[yellow]No automatic fix available. Please follow the manual steps above.[/yellow]"
|
|
336
|
+
)
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@click.command()
|
|
341
|
+
@click.argument("project_id", type=int)
|
|
342
|
+
@click.option("--show-details", is_flag=True, help="Show project details before confirmation")
|
|
343
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this project and its infrastructure?")
|
|
344
|
+
def delete(project_id, show_details):
|
|
345
|
+
"""
|
|
346
|
+
Delete a project and its infrastructure.
|
|
347
|
+
|
|
348
|
+
This command will:
|
|
349
|
+
- Destroy the DigitalOcean droplet
|
|
350
|
+
- Remove database records
|
|
351
|
+
|
|
352
|
+
WARNING: This action cannot be undone.
|
|
353
|
+
"""
|
|
354
|
+
# Validate
|
|
355
|
+
is_valid, error_msg = validate_project_id(project_id)
|
|
356
|
+
if not is_valid:
|
|
357
|
+
console.print(f"[bold red]Invalid project ID: {error_msg}[/bold red]")
|
|
358
|
+
raise click.Abort()
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
with get_client() as client:
|
|
362
|
+
# Optional: Show details
|
|
363
|
+
if show_details:
|
|
364
|
+
try:
|
|
365
|
+
project = client.projects.show(project_id)
|
|
366
|
+
|
|
367
|
+
# Display panel with project info
|
|
368
|
+
details_table = Table(show_header=False, box=None)
|
|
369
|
+
details_table.add_column("Property", style="cyan")
|
|
370
|
+
details_table.add_column("Value")
|
|
371
|
+
|
|
372
|
+
details_table.add_row("Project ID", str(project_id))
|
|
373
|
+
if hasattr(project, 'name'):
|
|
374
|
+
details_table.add_row("Name", project.name)
|
|
375
|
+
if hasattr(project, 'droplet_id'):
|
|
376
|
+
details_table.add_row("Droplet ID", str(project.droplet_id))
|
|
377
|
+
if hasattr(project, 'ip_address'):
|
|
378
|
+
details_table.add_row("IP Address", project.ip_address)
|
|
379
|
+
if hasattr(project, 'created_at'):
|
|
380
|
+
details_table.add_row("Created", str(project.created_at))
|
|
381
|
+
|
|
382
|
+
panel = Panel(details_table, title="[bold]Project Details[/bold]", border_style="yellow")
|
|
383
|
+
console.print(panel)
|
|
384
|
+
console.print()
|
|
385
|
+
except XenfraAPIError as e:
|
|
386
|
+
if e.status_code == 404:
|
|
387
|
+
console.print(f"[yellow]Note: Project {project_id} not found in records.[/yellow]")
|
|
388
|
+
else:
|
|
389
|
+
console.print(f"[yellow]Warning: Could not fetch project details: {e.detail}[/yellow]")
|
|
390
|
+
console.print()
|
|
391
|
+
|
|
392
|
+
# Delete
|
|
393
|
+
console.print(f"[cyan]Deleting project {project_id}...[/cyan]")
|
|
394
|
+
client.projects.delete(str(project_id))
|
|
395
|
+
console.print(f"[bold green]✓ Project {project_id} deleted successfully.[/bold green]")
|
|
396
|
+
console.print("[dim]The droplet has been destroyed and all records removed.[/dim]")
|
|
397
|
+
|
|
398
|
+
except XenfraAPIError as e:
|
|
399
|
+
if e.status_code == 404:
|
|
400
|
+
console.print(f"[yellow]Project {project_id} not found. It may have already been deleted.[/yellow]")
|
|
401
|
+
else:
|
|
402
|
+
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
403
|
+
raise click.Abort()
|
|
404
|
+
except XenfraError as e:
|
|
405
|
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
406
|
+
raise click.Abort()
|
|
407
|
+
except click.Abort:
|
|
408
|
+
console.print("[dim]Deletion cancelled.[/dim]")
|
|
409
|
+
raise
|
|
410
|
+
except Exception as e:
|
|
411
|
+
console.print(f"[bold red]Unexpected error: {e}[/bold red]")
|
|
412
|
+
raise click.Abort()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@click.command()
|
|
416
|
+
@click.option("--project-name", help="Project name (defaults to current directory name)")
|
|
417
|
+
@click.option("--git-repo", help="Git repository URL (if deploying from git)")
|
|
418
|
+
@click.option("--branch", default="main", help="Git branch (default: main)")
|
|
419
|
+
@click.option("--framework", help="Framework override (fastapi, flask, django)")
|
|
420
|
+
@click.option("--region", help="DigitalOcean region override")
|
|
421
|
+
@click.option("--size", help="DigitalOcean size slug override")
|
|
422
|
+
@click.option("--no-heal", is_flag=True, help="Disable auto-healing on failure")
|
|
423
|
+
@click.option("--cleanup-on-failure", is_flag=True, help="Automatically cleanup resources if deployment fails")
|
|
424
|
+
@click.option("--force-sandbox", is_flag=True, help="Force full sandbox validation (Tier 3)")
|
|
425
|
+
@click.option("--skip-sandbox", is_flag=True, help="Skip sandbox validation for faster iteration")
|
|
426
|
+
def deploy(project_name, git_repo, branch, framework, region, size, no_heal, cleanup_on_failure, force_sandbox, skip_sandbox):
|
|
427
|
+
"""
|
|
428
|
+
Deploy current project to DigitalOcean with 3-tier dry-run validation.
|
|
429
|
+
|
|
430
|
+
Every deployment is validated before touching real infrastructure:
|
|
431
|
+
|
|
432
|
+
\b
|
|
433
|
+
[bold]Tier 1[/bold]: Local validation (~100ms) - Syntax, imports, config
|
|
434
|
+
[bold]Tier 2[/bold]: Railpack plan (~1-2s) - Build plan generation
|
|
435
|
+
[bold]Tier 3[/bold]: E2B Sandbox (~30-60s) - Full build validation (smart detection)
|
|
436
|
+
|
|
437
|
+
This "Dry Run First" architecture ensures deployments never fail due to
|
|
438
|
+
avoidable errors. Set E2B_API_KEY for full sandbox validation.
|
|
439
|
+
|
|
440
|
+
\b
|
|
441
|
+
Deploys your application with zero configuration. The CLI will:
|
|
442
|
+
1. Run 3-tier dry-run validation
|
|
443
|
+
2. Create a deployment
|
|
444
|
+
3. Auto-diagnose and fix failures (unless --no-heal is set)
|
|
445
|
+
|
|
446
|
+
Environment Variables:
|
|
447
|
+
- E2B_API_KEY: API key for sandbox validation
|
|
448
|
+
- XENFRA_AI=off: Disable AI features
|
|
449
|
+
- XENFRA_SANDBOX=force: Always run Tier 3
|
|
450
|
+
- XENFRA_SANDBOX=skip: Skip Tier 3 validation
|
|
451
|
+
- XENFRA_SANDBOX=auto: Smart detection (default)
|
|
452
|
+
"""
|
|
453
|
+
# Check XENFRA_AI environment variable
|
|
454
|
+
ai_mode = os.environ.get("XENFRA_AI", "on").lower()
|
|
455
|
+
no_ai = ai_mode == "off"
|
|
456
|
+
if no_ai:
|
|
457
|
+
console.print("[yellow]XENFRA_AI=off is set. Auto-healing disabled.[/yellow]")
|
|
458
|
+
no_heal = True
|
|
459
|
+
|
|
460
|
+
# ==========================================
|
|
461
|
+
# 3-TIER DRY-RUN VALIDATION ("Dry Run First")
|
|
462
|
+
# ==========================================
|
|
463
|
+
project_path = Path(os.getcwd())
|
|
464
|
+
|
|
465
|
+
# Check for xenfra.yaml
|
|
466
|
+
if not has_xenfra_config():
|
|
467
|
+
console.print("[yellow]No xenfra.yaml found.[/yellow]")
|
|
468
|
+
if click.confirm("Run 'xenfra init' to create configuration?", default=True):
|
|
469
|
+
from .intelligence import init
|
|
470
|
+
|
|
471
|
+
ctx = click.get_current_context()
|
|
472
|
+
ctx.invoke(init, manual=no_ai, accept_all=False)
|
|
473
|
+
else:
|
|
474
|
+
console.print("[dim]Deployment cancelled.[/dim]")
|
|
475
|
+
return
|
|
476
|
+
|
|
477
|
+
# Load configuration from xenfra.yaml if it exists
|
|
478
|
+
config = {}
|
|
479
|
+
if has_xenfra_config():
|
|
480
|
+
try:
|
|
481
|
+
config = read_xenfra_yaml()
|
|
482
|
+
except Exception as e:
|
|
483
|
+
console.print(f"[yellow]Warning: Could not read xenfra.yaml: {e}[/dim]")
|
|
484
|
+
|
|
485
|
+
# Resolve values with precedence: 1. CLI Flag, 2. xenfra.yaml, 3. Default
|
|
486
|
+
project_name = project_name or config.get("name") or os.path.basename(os.getcwd())
|
|
487
|
+
framework = framework or config.get("framework")
|
|
488
|
+
# Track if is_dockerized was explicitly set in config (to avoid AI override)
|
|
489
|
+
is_dockerized_from_config = "is_dockerized" in config
|
|
490
|
+
is_dockerized = config.get("is_dockerized", True)
|
|
491
|
+
region = region or config.get("region") or "nyc3"
|
|
492
|
+
|
|
493
|
+
# Resolve size slug (complex mapping)
|
|
494
|
+
if not size:
|
|
495
|
+
if config.get("size"):
|
|
496
|
+
size = config.get("size")
|
|
497
|
+
else:
|
|
498
|
+
instance_size = config.get("instance_size", "basic")
|
|
499
|
+
resources = config.get("resources", {})
|
|
500
|
+
cpu = resources.get("cpu", 1)
|
|
501
|
+
|
|
502
|
+
if instance_size == "standard" or cpu >= 2:
|
|
503
|
+
size = "s-2vcpu-4gb"
|
|
504
|
+
elif instance_size == "premium" or cpu >= 4:
|
|
505
|
+
size = "s-4vcpu-8gb"
|
|
506
|
+
else:
|
|
507
|
+
size = "s-1vcpu-1gb"
|
|
508
|
+
|
|
509
|
+
# Extract port, command, database from config
|
|
510
|
+
# Track if port was explicitly set to avoid AI override
|
|
511
|
+
port_from_config = config.get("port")
|
|
512
|
+
port = port_from_config or 8000
|
|
513
|
+
command = config.get("command") # Auto-detected if not provided
|
|
514
|
+
entrypoint = config.get("entrypoint") # e.g., "todo.main:app"
|
|
515
|
+
database_config = config.get("database", {})
|
|
516
|
+
database = database_config.get("type") if isinstance(database_config, dict) else None
|
|
517
|
+
package_manager = config.get("package_manager", "pip")
|
|
518
|
+
dependency_file = config.get("dependency_file", "requirements.txt")
|
|
519
|
+
|
|
520
|
+
# Microservices support: extract services and mode from xenfra.yaml
|
|
521
|
+
services = config.get("services") # List of service definitions
|
|
522
|
+
mode = config.get("mode", "monolithic") # monolithic, single-droplet, multi-droplet
|
|
523
|
+
|
|
524
|
+
# If services are defined and > 1, this is a microservices deployment
|
|
525
|
+
if services and len(services) > 1:
|
|
526
|
+
console.print(f"\n[bold cyan]🔍 Detected microservices project ({len(services)} services)[/bold cyan]")
|
|
527
|
+
|
|
528
|
+
# Display services table
|
|
529
|
+
from rich.table import Table
|
|
530
|
+
svc_table = Table(show_header=True, header_style="bold cyan", box=None)
|
|
531
|
+
svc_table.add_column("Service", style="white")
|
|
532
|
+
svc_table.add_column("Port", style="green")
|
|
533
|
+
svc_table.add_column("Framework", style="yellow")
|
|
534
|
+
|
|
535
|
+
for svc in services:
|
|
536
|
+
svc_table.add_row(
|
|
537
|
+
svc.get("name", "?"),
|
|
538
|
+
str(svc.get("port", "?")),
|
|
539
|
+
svc.get("framework", "?")
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
console.print(svc_table)
|
|
543
|
+
console.print()
|
|
544
|
+
|
|
545
|
+
# If mode not explicitly set in config, ask user
|
|
546
|
+
if mode == "monolithic" or mode not in ["single-droplet", "multi-droplet"]:
|
|
547
|
+
console.print("[bold]Choose deployment mode:[/bold]")
|
|
548
|
+
console.print(" [cyan]1.[/cyan] Single Droplet - All services on one machine [dim](cost-effective)[/dim]")
|
|
549
|
+
console.print(" [cyan]2.[/cyan] Multi Droplet - Each service on its own machine [dim](scalable)[/dim]")
|
|
550
|
+
console.print()
|
|
551
|
+
|
|
552
|
+
mode_choice = Prompt.ask(
|
|
553
|
+
"Deployment mode",
|
|
554
|
+
choices=["1", "2"],
|
|
555
|
+
default="1"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
if mode_choice == "1":
|
|
559
|
+
mode = "single-droplet"
|
|
560
|
+
console.print("[green]✓ Using single-droplet mode[/green]\n")
|
|
561
|
+
else:
|
|
562
|
+
mode = "multi-droplet"
|
|
563
|
+
console.print("[green]✓ Using multi-droplet mode[/green]")
|
|
564
|
+
console.print(f"[dim]This will create {len(services)} separate droplets[/dim]\n")
|
|
565
|
+
else:
|
|
566
|
+
console.print(f"[dim]Using configured mode: {mode}[/dim]\n")
|
|
567
|
+
|
|
568
|
+
# Default project name to current directory
|
|
569
|
+
if not project_name:
|
|
570
|
+
project_name = os.path.basename(os.getcwd())
|
|
571
|
+
|
|
572
|
+
# Validate project name
|
|
573
|
+
is_valid, error_msg = validate_project_name(project_name)
|
|
574
|
+
if not is_valid:
|
|
575
|
+
console.print(f"[bold red]Invalid project name: {error_msg}[/bold red]")
|
|
576
|
+
raise click.Abort()
|
|
577
|
+
|
|
578
|
+
# Validate git repo if provided
|
|
579
|
+
if git_repo:
|
|
580
|
+
is_valid, error_msg = validate_git_repo_url(git_repo)
|
|
581
|
+
if not is_valid:
|
|
582
|
+
console.print(f"[bold red]Invalid git repository URL: {error_msg}[/bold red]")
|
|
583
|
+
raise click.Abort()
|
|
584
|
+
console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
|
|
585
|
+
console.print(f"[dim]Repository: {git_repo} (branch: {branch})[/dim]")
|
|
586
|
+
else:
|
|
587
|
+
# Note: Local folder deployment only works when engine runs locally
|
|
588
|
+
# In cloud API mode, this will fail with a clear error from the server
|
|
589
|
+
console.print(f"[cyan]Deploying {project_name}...[/cyan]")
|
|
590
|
+
console.print("[dim]Note: Git repository recommended for cloud deployments[/dim]")
|
|
591
|
+
|
|
592
|
+
# Validate branch name
|
|
593
|
+
is_valid, error_msg = validate_branch_name(branch)
|
|
594
|
+
if not is_valid:
|
|
595
|
+
console.print(f"[bold red]Invalid branch name: {error_msg}[/bold red]")
|
|
596
|
+
raise click.Abort()
|
|
597
|
+
|
|
598
|
+
# Validate framework if provided
|
|
599
|
+
if framework:
|
|
600
|
+
is_valid, error_msg = validate_framework(framework)
|
|
601
|
+
if not is_valid:
|
|
602
|
+
console.print(f"[bold red]Invalid framework: {error_msg}[/bold red]")
|
|
603
|
+
raise click.Abort()
|
|
604
|
+
|
|
605
|
+
# Retry loop for auto-healing
|
|
606
|
+
attempt = 0
|
|
607
|
+
deployment_id = None
|
|
608
|
+
|
|
609
|
+
# Context manager for temp dir (if cloning)
|
|
610
|
+
import tempfile
|
|
611
|
+
import shutil
|
|
612
|
+
import subprocess
|
|
613
|
+
|
|
614
|
+
temp_dir_obj = None
|
|
615
|
+
original_project_path = project_path
|
|
616
|
+
|
|
617
|
+
try:
|
|
618
|
+
with get_client() as client:
|
|
619
|
+
from ..utils.file_sync import scan_project_files_cached, ensure_gitignore_ignored
|
|
620
|
+
|
|
621
|
+
# --- 1. PREPARE SOURCE CODE ---
|
|
622
|
+
if git_repo:
|
|
623
|
+
console.print(f"[cyan]📥 Cloning {git_repo}...[/cyan]")
|
|
624
|
+
temp_dir_obj = tempfile.TemporaryDirectory()
|
|
625
|
+
temp_dir_path = Path(temp_dir_obj.name)
|
|
626
|
+
|
|
627
|
+
try:
|
|
628
|
+
# Shallow clone for speed
|
|
629
|
+
cmd = ["git", "clone", "--depth", "1"]
|
|
630
|
+
if branch:
|
|
631
|
+
cmd.extend(["-b", branch])
|
|
632
|
+
cmd.extend([git_repo, str(temp_dir_path)])
|
|
633
|
+
|
|
634
|
+
subprocess.run(cmd, check=True, capture_output=True)
|
|
635
|
+
project_path = temp_dir_path
|
|
636
|
+
console.print("[green]✓ Repository cloned[/green]")
|
|
637
|
+
except subprocess.CalledProcessError as e:
|
|
638
|
+
console.print(f"[bold red]Failed to clone repository: {e}[/bold red]")
|
|
639
|
+
raise click.Abort()
|
|
640
|
+
else:
|
|
641
|
+
# Local directory
|
|
642
|
+
# Protect privacy: ensure .xenfra is in .gitignore
|
|
643
|
+
if ensure_gitignore_ignored():
|
|
644
|
+
console.print("[dim] - Added .xenfra to .gitignore for privacy[/dim]")
|
|
645
|
+
|
|
646
|
+
# --- 2. SCAN FILES ---
|
|
647
|
+
console.print("[cyan]📁 Scanning project files...[/cyan]")
|
|
648
|
+
|
|
649
|
+
# We need to temporarily change CWD for scan_project_files_cached to work if we cloned
|
|
650
|
+
# OR pass path to it. Assuming it scans CWD, we might need chdir
|
|
651
|
+
import os
|
|
652
|
+
cwd = os.getcwd()
|
|
653
|
+
try:
|
|
654
|
+
if project_path != Path(cwd):
|
|
655
|
+
os.chdir(project_path)
|
|
656
|
+
file_manifest = scan_project_files_cached()
|
|
657
|
+
finally:
|
|
658
|
+
os.chdir(cwd)
|
|
659
|
+
|
|
660
|
+
console.print(f"[dim]Found {len(file_manifest)} files[/dim]")
|
|
661
|
+
|
|
662
|
+
if not file_manifest:
|
|
663
|
+
console.print("[bold red]Error: No files found.[/bold red]")
|
|
664
|
+
raise click.Abort()
|
|
665
|
+
|
|
666
|
+
# --- 3. UPLOAD FILES (Required for Tier 3 & Deployment) ---
|
|
667
|
+
# Check which files need uploading
|
|
668
|
+
console.print("[cyan]🔍 Checking file cache...[/cyan]")
|
|
669
|
+
check_result = client.files.check(file_manifest)
|
|
670
|
+
missing = check_result.get('missing', [])
|
|
671
|
+
cached = check_result.get('cached', 0)
|
|
672
|
+
|
|
673
|
+
if cached > 0:
|
|
674
|
+
console.print(f"[green]✓ {cached} files already cached on server[/green]")
|
|
675
|
+
|
|
676
|
+
# Upload missing files
|
|
677
|
+
if missing:
|
|
678
|
+
console.print(f"[cyan]☁️ Uploading {len(missing)} files...[/cyan]")
|
|
679
|
+
# We need absolute paths for upload reading
|
|
680
|
+
# If we cloned, file_manifest paths are relative to temp_dir
|
|
681
|
+
# But upload_files needs to know where to read from.
|
|
682
|
+
# Let's patch file_manifest with abs_paths for the uploader
|
|
683
|
+
|
|
684
|
+
upload_manifest = []
|
|
685
|
+
for f in file_manifest:
|
|
686
|
+
f_copy = f.copy()
|
|
687
|
+
f_copy["abs_path"] = str(project_path / f["path"])
|
|
688
|
+
upload_manifest.append(f_copy)
|
|
689
|
+
|
|
690
|
+
uploaded = client.files.upload_files(
|
|
691
|
+
upload_manifest,
|
|
692
|
+
missing,
|
|
693
|
+
progress_callback=lambda done, total: console.print(f"[dim] Progress: {done}/{total}[/dim]") if done % 10 == 0 or done == total else None
|
|
694
|
+
)
|
|
695
|
+
console.print(f"[green]✓ Uploaded {uploaded} files[/green]")
|
|
696
|
+
else:
|
|
697
|
+
console.print("[green]✓ All files synced[/green]")
|
|
698
|
+
|
|
699
|
+
# --- 4. RUN DRY RUN ---
|
|
700
|
+
console.print(Panel(
|
|
701
|
+
"[bold]🔍 Running 3-Tier Dry-Run Validation[/bold]\n"
|
|
702
|
+
"Tier 1: Local syntax check (~100ms)\n"
|
|
703
|
+
"Tier 2: Railpack build plan (~1-2s)\n"
|
|
704
|
+
"Tier 3: E2B sandbox via Xenfra API (smart detection)",
|
|
705
|
+
title="[bold cyan]Dry Run First Architecture[/bold cyan]",
|
|
706
|
+
border_style="cyan"
|
|
707
|
+
))
|
|
708
|
+
|
|
709
|
+
with console.status("[cyan]Running validation...[/cyan]"):
|
|
710
|
+
dry_run_result = run_dry_run(
|
|
711
|
+
project_path,
|
|
712
|
+
force_sandbox=force_sandbox, # or (force_sandbox or check_result['missing'])?
|
|
713
|
+
skip_sandbox=skip_sandbox,
|
|
714
|
+
api_client=client,
|
|
715
|
+
file_manifest=file_manifest
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
# Display results
|
|
719
|
+
console.print(dry_run_result.get_summary())
|
|
720
|
+
console.print()
|
|
721
|
+
|
|
722
|
+
# Check if dry-run failed
|
|
723
|
+
if not dry_run_result.success:
|
|
724
|
+
console.print("[bold red]❌ Dry-run validation failed[/bold red]")
|
|
725
|
+
console.print("[yellow]Fix the errors above before deploying.[/yellow]")
|
|
726
|
+
console.print()
|
|
727
|
+
console.print("[dim]Tip: Run with --skip-sandbox to skip Tier 3[/dim]")
|
|
728
|
+
raise click.Abort()
|
|
729
|
+
|
|
730
|
+
console.print("[bold green]✅ Dry-run validation passed[/bold green]")
|
|
731
|
+
console.print()
|
|
732
|
+
|
|
733
|
+
# Prepare manifest for API (remove local-only fields if any, strictly path/sha/size)
|
|
734
|
+
api_file_manifest = [{"path": f["path"], "sha": f["sha"], "size": f["size"]} for f in file_manifest]
|
|
735
|
+
|
|
736
|
+
while attempt < MAX_RETRY_ATTEMPTS:
|
|
737
|
+
attempt += 1
|
|
738
|
+
|
|
739
|
+
if attempt > 1:
|
|
740
|
+
console.print(
|
|
741
|
+
f"\n[cyan]🔄 Retrying deployment (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]"
|
|
742
|
+
)
|
|
743
|
+
else:
|
|
744
|
+
console.print("[cyan]Creating deployment...[/cyan]")
|
|
745
|
+
|
|
746
|
+
# Detect framework if not provided (AI-powered Zen Mode)
|
|
747
|
+
if not framework:
|
|
748
|
+
if no_ai:
|
|
749
|
+
# ... static logic existing ...
|
|
750
|
+
console.print("[yellow]⚠ AI disabled. Using static detection...[/yellow]")
|
|
751
|
+
# Simple static checks based on Tier 2 result potentially?
|
|
752
|
+
if dry_run_result.tier2:
|
|
753
|
+
framework = dry_run_result.tier2.framework
|
|
754
|
+
port = dry_run_result.tier2.port
|
|
755
|
+
is_dockerized = True
|
|
756
|
+
console.print(f"[green]✓ Detected {framework.upper()} (from Dry Run)[/green]")
|
|
757
|
+
else:
|
|
758
|
+
framework = "fastapi"
|
|
759
|
+
port = 8000
|
|
760
|
+
else:
|
|
761
|
+
# AI-powered detection
|
|
762
|
+
# We can use Tier 2 result for speed instead of full AI scan if available
|
|
763
|
+
if dry_run_result.tier2 and dry_run_result.tier2.framework != "unknown":
|
|
764
|
+
framework = dry_run_result.tier2.framework
|
|
765
|
+
# config overrides...
|
|
766
|
+
if not is_dockerized_from_config:
|
|
767
|
+
is_dockerized = True # Railpack is docker based
|
|
768
|
+
if not port_from_config:
|
|
769
|
+
port = dry_run_result.tier2.port
|
|
770
|
+
console.print(f"[green]✓ Detected {framework.upper()} (from Railpack)[/green]")
|
|
771
|
+
else:
|
|
772
|
+
# Fallback to AI
|
|
773
|
+
console.print("[cyan]🔍 AI Auto-detecting project type...[/cyan]")
|
|
774
|
+
try:
|
|
775
|
+
# We need to scan code snippet from PROJECT_PATH (temp or local)
|
|
776
|
+
from ..utils.codebase import scan_codebase
|
|
777
|
+
# scan_codebase needs CWD to be correct
|
|
778
|
+
curr = os.getcwd()
|
|
779
|
+
os.chdir(project_path)
|
|
780
|
+
try:
|
|
781
|
+
code_snippets = scan_codebase()
|
|
782
|
+
finally:
|
|
783
|
+
os.chdir(curr)
|
|
784
|
+
|
|
785
|
+
if code_snippets:
|
|
786
|
+
analysis = client.intelligence.analyze_codebase(code_snippets)
|
|
787
|
+
framework = analysis.framework
|
|
788
|
+
if not is_dockerized_from_config: is_dockerized = analysis.is_dockerized
|
|
789
|
+
if not port_from_config and analysis.port: port = analysis.port
|
|
790
|
+
if not size and analysis.instance_size: size = "s-1vcpu-1gb" if analysis.instance_size == "basic" else "s-2vcpu-4gb"
|
|
791
|
+
mode_str = "Docker" if is_dockerized else "Bare Metal"
|
|
792
|
+
console.print(f"[green]✓ Detected {framework.upper()} project ({mode_str} Mode)[/green] (Port: {port})")
|
|
793
|
+
else:
|
|
794
|
+
framework = "fastapi"
|
|
795
|
+
is_dockerized = True
|
|
796
|
+
except Exception as e:
|
|
797
|
+
framework = "fastapi"
|
|
798
|
+
is_dockerized = True
|
|
799
|
+
|
|
800
|
+
# Delta upload was already done above!
|
|
801
|
+
# file_manifest is ready (api_file_manifest)
|
|
802
|
+
|
|
803
|
+
# Create deployment with real-time streaming
|
|
804
|
+
try:
|
|
805
|
+
status_result, deployment_id, logs_data = _stream_deployment(
|
|
806
|
+
client=client,
|
|
807
|
+
project_name=project_name,
|
|
808
|
+
git_repo=git_repo,
|
|
809
|
+
branch=branch,
|
|
810
|
+
framework=framework,
|
|
811
|
+
region=region,
|
|
812
|
+
size_slug=size,
|
|
813
|
+
is_dockerized=is_dockerized,
|
|
814
|
+
port=port,
|
|
815
|
+
command=command,
|
|
816
|
+
entrypoint=entrypoint, # Pass entrypoint to deployment API
|
|
817
|
+
database=database,
|
|
818
|
+
package_manager=package_manager,
|
|
819
|
+
dependency_file=dependency_file,
|
|
820
|
+
file_manifest=api_file_manifest,
|
|
821
|
+
cleanup_on_failure=cleanup_on_failure,
|
|
822
|
+
services=services, # Microservices support
|
|
823
|
+
mode=mode, # Deployment mode
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
if status_result == "FAILED" and not no_heal:
|
|
828
|
+
# Hand off to the Zen Nod AI Agent
|
|
829
|
+
should_retry = zen_nod_workflow(
|
|
830
|
+
logs_data,
|
|
831
|
+
client,
|
|
832
|
+
attempt,
|
|
833
|
+
package_manager=package_manager,
|
|
834
|
+
dependency_file=dependency_file,
|
|
835
|
+
services=services
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
if should_retry:
|
|
839
|
+
# The agent applied a fix, loop back for attempt + 1
|
|
840
|
+
continue
|
|
841
|
+
else:
|
|
842
|
+
# Agent couldn't fix it or user declined
|
|
843
|
+
raise click.Abort()
|
|
844
|
+
|
|
845
|
+
# If we got here with success, break the retry loop
|
|
846
|
+
if status_result == "SUCCESS":
|
|
847
|
+
break
|
|
848
|
+
else:
|
|
849
|
+
raise click.Abort()
|
|
850
|
+
|
|
851
|
+
except XenfraAPIError as e:
|
|
852
|
+
# Deployment failed - try to provide helpful error
|
|
853
|
+
from ..utils.errors import detect_error_type, show_error_with_solution
|
|
854
|
+
|
|
855
|
+
console.print(f"[bold red]✗ Deployment failed[/bold red]")
|
|
856
|
+
|
|
857
|
+
# Try to detect error type and show solution
|
|
858
|
+
error_type, error_kwargs = detect_error_type(str(e.detail))
|
|
859
|
+
if error_type:
|
|
860
|
+
show_error_with_solution(error_type, **error_kwargs)
|
|
861
|
+
else:
|
|
862
|
+
console.print(f"[red]{e.detail}[/red]")
|
|
863
|
+
|
|
864
|
+
# Check if we should auto-heal
|
|
865
|
+
if no_heal or attempt >= MAX_RETRY_ATTEMPTS:
|
|
866
|
+
# No auto-healing or max retries reached
|
|
867
|
+
if attempt >= MAX_RETRY_ATTEMPTS:
|
|
868
|
+
console.print(
|
|
869
|
+
f"\n[bold red]❌ Maximum retry attempts ({MAX_RETRY_ATTEMPTS}) reached.[/bold red]"
|
|
870
|
+
)
|
|
871
|
+
console.print(
|
|
872
|
+
"[yellow]Unable to auto-fix the issue. Please review the errors above.[/yellow]"
|
|
873
|
+
)
|
|
874
|
+
raise
|
|
875
|
+
else:
|
|
876
|
+
# Try to get logs for diagnosis
|
|
877
|
+
error_logs = str(e.detail)
|
|
878
|
+
try:
|
|
879
|
+
if deployment_id:
|
|
880
|
+
# This should be a method in the SDK that returns a string
|
|
881
|
+
logs_response = client.deployments.get_logs(deployment_id)
|
|
882
|
+
if isinstance(logs_response, dict):
|
|
883
|
+
error_logs = logs_response.get("logs", str(e.detail))
|
|
884
|
+
else:
|
|
885
|
+
error_logs = str(logs_response) # Assuming it can be a string
|
|
886
|
+
except Exception as log_err:
|
|
887
|
+
console.print(
|
|
888
|
+
f"[yellow]Warning: Could not fetch detailed logs for diagnosis: {log_err}[/yellow]"
|
|
889
|
+
)
|
|
890
|
+
# Fallback to the initial error detail
|
|
891
|
+
pass
|
|
892
|
+
|
|
893
|
+
# Run Zen Nod workflow
|
|
894
|
+
should_retry = zen_nod_workflow(
|
|
895
|
+
error_logs,
|
|
896
|
+
client,
|
|
897
|
+
attempt,
|
|
898
|
+
package_manager=package_manager,
|
|
899
|
+
dependency_file=dependency_file,
|
|
900
|
+
services=services
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
if not should_retry:
|
|
904
|
+
# User declined patch or no patch available
|
|
905
|
+
console.print("\n[dim]Deployment cancelled.[/dim]")
|
|
906
|
+
raise click.Abort()
|
|
907
|
+
|
|
908
|
+
# Continue to next iteration (retry)
|
|
909
|
+
continue
|
|
910
|
+
|
|
911
|
+
except XenfraAPIError as e:
|
|
912
|
+
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
913
|
+
except XenfraError as e:
|
|
914
|
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
915
|
+
except click.Abort:
|
|
916
|
+
pass
|
|
917
|
+
except Exception as e:
|
|
918
|
+
console.print(f"[bold red]Unexpected error: {e}[/bold red]")
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
@click.command()
|
|
922
|
+
@click.argument("deployment-id")
|
|
923
|
+
@click.option("--follow", "-f", is_flag=True, help="Follow log output (stream)")
|
|
924
|
+
@click.option("--tail", type=int, help="Show last N lines")
|
|
925
|
+
def logs(deployment_id, follow, tail):
|
|
926
|
+
# Validate deployment ID
|
|
927
|
+
is_valid, error_msg = validate_deployment_id(deployment_id)
|
|
928
|
+
if not is_valid:
|
|
929
|
+
console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
|
|
930
|
+
raise click.Abort()
|
|
931
|
+
"""
|
|
932
|
+
Stream deployment logs.
|
|
933
|
+
|
|
934
|
+
Shows logs for a specific deployment. Use --follow to stream logs in real-time.
|
|
935
|
+
"""
|
|
936
|
+
try:
|
|
937
|
+
with get_client() as client:
|
|
938
|
+
console.print(f"[cyan]Fetching logs for deployment {deployment_id}...[/cyan]")
|
|
939
|
+
|
|
940
|
+
log_content = client.deployments.get_logs(deployment_id)
|
|
941
|
+
|
|
942
|
+
if not log_content:
|
|
943
|
+
console.print("[yellow]No logs available yet.[/yellow]")
|
|
944
|
+
console.print("[dim]The deployment may still be starting up.[/dim]")
|
|
945
|
+
return
|
|
946
|
+
|
|
947
|
+
# Process logs
|
|
948
|
+
log_lines = log_content.strip().split("\n")
|
|
949
|
+
|
|
950
|
+
# Apply tail if specified
|
|
951
|
+
if tail:
|
|
952
|
+
log_lines = log_lines[-tail:]
|
|
953
|
+
|
|
954
|
+
# Display logs with syntax highlighting
|
|
955
|
+
console.print(f"\n[bold]Logs for deployment {deployment_id}:[/bold]\n")
|
|
956
|
+
|
|
957
|
+
if follow:
|
|
958
|
+
_follow_deployment(client, deployment_id)
|
|
959
|
+
return
|
|
960
|
+
|
|
961
|
+
# Display logs
|
|
962
|
+
for line in log_lines:
|
|
963
|
+
# Color-code based on log level
|
|
964
|
+
if "ERROR" in line or "FAILED" in line:
|
|
965
|
+
console.print(f"[red]{line}[/red]")
|
|
966
|
+
elif "WARN" in line or "WARNING" in line:
|
|
967
|
+
console.print(f"[yellow]{line}[/yellow]")
|
|
968
|
+
elif "SUCCESS" in line or "COMPLETED" in line:
|
|
969
|
+
console.print(f"[green]{line}[/green]")
|
|
970
|
+
elif "INFO" in line:
|
|
971
|
+
console.print(f"[cyan]{line}[/cyan]")
|
|
972
|
+
else:
|
|
973
|
+
console.print(line)
|
|
974
|
+
|
|
975
|
+
except XenfraAPIError as e:
|
|
976
|
+
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
977
|
+
except XenfraError as e:
|
|
978
|
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
979
|
+
except click.Abort:
|
|
980
|
+
pass
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
@click.command()
|
|
984
|
+
@click.argument("deployment-id", required=False)
|
|
985
|
+
@click.option("--watch", "-w", is_flag=True, help="Watch status updates")
|
|
986
|
+
def status(deployment_id, watch):
|
|
987
|
+
"""
|
|
988
|
+
Show deployment status.
|
|
989
|
+
|
|
990
|
+
Displays current status, progress, and details for a deployment.
|
|
991
|
+
Use --watch to monitor status in real-time.
|
|
992
|
+
"""
|
|
993
|
+
try:
|
|
994
|
+
if not deployment_id:
|
|
995
|
+
console.print("[yellow]No deployment ID provided.[/yellow]")
|
|
996
|
+
console.print("[dim]Usage: xenfra status <deployment-id>[/dim]")
|
|
997
|
+
return
|
|
998
|
+
|
|
999
|
+
# Validate deployment ID
|
|
1000
|
+
is_valid, error_msg = validate_deployment_id(deployment_id)
|
|
1001
|
+
if not is_valid:
|
|
1002
|
+
console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
|
|
1003
|
+
raise click.Abort()
|
|
1004
|
+
|
|
1005
|
+
with get_client() as client:
|
|
1006
|
+
console.print(f"[cyan]Fetching status for deployment {deployment_id}...[/cyan]")
|
|
1007
|
+
|
|
1008
|
+
deployment_status = client.deployments.get_status(deployment_id)
|
|
1009
|
+
|
|
1010
|
+
if watch:
|
|
1011
|
+
_follow_deployment(client, deployment_id)
|
|
1012
|
+
return
|
|
1013
|
+
|
|
1014
|
+
# Display status
|
|
1015
|
+
status_value = deployment_status.get("status", "UNKNOWN")
|
|
1016
|
+
state = deployment_status.get("state", "unknown")
|
|
1017
|
+
progress = deployment_status.get("progress", 0)
|
|
1018
|
+
|
|
1019
|
+
# Status panel
|
|
1020
|
+
status_color = {
|
|
1021
|
+
"PENDING": "yellow",
|
|
1022
|
+
"IN_PROGRESS": "cyan",
|
|
1023
|
+
"SUCCESS": "green",
|
|
1024
|
+
"FAILED": "red",
|
|
1025
|
+
"CANCELLED": "dim",
|
|
1026
|
+
}.get(status_value, "white")
|
|
1027
|
+
|
|
1028
|
+
# Create status table
|
|
1029
|
+
table = Table(show_header=False, box=None)
|
|
1030
|
+
table.add_column("Property", style="cyan")
|
|
1031
|
+
table.add_column("Value")
|
|
1032
|
+
|
|
1033
|
+
table.add_row("Deployment ID", str(deployment_id))
|
|
1034
|
+
table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
|
|
1035
|
+
table.add_row("State", state)
|
|
1036
|
+
|
|
1037
|
+
if progress > 0:
|
|
1038
|
+
table.add_row("Progress", f"{progress}%")
|
|
1039
|
+
|
|
1040
|
+
if "project_name" in deployment_status:
|
|
1041
|
+
table.add_row("Project", deployment_status["project_name"])
|
|
1042
|
+
|
|
1043
|
+
if "created_at" in deployment_status:
|
|
1044
|
+
table.add_row("Created", deployment_status["created_at"])
|
|
1045
|
+
|
|
1046
|
+
if "finished_at" in deployment_status:
|
|
1047
|
+
table.add_row("Finished", deployment_status["finished_at"])
|
|
1048
|
+
|
|
1049
|
+
if "url" in deployment_status:
|
|
1050
|
+
table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
|
|
1051
|
+
|
|
1052
|
+
if "ip_address" in deployment_status:
|
|
1053
|
+
table.add_row("IP Address", deployment_status["ip_address"])
|
|
1054
|
+
|
|
1055
|
+
panel = Panel(table, title="[bold]Deployment Status[/bold]", border_style=status_color)
|
|
1056
|
+
console.print(panel)
|
|
1057
|
+
|
|
1058
|
+
# Show error if failed
|
|
1059
|
+
if status_value == "FAILED" and "error" in deployment_status:
|
|
1060
|
+
error_panel = Panel(
|
|
1061
|
+
deployment_status["error"],
|
|
1062
|
+
title="[bold red]Error[/bold red]",
|
|
1063
|
+
border_style="red",
|
|
1064
|
+
)
|
|
1065
|
+
console.print("\n", error_panel)
|
|
1066
|
+
|
|
1067
|
+
console.print("\n[bold]Troubleshooting:[/bold]")
|
|
1068
|
+
console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
|
|
1069
|
+
console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
|
|
1070
|
+
|
|
1071
|
+
# Show next steps based on status
|
|
1072
|
+
elif status_value == "SUCCESS":
|
|
1073
|
+
console.print("\n[bold green]Deployment successful! 🎉[/bold green]")
|
|
1074
|
+
if "url" in deployment_status:
|
|
1075
|
+
console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
|
|
1076
|
+
|
|
1077
|
+
elif status_value in ["PENDING", "IN_PROGRESS"]:
|
|
1078
|
+
console.print("\n[bold]Deployment in progress...[/bold]")
|
|
1079
|
+
console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
|
|
1080
|
+
console.print(f" • Check again: [cyan]xenfra status {deployment_id}[/cyan]")
|
|
1081
|
+
|
|
1082
|
+
except XenfraAPIError as e:
|
|
1083
|
+
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
1084
|
+
except XenfraError as e:
|
|
1085
|
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
1086
|
+
except click.Abort:
|
|
1087
|
+
pass
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
@click.command()
|
|
1091
|
+
@click.argument("deployment-id")
|
|
1092
|
+
@click.option("--format", "output_format", type=click.Choice(["detailed", "summary"], case_sensitive=False), default="detailed", help="Report format (detailed or summary)")
|
|
1093
|
+
def report(deployment_id, output_format):
|
|
1094
|
+
"""
|
|
1095
|
+
Generate deployment report with self-healing events.
|
|
1096
|
+
|
|
1097
|
+
Shows comprehensive deployment information including:
|
|
1098
|
+
- Deployment status and timeline
|
|
1099
|
+
- Self-healing attempts and outcomes
|
|
1100
|
+
- Patches applied during healing
|
|
1101
|
+
- Statistics and metrics
|
|
1102
|
+
"""
|
|
1103
|
+
try:
|
|
1104
|
+
# Validate deployment ID
|
|
1105
|
+
is_valid, error_msg = validate_deployment_id(deployment_id)
|
|
1106
|
+
if not is_valid:
|
|
1107
|
+
console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
|
|
1108
|
+
raise click.Abort()
|
|
1109
|
+
|
|
1110
|
+
with get_client() as client:
|
|
1111
|
+
console.print(f"[cyan]Generating report for deployment {deployment_id}...[/cyan]\n")
|
|
1112
|
+
|
|
1113
|
+
# Get deployment status
|
|
1114
|
+
try:
|
|
1115
|
+
deployment_status = client.deployments.get_status(deployment_id)
|
|
1116
|
+
except XenfraAPIError as e:
|
|
1117
|
+
console.print(f"[bold red]Error fetching deployment status: {e.detail}[/bold red]")
|
|
1118
|
+
raise click.Abort()
|
|
1119
|
+
|
|
1120
|
+
# Get deployment logs
|
|
1121
|
+
try:
|
|
1122
|
+
logs = client.deployments.get_logs(deployment_id)
|
|
1123
|
+
except XenfraAPIError:
|
|
1124
|
+
logs = None
|
|
1125
|
+
|
|
1126
|
+
# Parse status
|
|
1127
|
+
status_value = deployment_status.get("status", "UNKNOWN")
|
|
1128
|
+
state = deployment_status.get("state", "unknown")
|
|
1129
|
+
progress = deployment_status.get("progress", 0)
|
|
1130
|
+
|
|
1131
|
+
# Status color mapping
|
|
1132
|
+
status_color = {
|
|
1133
|
+
"PENDING": "yellow",
|
|
1134
|
+
"IN_PROGRESS": "cyan",
|
|
1135
|
+
"SUCCESS": "green",
|
|
1136
|
+
"FAILED": "red",
|
|
1137
|
+
"CANCELLED": "dim",
|
|
1138
|
+
}.get(status_value, "white")
|
|
1139
|
+
|
|
1140
|
+
# Calculate statistics from logs
|
|
1141
|
+
heal_attempts = logs.count("🤖 Analyzing failure") if logs else 0
|
|
1142
|
+
patches_applied = logs.count("✓ Patch applied") if logs else 0
|
|
1143
|
+
diagnoses = logs.count("🔍 Diagnosis") if logs else 0
|
|
1144
|
+
|
|
1145
|
+
# Create main report table
|
|
1146
|
+
report_table = Table(show_header=True, box=None)
|
|
1147
|
+
report_table.add_column("Property", style="cyan", width=25)
|
|
1148
|
+
report_table.add_column("Value", style="white")
|
|
1149
|
+
|
|
1150
|
+
report_table.add_row("Deployment ID", str(deployment_id))
|
|
1151
|
+
report_table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
|
|
1152
|
+
report_table.add_row("State", state)
|
|
1153
|
+
|
|
1154
|
+
if progress > 0:
|
|
1155
|
+
report_table.add_row("Progress", f"{progress}%")
|
|
1156
|
+
|
|
1157
|
+
if "project_name" in deployment_status:
|
|
1158
|
+
report_table.add_row("Project", deployment_status["project_name"])
|
|
1159
|
+
|
|
1160
|
+
if "created_at" in deployment_status:
|
|
1161
|
+
report_table.add_row("Created", deployment_status["created_at"])
|
|
1162
|
+
|
|
1163
|
+
if "finished_at" in deployment_status:
|
|
1164
|
+
report_table.add_row("Finished", deployment_status["finished_at"])
|
|
1165
|
+
|
|
1166
|
+
if "url" in deployment_status:
|
|
1167
|
+
report_table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
|
|
1168
|
+
|
|
1169
|
+
if "ip_address" in deployment_status:
|
|
1170
|
+
report_table.add_row("IP Address", deployment_status["ip_address"])
|
|
1171
|
+
|
|
1172
|
+
# Self-healing statistics
|
|
1173
|
+
report_table.add_row("", "") # Separator
|
|
1174
|
+
report_table.add_row("[bold]Self-Healing Stats[/bold]", "")
|
|
1175
|
+
report_table.add_row("Healing Attempts", str(heal_attempts))
|
|
1176
|
+
report_table.add_row("Patches Applied", str(patches_applied))
|
|
1177
|
+
report_table.add_row("Diagnoses Performed", str(diagnoses))
|
|
1178
|
+
|
|
1179
|
+
if heal_attempts > 0:
|
|
1180
|
+
success_rate = (patches_applied / heal_attempts * 100) if heal_attempts > 0 else 0
|
|
1181
|
+
report_table.add_row("Healing Success Rate", f"{success_rate:.1f}%")
|
|
1182
|
+
|
|
1183
|
+
# Display main report
|
|
1184
|
+
console.print(Panel(report_table, title="[bold]Deployment Report[/bold]", border_style=status_color))
|
|
1185
|
+
|
|
1186
|
+
# Detailed format includes timeline and healing events
|
|
1187
|
+
if output_format == "detailed" and logs:
|
|
1188
|
+
console.print("\n[bold]Self-Healing Timeline[/bold]\n")
|
|
1189
|
+
|
|
1190
|
+
# Extract healing events from logs
|
|
1191
|
+
log_lines = logs.split("\n")
|
|
1192
|
+
timeline_entries = []
|
|
1193
|
+
|
|
1194
|
+
for i, line in enumerate(log_lines):
|
|
1195
|
+
if "🤖 Analyzing failure" in line:
|
|
1196
|
+
attempt_match = None
|
|
1197
|
+
# Try to find attempt number in surrounding lines
|
|
1198
|
+
for j in range(max(0, i-5), min(len(log_lines), i+10)):
|
|
1199
|
+
if "attempt" in log_lines[j].lower():
|
|
1200
|
+
timeline_entries.append(("Healing Attempt", log_lines[j].strip()))
|
|
1201
|
+
break
|
|
1202
|
+
elif "🔍 Diagnosis" in line or "Diagnosis" in line:
|
|
1203
|
+
# Extract diagnosis text from next few lines
|
|
1204
|
+
diagnosis_text = line.strip()
|
|
1205
|
+
if i+1 < len(log_lines) and log_lines[i+1].strip():
|
|
1206
|
+
diagnosis_text += "\n " + log_lines[i+1].strip()[:100]
|
|
1207
|
+
timeline_entries.append(("Diagnosis", diagnosis_text))
|
|
1208
|
+
elif "✓ Patch applied" in line or "Patch applied" in line:
|
|
1209
|
+
timeline_entries.append(("Patch Applied", line.strip()))
|
|
1210
|
+
elif "🔄 Retrying deployment" in line:
|
|
1211
|
+
timeline_entries.append(("Retry", line.strip()))
|
|
1212
|
+
|
|
1213
|
+
if timeline_entries:
|
|
1214
|
+
timeline_table = Table(show_header=True, box=None)
|
|
1215
|
+
timeline_table.add_column("Event", style="cyan", width=20)
|
|
1216
|
+
timeline_table.add_column("Details", style="white")
|
|
1217
|
+
|
|
1218
|
+
for event_type, details in timeline_entries[:20]: # Limit to 20 entries
|
|
1219
|
+
timeline_table.add_row(event_type, details)
|
|
1220
|
+
|
|
1221
|
+
console.print(timeline_table)
|
|
1222
|
+
else:
|
|
1223
|
+
console.print("[dim]No self-healing events detected in logs.[/dim]")
|
|
1224
|
+
|
|
1225
|
+
# Show error if failed
|
|
1226
|
+
if status_value == "FAILED":
|
|
1227
|
+
console.print("\n[bold red]⚠ Deployment Failed[/bold red]")
|
|
1228
|
+
if "error" in deployment_status:
|
|
1229
|
+
error_panel = Panel(
|
|
1230
|
+
deployment_status["error"],
|
|
1231
|
+
title="[bold red]Error Details[/bold red]",
|
|
1232
|
+
border_style="red",
|
|
1233
|
+
)
|
|
1234
|
+
console.print("\n", error_panel)
|
|
1235
|
+
|
|
1236
|
+
console.print("\n[bold]Troubleshooting:[/bold]")
|
|
1237
|
+
console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
|
|
1238
|
+
console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
|
|
1239
|
+
console.print(f" • View status: [cyan]xenfra status {deployment_id}[/cyan]")
|
|
1240
|
+
|
|
1241
|
+
# Success summary
|
|
1242
|
+
elif status_value == "SUCCESS":
|
|
1243
|
+
console.print("\n[bold green]✓ Deployment Successful[/bold green]")
|
|
1244
|
+
if heal_attempts > 0:
|
|
1245
|
+
console.print(f"[dim]Deployment succeeded after {heal_attempts} self-healing attempt(s).[/dim]")
|
|
1246
|
+
if "url" in deployment_status:
|
|
1247
|
+
console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
|
|
1248
|
+
|
|
1249
|
+
# Export summary format (JSON-like structure for programmatic use)
|
|
1250
|
+
if output_format == "summary":
|
|
1251
|
+
console.print("\n[bold]Summary Format:[/bold]")
|
|
1252
|
+
import json
|
|
1253
|
+
summary = {
|
|
1254
|
+
"deployment_id": deployment_id,
|
|
1255
|
+
"status": status_value,
|
|
1256
|
+
"healing_attempts": heal_attempts,
|
|
1257
|
+
"patches_applied": patches_applied,
|
|
1258
|
+
"success": status_value == "SUCCESS",
|
|
1259
|
+
}
|
|
1260
|
+
console.print(f"[dim]{json.dumps(summary, indent=2)}[/dim]")
|
|
1261
|
+
|
|
1262
|
+
except XenfraAPIError as e:
|
|
1263
|
+
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
1264
|
+
except XenfraError as e:
|
|
1265
|
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
1266
|
+
except click.Abort:
|
|
1267
|
+
pass
|
|
1268
|
+
except Exception as e:
|
|
1269
|
+
console.print(f"[bold red]Unexpected error: {e}[/bold red]")
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
@click.command(name="dry-run")
|
|
1274
|
+
@click.option("--force-sandbox", is_flag=True, help="Force full sandbox validation (Tier 3)")
|
|
1275
|
+
@click.option("--skip-sandbox", is_flag=True, help="Skip sandbox validation for faster iteration")
|
|
1276
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
|
|
1277
|
+
def dry_run_command(force_sandbox, skip_sandbox, verbose):
|
|
1278
|
+
"""
|
|
1279
|
+
Run 3-tier dry-run validation without deploying.
|
|
1280
|
+
|
|
1281
|
+
Validates your project through all three tiers without
|
|
1282
|
+
actually deploying to infrastructure:
|
|
1283
|
+
|
|
1284
|
+
\b
|
|
1285
|
+
[bold]Tier 1[/bold]: Local validation - Syntax, imports, config files
|
|
1286
|
+
[bold]Tier 2[/bold]: Railpack plan - Generate build plan
|
|
1287
|
+
[bold]Tier 3[/bold]: E2B Sandbox - Full build validation via Xenfra API
|
|
1288
|
+
|
|
1289
|
+
This is useful for:
|
|
1290
|
+
- Validating changes before deploying
|
|
1291
|
+
- CI/CD pipelines
|
|
1292
|
+
- Checking for syntax errors quickly
|
|
1293
|
+
|
|
1294
|
+
Tier 3 runs on Xenfra's servers - no local setup needed.
|
|
1295
|
+
"""
|
|
1296
|
+
from pathlib import Path
|
|
1297
|
+
from ..blueprints import run_dry_run
|
|
1298
|
+
from ..utils.file_sync import scan_project_files_cached, ensure_gitignore_ignored
|
|
1299
|
+
|
|
1300
|
+
project_path = Path(os.getcwd())
|
|
1301
|
+
|
|
1302
|
+
# Get authenticated client for Tier 3 sandbox
|
|
1303
|
+
try:
|
|
1304
|
+
client = get_client()
|
|
1305
|
+
except click.Abort:
|
|
1306
|
+
return
|
|
1307
|
+
|
|
1308
|
+
# Scan project files
|
|
1309
|
+
if ensure_gitignore_ignored():
|
|
1310
|
+
console.print("[dim] - Added .xenfra to .gitignore for privacy[/dim]")
|
|
1311
|
+
|
|
1312
|
+
console.print("[cyan]📁 Scanning project files...[/cyan]")
|
|
1313
|
+
file_manifest = scan_project_files_cached()
|
|
1314
|
+
console.print(f"[dim]Found {len(file_manifest)} files[/dim]")
|
|
1315
|
+
|
|
1316
|
+
if not file_manifest:
|
|
1317
|
+
console.print("[bold red]Error: No files found to validate.[/bold red]")
|
|
1318
|
+
raise click.Abort()
|
|
1319
|
+
|
|
1320
|
+
console.print(Panel(
|
|
1321
|
+
"[bold]🔍 Running 3-Tier Dry-Run Validation[/bold]\n"
|
|
1322
|
+
"Validating your project without deploying...",
|
|
1323
|
+
title="[bold cyan]Dry Run[/bold cyan]",
|
|
1324
|
+
border_style="cyan"
|
|
1325
|
+
))
|
|
1326
|
+
|
|
1327
|
+
# Run dry-run
|
|
1328
|
+
with console.status("[cyan]Running validation...[/cyan]"):
|
|
1329
|
+
result = run_dry_run(
|
|
1330
|
+
project_path,
|
|
1331
|
+
force_sandbox=force_sandbox,
|
|
1332
|
+
skip_sandbox=skip_sandbox,
|
|
1333
|
+
api_client=client,
|
|
1334
|
+
file_manifest=file_manifest
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
# Display results
|
|
1338
|
+
console.print(result.get_summary())
|
|
1339
|
+
console.print()
|
|
1340
|
+
|
|
1341
|
+
# Detailed output if verbose
|
|
1342
|
+
if verbose and result.tier2:
|
|
1343
|
+
console.print("[bold]Build Plan Details:[/bold]")
|
|
1344
|
+
console.print(f" Framework: {result.tier2.framework}")
|
|
1345
|
+
console.print(f" Language: {result.tier2.language}")
|
|
1346
|
+
console.print(f" Package Manager: {result.tier2.package_manager}")
|
|
1347
|
+
console.print(f" Port: {result.tier2.port}")
|
|
1348
|
+
if result.tier2.start_command:
|
|
1349
|
+
console.print(f" Start Command: {result.tier2.start_command}")
|
|
1350
|
+
console.print()
|
|
1351
|
+
|
|
1352
|
+
# Final status
|
|
1353
|
+
if result.success:
|
|
1354
|
+
console.print("[bold green]✅ All validations passed - Ready to deploy![/bold green]")
|
|
1355
|
+
console.print("[dim]Run 'xenfra deploy' to deploy to infrastructure.[/dim]")
|
|
1356
|
+
else:
|
|
1357
|
+
console.print("[bold red]❌ Validation failed - Fix errors before deploying[/bold red]")
|
|
1358
|
+
raise click.Abort()
|