Skip to content

Commit a8405ca

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 62fcd77 commit a8405ca

7 files changed

Lines changed: 602 additions & 49 deletions

File tree

sonic_package_manager/main.py

Lines changed: 300 additions & 6 deletions
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,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

Comments
 (0)