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,163 @@ 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 file '{}' 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+ try :
440+ with open (mfile_name , 'r' ) as file :
441+ data = json .load (file )
442+ click .echo ("Manifest file: {}" .format (name ))
443+ click .echo (json .dumps (data , indent = 4 ))
444+ except FileNotFoundError :
445+ click .echo ("Manifest file '{}' not found." .format (name ))
446+
447+ @manifests .command ('list' )
448+ @click .pass_context
449+ @root_privileges_required
450+ def list_manifests (ctx ):
451+ """List all custom local manifest files."""
452+ # Get all files in the manifest location
453+ manifest_files = os .listdir (MANIFEST_LOCATION )
454+ if not manifest_files :
455+ click .echo ("No custom local manifest files found." )
456+ else :
457+ click .echo ("Custom Local Manifest files:" )
458+ for file in manifest_files :
459+ click .echo ("- {}" .format (file ))
460+
461+
283462@repository .command ()
284463@click .argument ('name' , type = str )
285464@click .argument ('repository' , type = str )
@@ -316,6 +495,79 @@ def remove(ctx, name):
316495 exit_cli (f'Failed to remove repository { name } : { err } ' , fg = 'red' )
317496
318497
498+ def parse_url (url ):
499+ # Parse information from URL
500+ parsed_url = urlparse (url )
501+ if parsed_url .scheme == "scp" or parsed_url .scheme == "sftp" :
502+ return parsed_url .username , parsed_url .password , parsed_url .hostname , parsed_url .path
503+ elif parsed_url .scheme == "http" :
504+ return None , None , parsed_url .netloc , parsed_url .path
505+ elif not parsed_url .scheme : # No scheme indicates a local file path
506+ return None , None , None , parsed_url .path
507+ else :
508+ raise ValueError ("Unsupported URL scheme" )
509+
510+ def validate_url_or_abort (url ):
511+ # Attempt to retrieve HTTP response code
512+ try :
513+ response = requests .head (url )
514+ response_code = response .status_code
515+ except requests .exceptions .RequestException as err :
516+ response_code = None
517+
518+ if not response_code :
519+ print ("Did not receive a response from remote machine. Aborting..." )
520+ return
521+ else :
522+ # Check for a 4xx response code which indicates a nonexistent URL
523+ if str (response_code ).startswith ('4' ):
524+ print ("Image file not found on remote machine. Aborting..." )
525+ return
526+
527+ def download_file (url , local_path ):
528+ # Parse information from the URL
529+ username , password , hostname , remote_path = parse_url (url )
530+ print ("Parsed URL: {} {} {} {}" .format (username , password , hostname , remote_path ))
531+
532+ if username is not None :
533+ # If password is not provided, prompt the user for it securely
534+ if password is None :
535+ password = getpass .getpass (prompt = f"Enter password for { username } @{ hostname } : " )
536+
537+ # Create an SSH client for SCP or SFTP
538+ client = paramiko .SSHClient ()
539+ # Automatically add the server's host key (this is insecure and should be handled differently in production)
540+ client .set_missing_host_key_policy (paramiko .AutoAddPolicy ())
541+
542+ try :
543+ # Connect to the SSH server
544+ client .connect (hostname , username = username , password = password )
545+
546+ # Open an SCP channel for SCP or an SFTP channel for SFTP
547+ with client .open_sftp () as sftp :
548+ # Download the file
549+ sftp .get (remote_path , local_path )
550+
551+ finally :
552+ # Close the SSH connection
553+ client .close ()
554+ elif hostname :
555+ # Download using HTTP for URLs without credentials
556+ validate_url_or_abort (url )
557+ try :
558+ response = requests .get (url )
559+ with open (local_path , 'wb' ) as f :
560+ f .write (response .content )
561+ except requests .exceptions .RequestException as e :
562+ print ("Download error" , e )
563+ return
564+ else :
565+ if os .path .exists (remote_path ):
566+ shutil .copy (remote_path , local_path )
567+ else :
568+ print (f"Error: Source file '{ remote_path } ' does not exist." )
569+
570+
319571@cli .command ()
320572@click .option ('--enable' ,
321573 is_flag = True ,
@@ -334,6 +586,13 @@ def remove(ctx, name):
334586 help = 'Allow package downgrade. By default an attempt to downgrade the package '
335587 'will result in a failure since downgrade might not be supported by the package, '
336588 'thus requires explicit request from the user.' )
589+ @click .option ('--use-local-manifest' ,
590+ is_flag = True ,
591+ default = None ,
592+ help = 'Use locally created custom manifest file ' )
593+ @click .option ('--name' ,
594+ type = str ,
595+ help = 'custom name for the package' )
337596@add_options (PACKAGE_SOURCE_OPTIONS )
338597@add_options (PACKAGE_COMMON_OPERATION_OPTIONS )
339598@add_options (PACKAGE_COMMON_INSTALL_OPTIONS )
@@ -348,7 +607,9 @@ def install(ctx,
348607 enable ,
349608 set_owner ,
350609 skip_host_plugins ,
351- allow_downgrade ):
610+ allow_downgrade ,
611+ use_local_manifest ,
612+ name ):
352613 """ Install/Upgrade package using [PACKAGE_EXPR] in format "<name>[=<version>|@<reference>]".
353614
354615 The repository to pull the package from is resolved by lookup in package database,
@@ -378,17 +639,50 @@ def install(ctx,
378639 if allow_downgrade is not None :
379640 install_opts ['allow_downgrade' ] = allow_downgrade
380641
642+ if use_local_manifest :
643+ if not name :
644+ click .echo (f'name argument is not provided to use local manifest' )
645+ return
646+ ORG_FILE = os .path .join (MANIFEST_LOCATION , name )
647+ if not os .path .exists (ORG_FILE ):
648+ click .echo (f'Local Manifest file for { name } does not exists to install' )
649+ return
650+
651+ if from_tarball :
652+ #Download the tar file from local/scp/sftp/http
653+ download_file (from_tarball , LOCAL_TARBALL_PATH )
654+ from_tarball = LOCAL_TARBALL_PATH
655+
381656 try :
382657 manager .install (package_expr ,
383658 from_repository ,
384659 from_tarball ,
660+ use_local_manifest ,
661+ name ,
385662 ** install_opts )
386663 except Exception as err :
387664 exit_cli (f'Failed to install { package_source } : { err } ' , fg = 'red' )
388665 except KeyboardInterrupt :
389666 exit_cli ('Operation canceled by user' , fg = 'red' )
390667
391668
669+ @cli .command ()
670+ @add_options (PACKAGE_COMMON_OPERATION_OPTIONS )
671+ @click .argument ('name' )
672+ @click .pass_context
673+ @root_privileges_required
674+ def update (ctx , name , force , yes ):
675+ """ Update package to the updated manifest file """
676+
677+ manager : PackageManager = ctx .obj
678+
679+ try :
680+ manager .update (name , force )
681+ except Exception as err :
682+ exit_cli (f'Failed to update package { name } : { err } ' , fg = 'red' )
683+ except KeyboardInterrupt :
684+ exit_cli ('Operation canceled by user' , fg = 'red' )
685+
392686@cli .command ()
393687@add_options (PACKAGE_COMMON_OPERATION_OPTIONS )
394688@add_options (PACKAGE_COMMON_INSTALL_OPTIONS )
0 commit comments