9
9
import click
10
10
import click_log
11
11
import tabulate
12
+ from urllib .parse import urlparse
13
+ import paramiko
14
+ import requests
15
+ import getpass
16
+ import shutil
12
17
from natsort import natsorted
13
18
14
19
from sonic_package_manager .database import PackageEntry , PackageDatabase
15
20
from sonic_package_manager .errors import PackageManagerError
16
21
from sonic_package_manager .logger import log
17
22
from sonic_package_manager .manager import PackageManager
23
+ from sonic_package_manager .manifest import Manifest , DEFAULT_MANIFEST , MANIFEST_LOCATION , DEFAUT_MANIFEST_NAME , DMFILE_NAME
24
+ LOCAL_TARBALL_PATH = "/tmp/local_tarball.gz"
25
+ LOCAL_JSON = "/tmp/local_json"
18
26
19
27
BULLET_UC = '\u2022 '
20
28
@@ -97,11 +105,8 @@ def handle_parse_result(self, ctx, opts, args):
97
105
cls = MutuallyExclusiveOption ,
98
106
mutually_exclusive = ['from_tarball' , 'package_expr' ]),
99
107
click .option ('--from-tarball' ,
100
- type = click .Path (exists = True ,
101
- readable = True ,
102
- file_okay = True ,
103
- dir_okay = False ),
104
- help = 'Fetch package from saved image tarball.' ,
108
+ type = str ,
109
+ help = 'Fetch package from saved image tarball from local/scp/sftp/http' ,
105
110
cls = MutuallyExclusiveOption ,
106
111
mutually_exclusive = ['from_repository' , 'package_expr' ]),
107
112
click .argument ('package-expr' ,
@@ -157,6 +162,13 @@ def repository(ctx):
157
162
pass
158
163
159
164
165
+ @cli .group ()
166
+ @click .pass_context
167
+ def manifests (ctx ):
168
+ """ Custom local Manifest management commands. """
169
+
170
+ pass
171
+
160
172
@cli .group ()
161
173
@click .pass_context
162
174
def show (ctx ):
@@ -215,6 +227,11 @@ def manifest(ctx,
215
227
manager : PackageManager = ctx .obj
216
228
217
229
try :
230
+ if from_tarball :
231
+ #Download the tar file from local/scp/sftp/http
232
+ download_file (from_tarball , LOCAL_TARBALL_PATH )
233
+ from_tarball = LOCAL_TARBALL_PATH
234
+
218
235
source = manager .get_package_source (package_expr ,
219
236
from_repository ,
220
237
from_tarball )
@@ -255,6 +272,11 @@ def changelog(ctx,
255
272
manager : PackageManager = ctx .obj
256
273
257
274
try :
275
+ if from_tarball :
276
+ #Download the tar file from local/scp/sftp/http
277
+ download_file (from_tarball , LOCAL_TARBALL_PATH )
278
+ from_tarball = LOCAL_TARBALL_PATH
279
+
258
280
source = manager .get_package_source (package_expr ,
259
281
from_repository ,
260
282
from_tarball )
@@ -280,6 +302,166 @@ def changelog(ctx,
280
302
exit_cli (f'Failed to print package changelog: { err } ' , fg = 'red' )
281
303
282
304
305
+
306
+ @manifests .command ('create' )
307
+ @click .pass_context
308
+ @click .argument ('name' , type = click .Path ())
309
+ @click .option ('--from-json' , type = str , help = 'specify manifest json file' )
310
+ @root_privileges_required
311
+ def create (ctx , name , from_json ):
312
+ """Create a new custom local manifest file."""
313
+
314
+ #Creation of default manifest file in case the file does not exist
315
+ if not os .path .exists (MANIFEST_LOCATION ):
316
+ os .mkdir (MANIFEST_LOCATION )
317
+ if not os .path .exists (DMFILE_NAME ):
318
+ with open (DMFILE_NAME , 'w' ) as file :
319
+ json .dump (DEFAULT_MANIFEST , file , indent = 4 )
320
+ click .echo (f"Manifest '{ DEFAUT_MANIFEST_NAME } ' created now." )
321
+
322
+ #Validation checks
323
+ manager : PackageManager = ctx .obj
324
+ if manager .is_installed (name ):
325
+ click .echo ("Error: A package with the same name {} is already installed" .format (name ))
326
+ return
327
+ MFILE_NAME = os .path .join (MANIFEST_LOCATION , name )
328
+ if os .path .exists (MFILE_NAME ):
329
+ click .echo ("Error: Manifest file '{}' already exists." .format (name ))
330
+ return
331
+
332
+ #Create the manifest file in centralized location
333
+ #Download the json file from scp/sftp/http to local_json_file
334
+ try :
335
+ if from_json :
336
+ download_file (from_json , LOCAL_JSON )
337
+ from_json = LOCAL_JSON
338
+ data = {}
339
+ with open (from_json , 'r' ) as file :
340
+ data = json .load (file )
341
+ #Validate with manifest scheme
342
+ Manifest .marshal (data )
343
+
344
+ #Make sure the 'name' is overwritten into the dict
345
+ data ['package' ]['name' ] = name
346
+ data ['service' ]['name' ] = name
347
+
348
+ with open (MFILE_NAME , 'w' ) as file :
349
+ json .dump (data , file , indent = 4 )
350
+ else :
351
+ shutil .copy (DMFILE_NAME , MFILE_NAME )
352
+ click .echo (f"Manifest '{ name } ' created successfully." )
353
+ except Exception as e :
354
+ click .echo ("Error: Manifest {} creation failed - {}" .format (name , str (e )))
355
+ return
356
+
357
+
358
+
359
+ #At the end of sonic-package-manager install, a new manifest file is created with the name.
360
+ #At the end of sonic-package-manager uninstall name, this manifest file name and name.edit will be deleted.
361
+ #At the end of sonic-package-manager update, we need to mv maniests name.edit to name in case of success, else keep it as such.
362
+ #So during sonic-package-manager update, we could take old package from name and new package from edit and at the end, follow 3rd point
363
+ @manifests .command ('update' )
364
+ @click .pass_context
365
+ @click .argument ('name' , type = click .Path ())
366
+ @click .option ('--from-json' , type = str , required = True )
367
+ #@click.argument('--from-json', type=str, help='Specify Manifest json file')
368
+ @root_privileges_required
369
+ def update (ctx , name , from_json ):
370
+ """Update an existing custom local manifest file with new one."""
371
+
372
+ manager : PackageManager = ctx .obj
373
+ ORG_FILE = os .path .join (MANIFEST_LOCATION , name )
374
+ if not os .path .exists (ORG_FILE ):
375
+ click .echo (f'Local Manifest file for { name } does not exists to update' )
376
+ return
377
+ try :
378
+ #download json file from remote/local path
379
+ download_file (from_json , LOCAL_JSON )
380
+ from_json = LOCAL_JSON
381
+ with open (from_json , 'r' ) as file :
382
+ data = json .load (file )
383
+
384
+ #Validate with manifest scheme
385
+ Manifest .marshal (data )
386
+
387
+ #Make sure the 'name' is overwritten into the dict
388
+ data ['package' ]['name' ] = name
389
+ data ['service' ]['name' ] = name
390
+
391
+ if manager .is_installed (name ):
392
+ edit_name = name + '.edit'
393
+ EDIT_FILE = os .path .join (MANIFEST_LOCATION , edit_name )
394
+ with open (EDIT_FILE , 'w' ) as edit_file :
395
+ json .dump (data , edit_file , indent = 4 )
396
+ click .echo (f"Manifest '{ name } ' updated successfully." )
397
+ else :
398
+ #If package is not installed,
399
+ ## update the name file directly
400
+ with open (ORG_FILE , 'w' ) as orig_file :
401
+ json .dump (data , orig_file , indent = 4 )
402
+ click .echo (f"Manifest '{ name } ' updated successfully." )
403
+ except Exception as e :
404
+ click .echo (f"Error occurred while updating manifest '{ name } ': { e } " )
405
+ return
406
+
407
+
408
+ @manifests .command ('delete' )
409
+ @click .pass_context
410
+ @click .argument ('name' , type = click .Path ())
411
+ @root_privileges_required
412
+ def delete (ctx , name ):
413
+ """Delete a custom local manifest file."""
414
+ # Check if the manifest file exists
415
+ mfile_name = "{}{}" .format (MANIFEST_LOCATION , name )
416
+ if not os .path .exists (mfile_name ):
417
+ click .echo ("Error: Manifest file '{}' not found." .format (name ))
418
+ return
419
+
420
+ try :
421
+ # Confirm deletion with user input
422
+ confirm = click .prompt ("Are you sure you want to delete the manifest file '{}'? (y/n)" .format (name ), type = str )
423
+ if confirm .lower () == 'y' :
424
+ os .remove (mfile_name )
425
+ click .echo ("Manifest '{}' deleted successfully." .format (name ))
426
+ else :
427
+ click .echo ("Deletion cancelled." )
428
+ except Exception as e :
429
+ click .echo ("Error: Failed to delete manifest file '{}'. {}" .format (name , e ))
430
+
431
+
432
+ @manifests .command ('show' )
433
+ @click .pass_context
434
+ @click .argument ('name' , type = click .Path ())
435
+ @root_privileges_required
436
+ def show_manifest (ctx , name ):
437
+ """Show the contents of custom local manifest file."""
438
+ mfile_name = "{}{}" .format (MANIFEST_LOCATION , name )
439
+ edit_file_name = "{}.edit" .format (mfile_name )
440
+ try :
441
+ if os .path .exists (edit_file_name ):
442
+ mfile_name = edit_file_name
443
+ with open (mfile_name , 'r' ) as file :
444
+ data = json .load (file )
445
+ click .echo ("Manifest file: {}" .format (name ))
446
+ click .echo (json .dumps (data , indent = 4 ))
447
+ except FileNotFoundError :
448
+ click .echo ("Manifest file '{}' not found." .format (name ))
449
+
450
+ @manifests .command ('list' )
451
+ @click .pass_context
452
+ @root_privileges_required
453
+ def list_manifests (ctx ):
454
+ """List all custom local manifest files."""
455
+ # Get all files in the manifest location
456
+ manifest_files = os .listdir (MANIFEST_LOCATION )
457
+ if not manifest_files :
458
+ click .echo ("No custom local manifest files found." )
459
+ else :
460
+ click .echo ("Custom Local Manifest files:" )
461
+ for file in manifest_files :
462
+ click .echo ("- {}" .format (file ))
463
+
464
+
283
465
@repository .command ()
284
466
@click .argument ('name' , type = str )
285
467
@click .argument ('repository' , type = str )
@@ -316,6 +498,78 @@ def remove(ctx, name):
316
498
exit_cli (f'Failed to remove repository { name } : { err } ' , fg = 'red' )
317
499
318
500
501
+ def parse_url (url ):
502
+ # Parse information from URL
503
+ parsed_url = urlparse (url )
504
+ if parsed_url .scheme == "scp" or parsed_url .scheme == "sftp" :
505
+ return parsed_url .username , parsed_url .password , parsed_url .hostname , parsed_url .path
506
+ elif parsed_url .scheme == "http" :
507
+ return None , None , parsed_url .netloc , parsed_url .path
508
+ elif not parsed_url .scheme : # No scheme indicates a local file path
509
+ return None , None , None , parsed_url .path
510
+ else :
511
+ raise ValueError ("Unsupported URL scheme" )
512
+
513
+ def validate_url_or_abort (url ):
514
+ # Attempt to retrieve HTTP response code
515
+ try :
516
+ response = requests .head (url )
517
+ response_code = response .status_code
518
+ except requests .exceptions .RequestException as err :
519
+ response_code = None
520
+
521
+ if not response_code :
522
+ print ("Did not receive a response from remote machine. Aborting..." )
523
+ return
524
+ else :
525
+ # Check for a 4xx response code which indicates a nonexistent URL
526
+ if str (response_code ).startswith ('4' ):
527
+ print ("Image file not found on remote machine. Aborting..." )
528
+ return
529
+
530
+ def download_file (url , local_path ):
531
+ # Parse information from the URL
532
+ username , password , hostname , remote_path = parse_url (url )
533
+
534
+ if username is not None :
535
+ # If password is not provided, prompt the user for it securely
536
+ if password is None :
537
+ password = getpass .getpass (prompt = f"Enter password for { username } @{ hostname } : " )
538
+
539
+ # Create an SSH client for SCP or SFTP
540
+ client = paramiko .SSHClient ()
541
+ # Automatically add the server's host key (this is insecure and should be handled differently in production)
542
+ client .set_missing_host_key_policy (paramiko .AutoAddPolicy ())
543
+
544
+ try :
545
+ # Connect to the SSH server
546
+ client .connect (hostname , username = username , password = password )
547
+
548
+ # Open an SCP channel for SCP or an SFTP channel for SFTP
549
+ with client .open_sftp () as sftp :
550
+ # Download the file
551
+ sftp .get (remote_path , local_path )
552
+
553
+ finally :
554
+ # Close the SSH connection
555
+ client .close ()
556
+ elif hostname :
557
+ # Download using HTTP for URLs without credentials
558
+ validate_url_or_abort (url )
559
+ try :
560
+ response = requests .get (url )
561
+ with open (local_path , 'wb' ) as f :
562
+ f .write (response .content )
563
+ except requests .exceptions .RequestException as e :
564
+ print ("Download error" , e )
565
+ return
566
+ else :
567
+ if os .path .exists (remote_path ):
568
+ shutil .copy (remote_path , local_path )
569
+ else :
570
+ print (f"Error: Source file '{ remote_path } ' does not exist." )
571
+
572
+
319
573
@cli .command ()
320
574
@click .option ('--enable' ,
321
575
is_flag = True ,
@@ -334,6 +588,13 @@ def remove(ctx, name):
334
588
help = 'Allow package downgrade. By default an attempt to downgrade the package '
335
589
'will result in a failure since downgrade might not be supported by the package, '
336
590
'thus requires explicit request from the user.' )
591
+ @click .option ('--use-local-manifest' ,
592
+ is_flag = True ,
593
+ default = None ,
594
+ help = 'Use locally created custom manifest file ' )
595
+ @click .option ('--name' ,
596
+ type = str ,
597
+ help = 'custom name for the package' )
337
598
@add_options (PACKAGE_SOURCE_OPTIONS )
338
599
@add_options (PACKAGE_COMMON_OPERATION_OPTIONS )
339
600
@add_options (PACKAGE_COMMON_INSTALL_OPTIONS )
@@ -348,7 +609,9 @@ def install(ctx,
348
609
enable ,
349
610
set_owner ,
350
611
skip_host_plugins ,
351
- allow_downgrade ):
612
+ allow_downgrade ,
613
+ use_local_manifest ,
614
+ name ):
352
615
""" Install/Upgrade package using [PACKAGE_EXPR] in format "<name>[=<version>|@<reference>]".
353
616
354
617
The repository to pull the package from is resolved by lookup in package database,
@@ -378,17 +641,50 @@ def install(ctx,
378
641
if allow_downgrade is not None :
379
642
install_opts ['allow_downgrade' ] = allow_downgrade
380
643
644
+ if use_local_manifest :
645
+ if not name :
646
+ click .echo (f'name argument is not provided to use local manifest' )
647
+ return
648
+ ORG_FILE = os .path .join (MANIFEST_LOCATION , name )
649
+ if not os .path .exists (ORG_FILE ):
650
+ click .echo (f'Local Manifest file for { name } does not exists to install' )
651
+ return
652
+
653
+ if from_tarball :
654
+ #Download the tar file from local/scp/sftp/http
655
+ download_file (from_tarball , LOCAL_TARBALL_PATH )
656
+ from_tarball = LOCAL_TARBALL_PATH
657
+
381
658
try :
382
659
manager .install (package_expr ,
383
660
from_repository ,
384
661
from_tarball ,
662
+ use_local_manifest ,
663
+ name ,
385
664
** install_opts )
386
665
except Exception as err :
387
666
exit_cli (f'Failed to install { package_source } : { err } ' , fg = 'red' )
388
667
except KeyboardInterrupt :
389
668
exit_cli ('Operation canceled by user' , fg = 'red' )
390
669
391
670
671
+ @cli .command ()
672
+ @add_options (PACKAGE_COMMON_OPERATION_OPTIONS )
673
+ @click .argument ('name' )
674
+ @click .pass_context
675
+ @root_privileges_required
676
+ def update (ctx , name , force , yes ):
677
+ """ Update package to the updated manifest file """
678
+
679
+ manager : PackageManager = ctx .obj
680
+
681
+ try :
682
+ manager .update (name , force )
683
+ except Exception as err :
684
+ exit_cli (f'Failed to update package { name } : { err } ' , fg = 'red' )
685
+ except KeyboardInterrupt :
686
+ exit_cli ('Operation canceled by user' , fg = 'red' )
687
+
392
688
@cli .command ()
393
689
@add_options (PACKAGE_COMMON_OPERATION_OPTIONS )
394
690
@add_options (PACKAGE_COMMON_INSTALL_OPTIONS )
0 commit comments