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