@@ -671,6 +671,181 @@ def update_vm_ports(self, vm_id: str, ports: List[str]) -> None:
671671 )
672672 print (f"Port mapping updated for VM { vm_id } " )
673673
674+ def update_vm (
675+ self ,
676+ vm_id : str ,
677+ vcpu : Optional [int ] = None ,
678+ memory : Optional [int ] = None ,
679+ disk_size : Optional [int ] = None ,
680+ image : Optional [str ] = None ,
681+ docker_compose_content : Optional [str ] = None ,
682+ prelaunch_script : Optional [str ] = None ,
683+ swap_size : Optional [int ] = None ,
684+ env_file : Optional [str ] = None ,
685+ user_config : Optional [str ] = None ,
686+ ports : Optional [List [str ]] = None ,
687+ no_ports : bool = False ,
688+ gpu_slots : Optional [List [str ]] = None ,
689+ attach_all : bool = False ,
690+ no_gpus : bool = False ,
691+ kms_urls : Optional [List [str ]] = None ,
692+ ) -> None :
693+ """Update multiple aspects of a VM in one command"""
694+ updates = []
695+
696+ # handle resize operations (vcpu, memory, disk, image)
697+ resize_params = {}
698+ if vcpu is not None :
699+ resize_params ["vcpu" ] = vcpu
700+ updates .append (f"vCPU: { vcpu } " )
701+ if memory is not None :
702+ resize_params ["memory" ] = memory
703+ updates .append (f"memory: { memory } MB" )
704+ if disk_size is not None :
705+ resize_params ["disk_size" ] = disk_size
706+ updates .append (f"disk: { disk_size } GB" )
707+ if image is not None :
708+ resize_params ["image" ] = image
709+ updates .append (f"image: { image } " )
710+
711+ if resize_params :
712+ resize_params ["id" ] = vm_id
713+ self .rpc_call ("ResizeVm" , resize_params )
714+
715+ # handle upgrade operations (compose, env, user_config, ports, gpu)
716+ upgrade_params = {"id" : vm_id }
717+
718+ # handle compose file updates (docker-compose, prelaunch script, swap)
719+ needs_compose_update = docker_compose_content or prelaunch_script is not None or swap_size is not None
720+ vm_info_response = None
721+
722+ if needs_compose_update or env_file :
723+ vm_info_response = self .rpc_call ('GetInfo' , {'id' : vm_id })
724+ if not vm_info_response .get ('found' , False ) or 'info' not in vm_info_response :
725+ raise Exception (f"VM with ID { vm_id } not found" )
726+
727+ if needs_compose_update :
728+ vm_configuration = vm_info_response ['info' ].get ('configuration' ) or {}
729+ compose_file_content = vm_configuration .get ('compose_file' )
730+
731+ try :
732+ app_compose = json .loads (compose_file_content ) if compose_file_content else {}
733+ except json .JSONDecodeError :
734+ app_compose = {}
735+
736+ if docker_compose_content :
737+ app_compose ['docker_compose_file' ] = docker_compose_content
738+ updates .append ("docker compose" )
739+
740+ if prelaunch_script is not None :
741+ script_stripped = prelaunch_script .strip ()
742+ if script_stripped :
743+ app_compose ['pre_launch_script' ] = script_stripped
744+ updates .append ("prelaunch script" )
745+ elif 'pre_launch_script' in app_compose :
746+ del app_compose ['pre_launch_script' ]
747+ updates .append ("prelaunch script (removed)" )
748+
749+ if swap_size is not None :
750+ swap_bytes = max (0 , int (round (swap_size )) * 1024 * 1024 )
751+ if swap_bytes > 0 :
752+ app_compose ['swap_size' ] = swap_bytes
753+ updates .append (f"swap: { swap_size } MB" )
754+ elif 'swap_size' in app_compose :
755+ del app_compose ['swap_size' ]
756+ updates .append ("swap (disabled)" )
757+
758+ upgrade_params ['compose_file' ] = json .dumps (app_compose , indent = 4 , ensure_ascii = False )
759+
760+ if env_file :
761+ envs = parse_env_file (env_file )
762+ if envs :
763+ app_id = vm_info_response ['info' ]['app_id' ]
764+ vm_configuration = vm_info_response ['info' ].get ('configuration' ) or {}
765+ compose_file_content = vm_configuration .get ('compose_file' )
766+
767+ encrypt_pubkey = self .get_app_env_encrypt_pub_key (
768+ app_id , kms_urls [0 ] if kms_urls else None )
769+ envs_list = [{"key" : k , "value" : v } for k , v in envs .items ()]
770+ upgrade_params ["encrypted_env" ] = encrypt_env (envs_list , encrypt_pubkey )
771+ updates .append ("environment variables" )
772+
773+ # update allowed_envs in compose file if needed
774+ if compose_file_content :
775+ try :
776+ app_compose = json .loads (compose_file_content )
777+ except json .JSONDecodeError :
778+ app_compose = {}
779+ compose_changed = False
780+ allowed_envs = list (envs .keys ())
781+ if app_compose .get ('allowed_envs' ) != allowed_envs :
782+ app_compose ['allowed_envs' ] = allowed_envs
783+ compose_changed = True
784+ launch_token_value = envs .get ('APP_LAUNCH_TOKEN' )
785+ if launch_token_value is not None :
786+ launch_token_hash = hashlib .sha256 (
787+ launch_token_value .encode ('utf-8' )
788+ ).hexdigest ()
789+ if app_compose .get ('launch_token_hash' ) != launch_token_hash :
790+ app_compose ['launch_token_hash' ] = launch_token_hash
791+ compose_changed = True
792+ if compose_changed :
793+ upgrade_params ['compose_file' ] = json .dumps (
794+ app_compose , indent = 4 , ensure_ascii = False )
795+
796+ if user_config :
797+ upgrade_params ["user_config" ] = user_config
798+ updates .append ("user config" )
799+
800+ # handle port updates - only update if --port or --no-ports is specified
801+ if no_ports or ports is not None :
802+ if no_ports :
803+ port_mappings = []
804+ updates .append ("port mappings (removed)" )
805+ elif ports :
806+ port_mappings = [parse_port_mapping (port ) for port in ports ]
807+ updates .append ("port mappings" )
808+ else :
809+ # ports is an empty list - shouldn't happen with mutually exclusive group
810+ port_mappings = []
811+ updates .append ("port mappings (none)" )
812+ upgrade_params ["update_ports" ] = True
813+ upgrade_params ["ports" ] = port_mappings
814+
815+ # handle GPU updates - only update if one of the GPU flags is set
816+ if attach_all or no_gpus or gpu_slots is not None :
817+ if attach_all :
818+ gpu_config = {"attach_mode" : "all" }
819+ updates .append ("GPUs (all)" )
820+ elif no_gpus :
821+ gpu_config = {
822+ "attach_mode" : "listed" ,
823+ "gpus" : []
824+ }
825+ updates .append ("GPUs (detached)" )
826+ elif gpu_slots :
827+ gpu_config = {
828+ "attach_mode" : "listed" ,
829+ "gpus" : [{"slot" : gpu } for gpu in gpu_slots ]
830+ }
831+ updates .append (f"GPUs ({ len (gpu_slots )} devices)" )
832+ else :
833+ # gpu_slots is an empty list ([] not None) - shouldn't happen with mutually exclusive group
834+ gpu_config = {
835+ "attach_mode" : "listed" ,
836+ "gpus" : []
837+ }
838+ updates .append ("GPUs (none)" )
839+ upgrade_params ["gpus" ] = gpu_config
840+
841+ if len (upgrade_params ) > 1 : # more than just the id
842+ self .rpc_call ("UpgradeApp" , upgrade_params )
843+
844+ if updates :
845+ print (f"Updated VM { vm_id } : { ', ' .join (updates )} " )
846+ else :
847+ print (f"No updates specified for VM { vm_id } " )
848+
674849 def list_gpus (self , json_output : bool = False ) -> None :
675850 """List all available GPUs"""
676851 response = self .rpc_call ('ListGpus' )
@@ -1102,6 +1277,80 @@ def main():
11021277 help = "Port mapping in format: protocol[:address]:from:to (can be used multiple times)" ,
11031278 )
11041279
1280+ # Update (all-in-one) command
1281+ update_parser = subparsers .add_parser (
1282+ "update" , help = "Update multiple aspects of a VM in one command"
1283+ )
1284+ update_parser .add_argument ("vm_id" , help = "VM ID to update" )
1285+
1286+ # Resource options (requires VM to be stopped)
1287+ update_parser .add_argument (
1288+ "--vcpu" , type = int , help = "Number of vCPUs"
1289+ )
1290+ update_parser .add_argument (
1291+ "--memory" , type = parse_memory_size , help = "Memory size (e.g. 1G, 100M)"
1292+ )
1293+ update_parser .add_argument (
1294+ "--disk" , type = parse_disk_size , help = "Disk size (e.g. 20G, 1T)"
1295+ )
1296+ update_parser .add_argument (
1297+ "--image" , type = str , help = "Image name"
1298+ )
1299+
1300+ # Application options
1301+ update_parser .add_argument (
1302+ "--compose" , help = "Path to app-compose.json file"
1303+ )
1304+ update_parser .add_argument (
1305+ "--prelaunch-script" , help = "Path to pre-launch script file"
1306+ )
1307+ update_parser .add_argument (
1308+ "--env-file" , help = "File with environment variables to encrypt"
1309+ )
1310+ update_parser .add_argument (
1311+ "--user-config" , help = "Path to user config file"
1312+ )
1313+ # Port mapping options (mutually exclusive with --no-ports)
1314+ port_group = update_parser .add_mutually_exclusive_group ()
1315+ port_group .add_argument (
1316+ "--port" ,
1317+ action = "append" ,
1318+ type = str ,
1319+ help = "Port mapping in format: protocol[:address]:from:to (can be used multiple times)" ,
1320+ )
1321+ port_group .add_argument (
1322+ "--no-ports" ,
1323+ action = "store_true" ,
1324+ help = "Remove all port mappings from the VM" ,
1325+ )
1326+ update_parser .add_argument (
1327+ "--swap" , type = parse_memory_size , help = "Swap size (e.g. 4G). Set to 0 to disable"
1328+ )
1329+
1330+ # GPU options (mutually exclusive)
1331+ gpu_group = update_parser .add_mutually_exclusive_group ()
1332+ gpu_group .add_argument (
1333+ "--gpu" ,
1334+ action = "append" ,
1335+ type = str ,
1336+ help = "GPU slot to attach (can be used multiple times)" ,
1337+ )
1338+ gpu_group .add_argument (
1339+ "--ppcie" ,
1340+ action = "store_true" ,
1341+ help = "Enable PPCIE (Protected PCIe) mode - attach all available GPUs" ,
1342+ )
1343+ gpu_group .add_argument (
1344+ "--no-gpus" ,
1345+ action = "store_true" ,
1346+ help = "Detach all GPUs from the VM" ,
1347+ )
1348+
1349+ # KMS URL for environment encryption
1350+ update_parser .add_argument (
1351+ "--kms-url" , action = "append" , type = str , help = "KMS URL"
1352+ )
1353+
11051354 args = parser .parse_args ()
11061355
11071356 cli = VmmCLI (args .url , args .auth_user , args .auth_password )
@@ -1142,6 +1391,34 @@ def main():
11421391 cli .update_vm_app_compose (args .vm_id , open (args .compose , 'r' ).read ())
11431392 elif args .command == "update-ports" :
11441393 cli .update_vm_ports (args .vm_id , args .port )
1394+ elif args .command == "update" :
1395+ compose_content = None
1396+ if args .compose :
1397+ compose_content = read_utf8 (args .compose )
1398+ prelaunch_content = None
1399+ if hasattr (args , 'prelaunch_script' ) and args .prelaunch_script :
1400+ prelaunch_content = read_utf8 (args .prelaunch_script )
1401+ user_config_content = None
1402+ if args .user_config :
1403+ user_config_content = read_utf8 (args .user_config )
1404+ cli .update_vm (
1405+ args .vm_id ,
1406+ vcpu = args .vcpu ,
1407+ memory = args .memory ,
1408+ disk_size = args .disk ,
1409+ image = args .image ,
1410+ docker_compose_content = compose_content ,
1411+ prelaunch_script = prelaunch_content ,
1412+ swap_size = args .swap if hasattr (args , 'swap' ) else None ,
1413+ env_file = args .env_file ,
1414+ user_config = user_config_content ,
1415+ ports = args .port ,
1416+ no_ports = args .no_ports if hasattr (args , 'no_ports' ) else False ,
1417+ gpu_slots = args .gpu ,
1418+ attach_all = args .ppcie ,
1419+ no_gpus = args .no_gpus if hasattr (args , 'no_gpus' ) else False ,
1420+ kms_urls = args .kms_url ,
1421+ )
11451422 elif args .command == 'kms' :
11461423 if not args .kms_action :
11471424 kms_parser .print_help ()
0 commit comments