Skip to content

Commit 34f75f2

Browse files
authored
Merge pull request #392 from Dstack-TEE/vmm-cli-update-vm
vmm-cli: Add subcommand update
2 parents 47f630f + d39cd28 commit 34f75f2

File tree

2 files changed

+350
-0
lines changed

2 files changed

+350
-0
lines changed

docs/vmm-cli-user-guide.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,79 @@ The whitelist is stored in `~/.dstack-vmm/kms-whitelist.json`.
503503
./vmm-cli.py update-user-config <vm-id> ./new-config.json
504504
```
505505

506+
#### Update Port Mapping
507+
508+
Update port mappings for an existing VM:
509+
510+
```bash
511+
./vmm-cli.py update-ports <vm-id> --port tcp:8080:80 --port tcp:8443:443
512+
```
513+
514+
#### Update Multiple Aspects at Once
515+
516+
Use the all-in-one `update` command to update multiple VM aspects in a single operation:
517+
518+
```bash
519+
# Update resources (requires VM to be stopped)
520+
./vmm-cli.py update <vm-id> \
521+
--vcpu 4 \
522+
--memory 8G \
523+
--disk 100G \
524+
--image "dstack-0.5.4"
525+
526+
# Update application configuration
527+
./vmm-cli.py update <vm-id> \
528+
--compose ./new-docker-compose.yml \
529+
--prelaunch-script ./setup.sh \
530+
--swap 4G \
531+
--env-file ./new-secrets.env \
532+
--user-config ./new-config.json
533+
534+
# Update networking and GPU
535+
./vmm-cli.py update <vm-id> \
536+
--port tcp:8080:80 \
537+
--port tcp:8443:443 \
538+
--gpu "18:00.0" --gpu "2a:00.0"
539+
540+
# Detach all GPUs from a VM
541+
./vmm-cli.py update <vm-id> --no-gpus
542+
543+
# Remove all port mappings from a VM
544+
./vmm-cli.py update <vm-id> --no-ports
545+
546+
# Update everything at once
547+
./vmm-cli.py update <vm-id> \
548+
--vcpu 8 \
549+
--memory 16G \
550+
--disk 200G \
551+
--compose ./new-docker-compose.yml \
552+
--prelaunch-script ./init.sh \
553+
--swap 8G \
554+
--env-file ./new-secrets.env \
555+
--port tcp:8080:80 \
556+
--ppcie
557+
```
558+
559+
**Available Options:**
560+
- **Resource changes** (requires VM to be stopped): `--vcpu`, `--memory`, `--disk`, `--image`
561+
- **Application updates**: `--compose` (docker-compose file), `--prelaunch-script`, `--swap`, `--env-file`, `--user-config`
562+
- **Networking** (mutually exclusive):
563+
- `--port <mapping>` (can be used multiple times)
564+
- `--no-ports` (remove all port mappings)
565+
- _No port flag: port configuration remains unchanged_
566+
- **GPU** (mutually exclusive):
567+
- `--gpu <slot>` (can be used multiple times for specific GPUs)
568+
- `--ppcie` (attach all available GPUs)
569+
- `--no-gpus` (detach all GPUs)
570+
- _No GPU flag: GPU configuration remains unchanged_
571+
- **KMS**: `--kms-url` (for environment encryption)
572+
573+
**Notes:**
574+
- Resource changes (vCPU, memory, disk, image) require the VM to be stopped
575+
- Application updates can be applied to running VMs
576+
- Port and GPU options are mutually exclusive within their groups
577+
- If no flag is specified for ports or GPUs, those configurations remain unchanged
578+
506579
### Performance Optimization
507580

508581
#### NUMA Pinning

vmm/src/vmm-cli.py

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)