99import click
1010import click_log
1111import tabulate
12+ from urllib .parse import urlparse
13+ import paramiko
14+ import requests
15+ import getpass
16+ import shutil
1217from natsort import natsorted
1318
1419from sonic_package_manager .database import PackageEntry , PackageDatabase
1520from sonic_package_manager .errors import PackageManagerError
1621from sonic_package_manager .logger import log
1722from 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"
1826
1927BULLET_UC = '\u2022 '
2028
@@ -97,11 +105,8 @@ def handle_parse_result(self, ctx, opts, args):
97105 cls = MutuallyExclusiveOption ,
98106 mutually_exclusive = ['from_tarball' , 'package_expr' ]),
99107 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' ,
105110 cls = MutuallyExclusiveOption ,
106111 mutually_exclusive = ['from_repository' , 'package_expr' ]),
107112 click .argument ('package-expr' ,
@@ -157,6 +162,13 @@ def repository(ctx):
157162 pass
158163
159164
165+ @cli .group ()
166+ @click .pass_context
167+ def manifests (ctx ):
168+ """ Custom local Manifest management commands. """
169+
170+ pass
171+
160172@cli .group ()
161173@click .pass_context
162174def show (ctx ):
@@ -215,6 +227,11 @@ def manifest(ctx,
215227 manager : PackageManager = ctx .obj
216228
217229 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+
218235 source = manager .get_package_source (package_expr ,
219236 from_repository ,
220237 from_tarball )
@@ -255,6 +272,11 @@ def changelog(ctx,
255272 manager : PackageManager = ctx .obj
256273
257274 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+
258280 source = manager .get_package_source (package_expr ,
259281 from_repository ,
260282 from_tarball )
@@ -280,6 +302,166 @@ def changelog(ctx,
280302 exit_cli (f'Failed to print package changelog: { err } ' , fg = 'red' )
281303
282304
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+
283465@repository .command ()
284466@click .argument ('name' , type = str )
285467@click .argument ('repository' , type = str )
@@ -316,6 +498,78 @@ def remove(ctx, name):
316498 exit_cli (f'Failed to remove repository { name } : { err } ' , fg = 'red' )
317499
318500
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+
319573@cli .command ()
320574@click .option ('--enable' ,
321575 is_flag = True ,
@@ -334,6 +588,13 @@ def remove(ctx, name):
334588 help = 'Allow package downgrade. By default an attempt to downgrade the package '
335589 'will result in a failure since downgrade might not be supported by the package, '
336590 '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' )
337598@add_options (PACKAGE_SOURCE_OPTIONS )
338599@add_options (PACKAGE_COMMON_OPERATION_OPTIONS )
339600@add_options (PACKAGE_COMMON_INSTALL_OPTIONS )
@@ -348,7 +609,9 @@ def install(ctx,
348609 enable ,
349610 set_owner ,
350611 skip_host_plugins ,
351- allow_downgrade ):
612+ allow_downgrade ,
613+ use_local_manifest ,
614+ name ):
352615 """ Install/Upgrade package using [PACKAGE_EXPR] in format "<name>[=<version>|@<reference>]".
353616
354617 The repository to pull the package from is resolved by lookup in package database,
@@ -378,17 +641,50 @@ def install(ctx,
378641 if allow_downgrade is not None :
379642 install_opts ['allow_downgrade' ] = allow_downgrade
380643
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+
381658 try :
382659 manager .install (package_expr ,
383660 from_repository ,
384661 from_tarball ,
662+ use_local_manifest ,
663+ name ,
385664 ** install_opts )
386665 except Exception as err :
387666 exit_cli (f'Failed to install { package_source } : { err } ' , fg = 'red' )
388667 except KeyboardInterrupt :
389668 exit_cli ('Operation canceled by user' , fg = 'red' )
390669
391670
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+
392688@cli .command ()
393689@add_options (PACKAGE_COMMON_OPERATION_OPTIONS )
394690@add_options (PACKAGE_COMMON_INSTALL_OPTIONS )
0 commit comments