Skip to content

Commit 589d4b6

Browse files
committed
feat(oracle): add file upload and image creation functionality
1 parent 8cf94e8 commit 589d4b6

File tree

2 files changed

+223
-11
lines changed

2 files changed

+223
-11
lines changed

examples/oracle.py

+57-10
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
# This file is part of pycloudlib. See LICENSE file for license information.
33
"""Basic examples of various lifecycle with an OCI instance."""
44

5+
import argparse
56
import logging
6-
import sys
77
from base64 import b64encode
88

99
import pycloudlib
@@ -46,17 +46,64 @@ def demo(
4646
new_instance.wait()
4747
new_instance.execute("whoami")
4848

49+
def upload_and_create_image(
50+
local_file_path: str,
51+
image_name: str,
52+
suite: str,
53+
intermediary_storage_name: str,
54+
availability_domain: str = None,
55+
compartment_id: str = None,
56+
):
57+
"""Upload a local .img file and create an image from it on OCI."""
58+
with pycloudlib.OCI(
59+
"oracle-test",
60+
availability_domain=availability_domain,
61+
compartment_id=compartment_id,
62+
) as client:
63+
image_info = client.create_image_from_local_file(
64+
local_file_path=local_file_path,
65+
image_name=image_name,
66+
intermediary_storage_name=intermediary_storage_name,
67+
suite=suite,
68+
)
69+
print(f"Created image: {image_info}")
70+
4971

5072
if __name__ == "__main__":
73+
parser = argparse.ArgumentParser(description="OCI example script")
74+
subparsers = parser.add_subparsers(dest="command")
75+
76+
demo_parser = subparsers.add_parser("demo", help="Run the demo")
77+
demo_parser.add_argument("--availability-domain", type=str, help="Availability domain", required=False)
78+
demo_parser.add_argument("--compartment-id", type=str, help="Compartment ID", required=False)
79+
demo_parser.add_argument("--vcn-name", type=str, help="VCN name", required=False)
80+
81+
create_image_parser = subparsers.add_parser("create_image", help="Create an image from a local file")
82+
create_image_parser.add_argument("--local-file-path", type=str, required=True, help="Local file path")
83+
create_image_parser.add_argument("--image-name", type=str, required=True, help="Image name")
84+
create_image_parser.add_argument("--intermediary-storage-name", type=str, required=True, help="Intermediary storage name")
85+
create_image_parser.add_argument("--suite", type=str, help="Suite of the image. I.e. 'jammy'", required=True)
86+
create_image_parser.add_argument("--availability-domain", type=str, help="Availability domain")
87+
create_image_parser.add_argument("--compartment-id", type=str, help="Compartment ID")
88+
89+
args = parser.parse_args()
90+
5191
logging.basicConfig(level=logging.DEBUG)
52-
if len(sys.argv) != 3:
53-
print(
54-
"No arguments passed via command line. "
55-
"Assuming values are set in pycloudlib configuration file."
92+
93+
if args.command == "demo":
94+
demo(
95+
availability_domain=args.availability_domain,
96+
compartment_id=args.compartment_id,
97+
vcn_name=args.vcn_name,
98+
)
99+
elif args.command == "create_image":
100+
upload_and_create_image(
101+
local_file_path=args.local_file_path,
102+
image_name=args.image_name,
103+
suite=args.suite,
104+
intermediary_storage_name=args.intermediary_storage_name,
105+
availability_domain=args.availability_domain,
106+
compartment_id=args.compartment_id,
56107
)
57-
demo()
58108
else:
59-
passed_availability_domain = sys.argv[1]
60-
passed_compartment_id = sys.argv[2]
61-
passed_vcn_name = sys.argv[3] if len(sys.argv) == 4 else None
62-
demo(passed_availability_domain, passed_compartment_id, passed_vcn_name)
109+
parser.print_help()

pycloudlib/oci/cloud.py

+166-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import oci
1212

13-
from pycloudlib.cloud import BaseCloud
13+
from pycloudlib.cloud import BaseCloud, ImageInfo
1414
from pycloudlib.config import ConfigFile
1515
from pycloudlib.errors import (
1616
CloudSetupError,
@@ -367,3 +367,168 @@ def _validate_tag(tag: str):
367367

368368
if rules_failed:
369369
raise InvalidTagNameError(tag=tag, rules_failed=rules_failed)
370+
371+
# all actual "Clouds" and not just substrates like LXD and QEMU should support this method
372+
def upload_local_file_to_cloud_storage(
373+
self,
374+
*,
375+
local_file_path: str,
376+
storage_name: str,
377+
remote_file_name: Optional[str] = None,
378+
overwrite_existing: bool = False,
379+
) -> str:
380+
"""
381+
Upload a file to a storage destination on the Cloud.
382+
383+
Args:
384+
local_file_path: The local file path of the image to upload.
385+
storage_name: The name of the storage destination on the Cloud to upload the file to.
386+
remote_file_name: The name of the file in the storage destination. If not provided,
387+
the base name of the local file path will be used.
388+
389+
Returns:
390+
str: URL of the uploaded file in the storage destination.
391+
"""
392+
object_storage_client = oci.object_storage.ObjectStorageClient(self.oci_config)
393+
namespace = object_storage_client.get_namespace().data
394+
bucket_name = storage_name
395+
remote_file_name = remote_file_name or os.path.basename(local_file_path)
396+
397+
# if remote file name is missing the extension, add it from the local file path
398+
if not remote_file_name.endswith(ext:="."+local_file_path.split('.')[-1]):
399+
remote_file_name += ext
400+
401+
# check if object already exists in the bucket
402+
try:
403+
object_storage_client.get_object(
404+
namespace,
405+
bucket_name,
406+
remote_file_name
407+
)
408+
if overwrite_existing:
409+
self._log.warning(
410+
f"Object {remote_file_name} already exists in the bucket {bucket_name}. "
411+
"Overwriting it."
412+
)
413+
else:
414+
self._log.info(
415+
"Skipping upload as the object already exists in the bucket."
416+
)
417+
return f"https://objectstorage.{self.region}.oraclecloud.com/n/{namespace}/b/{bucket_name}/o/{remote_file_name}"
418+
except oci.exceptions.ServiceError as e:
419+
if e.status != 404:
420+
raise e
421+
422+
with open(local_file_path, 'rb') as file:
423+
object_storage_client.put_object(
424+
namespace,
425+
bucket_name,
426+
remote_file_name,
427+
file
428+
)
429+
430+
return f"https://objectstorage.{self.region}.oraclecloud.com/n/{namespace}/b/{bucket_name}/o/{remote_file_name}"
431+
432+
def create_image_from_local_file(
433+
self,
434+
*,
435+
local_file_path: str,
436+
image_name: str,
437+
intermediary_storage_name: str,
438+
suite: str,
439+
) -> ImageInfo:
440+
"""
441+
Upload local image file to storage on the Cloud and then create a custom image from it.
442+
443+
Args:
444+
local_file_path: The local file path of the image to upload.
445+
image_name: The name to upload the image as and to register.
446+
intermediary_storage_name: The intermediary storage destination on the Cloud to upload
447+
the file to before creating the image.
448+
suite: The suite of the image to create. I.e. "noble", "jammy", or "focal".
449+
450+
Returns:
451+
ImageInfo: Information about the created image.
452+
"""
453+
remote_file_url = self.upload_local_file_to_cloud_storage(
454+
local_file_path=local_file_path,
455+
storage_name=intermediary_storage_name,
456+
remote_file_name=image_name,
457+
)
458+
return self._create_image_from_cloud_storage(
459+
image_name=image_name,
460+
remote_image_file_url=remote_file_url,
461+
suite=suite,
462+
)
463+
464+
def parse_remote_object_url(self, remote_object_url: str) -> tuple[str, str, str]:
465+
"""
466+
Parse the remote object URL to extract the namespace, bucket name, and object name.
467+
468+
Args:
469+
remote_object_url: The URL of the object in the Cloud storage.
470+
471+
Returns:
472+
tuple[str, str, str]: The namespace, bucket name, and object name.
473+
"""
474+
if not remote_object_url.startswith("https://objectstorage"):
475+
raise ValueError("Invalid URL. Expected a URL from the Oracle Cloud object storage.")
476+
parts = remote_object_url.split("/")
477+
# find the "n/", "b/", and "o/" parts of the URL
478+
namespace_index = parts.index("n") + 1
479+
bucket_index = parts.index("b") + 1
480+
object_index = parts.index("o") + 1
481+
return parts[namespace_index], parts[bucket_index], parts[object_index]
482+
483+
def _create_image_from_cloud_storage(
484+
self,
485+
*,
486+
image_name: str,
487+
remote_image_file_url: str,
488+
suite: str,
489+
image_description: Optional[str] = None,
490+
) -> ImageInfo:
491+
"""
492+
Register a custom image in the Cloud from a file in Cloud storage using its url.
493+
494+
Ideally, this url would be returned from the upload_local_file_to_cloud_storage method.
495+
496+
Args:
497+
image_name: The name the image will be created with.
498+
remote_image_file_url: The URL of the image file in the Cloud storage.
499+
image_description: (Optional) A description of the image.
500+
"""
501+
suites = {
502+
"noble": "24.04",
503+
"jammy": "22.04",
504+
"focal": "20.04",
505+
}
506+
if suite not in suites:
507+
raise ValueError(f"Invalid suite. Expected one of {list(suites.keys())}. Found: {suite}")
508+
# parse object name and bucket name from the url
509+
object_namespace, bucket_name, object_name = self.parse_remote_object_url(remote_image_file_url)
510+
self._log.debug(f"Bucket name: {bucket_name}, Object name: {object_name}, Object namespace: {object_namespace}")
511+
image_details = oci.core.models.CreateImageDetails(
512+
compartment_id=self.compartment_id,
513+
display_name=image_name,
514+
image_source_details=oci.core.models.ImageSourceViaObjectStorageTupleDetails(
515+
source_type="objectStorageTuple",
516+
bucket_name=bucket_name,
517+
object_name=object_name,
518+
namespace_name=object_namespace,
519+
operating_system="Canonical Ubuntu",
520+
operating_system_version=suites[suite],
521+
),
522+
launch_mode="PARAVIRTUALIZED",
523+
freeform_tags={"Description": image_description} if image_description else None
524+
)
525+
526+
image_data = self.compute_client.create_image(image_details).data
527+
image_data = wait_till_ready(
528+
func=self.compute_client.get_image,
529+
current_data=image_data,
530+
desired_state="AVAILABLE",
531+
)
532+
533+
return ImageInfo(id=image_data.id, name=image_data.display_name)
534+

0 commit comments

Comments
 (0)