Skip to content

Commit ebf186a

Browse files
committed
ThirdPartyContainerManagement_in_SonicPackageManager
ThirdPartyContainerManagement(TPCM) support in SonicPackageManager allows third party dockers to be installed on the sonic system. The Manifest file is generated from a local default file. The Manifest file could be updated through "sonic-package-manager manifests update" command and later the running package could be updated with the new manifest file through "sonic-package-manager update"
1 parent 02a588b commit ebf186a

File tree

11 files changed

+816
-51
lines changed

11 files changed

+816
-51
lines changed

sonic_package_manager/main.py

+302-6
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,20 @@
99
import click
1010
import click_log
1111
import tabulate
12+
from urllib.parse import urlparse
13+
import paramiko
14+
import requests
15+
import getpass
16+
import shutil
1217
from natsort import natsorted
1318

1419
from sonic_package_manager.database import PackageEntry, PackageDatabase
1520
from sonic_package_manager.errors import PackageManagerError
1621
from sonic_package_manager.logger import log
1722
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"
1826

1927
BULLET_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
162174
def 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

Comments
 (0)