From 01fff39e4119144d4a5e26b7979e1168d694176f Mon Sep 17 00:00:00 2001 From: mariobassem Date: Thu, 23 Jan 2025 16:17:12 +0200 Subject: [PATCH] refactor: improve runpod client - Refactor RunPod client to use environment variables for API key. - Update RunPod example script to reflect changes. - Remove unused gql_builder.v file. - Update README.md to reflect changes. - Improve error handling and logging. - Use json2 for JSON encoding/decoding. - Update dependencies. - Implemented more endpoints for managing pods. Co-authored-by: mahmmoud.hassanein --- examples/develop/runpod/runpod_example.vsh | 113 +++--- lib/clients/runpod/client.v | 77 +++- lib/clients/runpod/gql_builder.v | 82 ---- lib/clients/runpod/readme.md | 10 + lib/clients/runpod/runpod_factory_.v | 128 +++---- lib/clients/runpod/runpod_http.v | 413 +++++++++++++++------ lib/clients/runpod/runpod_model.v | 59 +-- lib/clients/runpod/utils.v | 284 +++++--------- 8 files changed, 610 insertions(+), 556 deletions(-) delete mode 100644 lib/clients/runpod/gql_builder.v diff --git a/examples/develop/runpod/runpod_example.vsh b/examples/develop/runpod/runpod_example.vsh index 0e69de54..30ddfc62 100755 --- a/examples/develop/runpod/runpod_example.vsh +++ b/examples/develop/runpod/runpod_example.vsh @@ -2,24 +2,24 @@ // import freeflowuniverse.herolib.core.base import freeflowuniverse.herolib.clients.runpod +import json +import x.json2 -// Example 1: Create client with direct API key -mut rp := runpod.get( - name: 'example1' - api_key: 'rpa_P77PNL3UHJ2XP0EC3XKYCH8M8BZREVLY9U4VGK4E1p4j68' -)! +// Create client with direct API key +// This uses RUNPOD_API_KEY from environment +mut rp := runpod.get()! // Create a new on demand pod on_demand_pod_response := rp.create_on_demand_pod( name: 'RunPod Tensorflow' image_name: 'runpod/tensorflow' - cloud_type: .all + cloud_type: 'ALL' gpu_count: 1 volume_in_gb: 5 container_disk_in_gb: 5 min_memory_in_gb: 4 min_vcpu_count: 1 - gpu_type_id: 'NVIDIA RTX 2000 Ada' + gpu_type_id: 'NVIDIA RTX A4000' ports: '8888/http' volume_mount_path: '/workspace' env: [ @@ -32,51 +32,62 @@ on_demand_pod_response := rp.create_on_demand_pod( println('Created pod with ID: ${on_demand_pod_response.id}') -// // create a spot pod -// spot_pod_response := rp.create_spot_pod( -// port: 1826 -// bid_per_gpu: 0.2 -// cloud_type: .secure -// gpu_count: 1 -// volume_in_gb: 5 -// container_disk_in_gb: 5 -// min_vcpu_count: 1 -// min_memory_in_gb: 4 -// gpu_type_id: 'NVIDIA RTX 2000 Ada' -// name: 'RunPod Pytorch' -// image_name: 'runpod/pytorch' -// docker_args: '' -// ports: '8888/http' -// volume_mount_path: '/workspace' -// env: [ -// runpod.EnvironmentVariableInput{ -// key: 'JUPYTER_PASSWORD' -// value: 'rn51hunbpgtltcpac3ol' -// }, -// ] -// )! -// println('Created spot pod with ID: ${spot_pod_response.id}') +// create a spot pod +spot_pod_response := rp.create_spot_pod( + port: 1826 + bid_per_gpu: 0.2 + cloud_type: 'SECURE' + gpu_count: 1 + volume_in_gb: 5 + container_disk_in_gb: 5 + min_vcpu_count: 1 + min_memory_in_gb: 4 + gpu_type_id: 'NVIDIA RTX A4000' + name: 'RunPod Pytorch' + image_name: 'runpod/pytorch' + docker_args: '' + ports: '8888/http' + volume_mount_path: '/workspace' + env: [ + runpod.EnvironmentVariableInput{ + key: 'JUPYTER_PASSWORD' + value: 'rn51hunbpgtltcpac3ol' + }, + ] +)! +println('Created spot pod with ID: ${spot_pod_response.id}') -// // stop on-demand pod -// stop_on_demand_pod := rp.stop_pod( -// pod_id: '${on_demand_pod_response.id}' -// )! -// println('Stopped on-demand pod with ID: ${stop_on_demand_pod.id}') +// stop on-demand pod +stop_on_demand_pod := rp.stop_pod( + pod_id: '${on_demand_pod_response.id}' +)! +println('Stopped on-demand pod with ID: ${stop_on_demand_pod.id}') -// // stop spot pod -// stop_spot_pod := rp.stop_pod( -// pod_id: '${spot_pod_response.id}' -// )! -// println('Stopped spot pod with ID: ${stop_spot_pod.id}') +// stop spot pod +stop_spot_pod := rp.stop_pod( + pod_id: '${spot_pod_response.id}' +)! +println('Stopped spot pod with ID: ${stop_spot_pod.id}') -// // start on-demand pod -// start_on_demand_pod := rp.start_on_demand_pod(pod_id: '${on_demand_pod_response.id}', gpu_count: 1)! -// println('Started on demand pod with ID: ${start_on_demand_pod.id}') +// start on-demand pod +start_on_demand_pod := rp.start_on_demand_pod(pod_id: '${on_demand_pod_response.id}', gpu_count: 1)! +println('Started on demand pod with ID: ${on_demand_pod_response.id}') -// // start spot pod -// start_spot_pod := rp.start_spot_pod( -// pod_id: '${spot_pod_response.id}' -// gpu_count: 1 -// bid_per_gpu: 0.2 -// )! -// println('Started spot pod with ID: ${start_on_demand_pod.id}') +// start spot pod +start_spot_pod := rp.start_spot_pod( + pod_id: '${spot_pod_response.id}' + gpu_count: 1 + bid_per_gpu: 0.2 +)! +println('Started spot pod with ID: ${spot_pod_response.id}') + +get_pod := rp.get_pod( + pod_id: '${spot_pod_response.id}' +)! +println('Get pod result: ${get_pod}') + +rp.terminate_pod(pod_id: '${spot_pod_response.id}')! +println('pod with id ${spot_pod_response.id} is terminated') + +rp.terminate_pod(pod_id: '${on_demand_pod_response.id}')! +println('pod with id ${on_demand_pod_response.id} is terminated') diff --git a/lib/clients/runpod/client.v b/lib/clients/runpod/client.v index bfd95c9a..3c6b9723 100644 --- a/lib/clients/runpod/client.v +++ b/lib/clients/runpod/client.v @@ -1,5 +1,13 @@ module runpod +import json + +pub struct EnvironmentVariableInput { +pub mut: + key string + value string +} + // Represents the nested machine structure in the response pub struct Machine { pub: @@ -21,7 +29,7 @@ pub: @[params] pub struct PodFindAndDeployOnDemandRequest { pub mut: - cloud_type CloudType @[json: 'cloudType'] + cloud_type string @[json: 'cloudType'] gpu_count int @[json: 'gpuCount'] volume_in_gb int @[json: 'volumeInGb'] container_disk_in_gb int @[json: 'containerDiskInGb'] @@ -36,6 +44,10 @@ pub mut: env []EnvironmentVariableInput @[json: 'env'] } +pub fn (p PodFindAndDeployOnDemandRequest) json_str() string { + return json.encode(p) +} + // Create On-Demand Pod pub fn (mut rp RunPod) create_on_demand_pod(input PodFindAndDeployOnDemandRequest) !PodResult { return rp.create_on_demand_pod_request(input)! @@ -49,7 +61,7 @@ pub mut: start_jupyter bool @[json: 'startJupyter'] start_ssh bool @[json: 'startSsh'] bid_per_gpu f32 @[json: 'bidPerGpu'] - cloud_type CloudType @[json: 'cloudType'] + cloud_type string @[json: 'cloudType'] container_disk_in_gb int @[json: 'containerDiskInGb'] country_code string @[json: 'countryCode'; omitempty] docker_args string @[json: 'dockerArgs'; omitempty] @@ -76,37 +88,82 @@ pub mut: allowed_cuda_versions []string @[json: 'allowedCudaVersions'] } +pub fn (p PodRentInterruptableInput) json_str() string { + return json.encode(p) +} + // Create Spot Pod pub fn (mut rp RunPod) create_spot_pod(input PodRentInterruptableInput) !PodResult { return rp.create_spot_pod_request(input)! } @[params] -pub struct PodResume { +pub struct PodResumeInput { pub mut: - pod_id string @[json: 'podId'] - gpu_count int @[json: 'gpuCount'] + pod_id string @[json: 'podId'; required] + gpu_count int @[json: 'gpuCount'] + sync_machine bool @[json: 'syncMachine'] + compute_type string @[json: 'computeType'; omitempty] +} + +pub fn (p PodResumeInput) json_str() string { + return json.encode(p) } // Start On-Demand Pod -pub fn (mut rp RunPod) start_on_demand_pod(input PodResume) !PodResult { +pub fn (mut rp RunPod) start_on_demand_pod(input PodResumeInput) !PodResult { return rp.start_on_demand_pod_request(input)! } @[params] -pub struct PodBidResume { +pub struct PodBidResumeInput { pub mut: - pod_id string @[json: 'podId'] + pod_id string @[json: 'podId'; required] gpu_count int @[json: 'gpuCount'] bid_per_gpu f32 @[json: 'bidPerGpu'] } +pub fn (p PodBidResumeInput) json_str() string { + return json.encode(p) +} + // Start Spot Pod -pub fn (mut rp RunPod) start_spot_pod(input PodBidResume) !PodResult { +pub fn (mut rp RunPod) start_spot_pod(input PodBidResumeInput) !PodResult { return rp.start_spot_pod_request(input)! } +@[params] +pub struct PodStopInput { +pub: + pod_id string @[json: 'podId'] + increment_version bool @[json: 'incrementVersion'] +} + +pub fn (p PodStopInput) json_str() string { + return json.encode(p) +} + // Stop Pod -pub fn (mut rp RunPod) stop_pod(input PodResume) !PodResult { +pub fn (mut rp RunPod) stop_pod(input PodStopInput) !PodResult { return rp.stop_pod_request(input)! } + +@[params] +pub struct PodTerminateInput { +pub: + pod_id string @[json: 'podId'] +} + +pub fn (mut rp RunPod) terminate_pod(input PodTerminateInput) ! { + rp.terminate_pod_request(input)! +} + +@[params] +pub struct PodFilter { +pub: + pod_id string @[json: 'podId'; required] +} + +pub fn (mut rp RunPod) get_pod(input PodFilter) !PodResult { + return rp.get_pod_request(input)! +} diff --git a/lib/clients/runpod/gql_builder.v b/lib/clients/runpod/gql_builder.v deleted file mode 100644 index 11567daf..00000000 --- a/lib/clients/runpod/gql_builder.v +++ /dev/null @@ -1,82 +0,0 @@ -module runpod - -enum OperationType { - query - mutation -} - -struct QueryBuilder { -pub mut: - operation OperationType - fields []Field - variables map[string]string -} - -struct Field { - name string - arguments map[string]string - sub_fields []Field -} - -fn new_field(name string, args map[string]string, sub_fields []Field) Field { - return Field{ - name: name - arguments: args - sub_fields: sub_fields - } -} - -fn build_arguments(args map[string]string) string { - if args.len == 0 { - return '' - } - - mut sb := '' - sb += '(' - - for key, value in args { - if value.len == 0 { - continue - } - - sb += '${key}: ${value}, ' - } - - return sb.trim_right(', ') + ')' -} - -fn build_fields(fields []Field) string { - mut sb := ' { ' - for field in fields { - sb += field.name - if field.arguments.len > 0 { - sb += build_arguments(field.arguments) - } - - if field.sub_fields.len > 0 { - sb += build_fields(field.sub_fields) - } - - sb += ' ' - } - sb += ' } ' - return sb -} - -fn (mut q QueryBuilder) add_operation(operation OperationType, fields []Field, variables map[string]string) { - q.operation = operation - q.fields = fields - q.variables = variables.clone() -} - -fn (q QueryBuilder) build_query() string { - mut sb := '' - sb += '${q.operation}' + ' myOperation' - - if q.variables.len > 0 { - sb += build_arguments(q.variables) - } - - sb += build_fields(q.fields) - return sb -} diff --git a/lib/clients/runpod/readme.md b/lib/clients/runpod/readme.md index 9fa0bd36..a8f0e7fb 100644 --- a/lib/clients/runpod/readme.md +++ b/lib/clients/runpod/readme.md @@ -29,4 +29,14 @@ client... port: 8888 ``` +**RunPod API Example** +This script demonstrates creating, stopping, starting, and terminating RunPod pods using the RunPod API. It creates both on-demand and spot pods. + +**Requirements** + +* Environment variable `RUNPOD_API_KEY` set with your RunPod API key + +**How to Run** + +- Find out our example in: examples/develop/runpod/runpod_example.vsh diff --git a/lib/clients/runpod/runpod_factory_.v b/lib/clients/runpod/runpod_factory_.v index 37f62062..d46a6d37 100644 --- a/lib/clients/runpod/runpod_factory_.v +++ b/lib/clients/runpod/runpod_factory_.v @@ -2,6 +2,7 @@ module runpod import freeflowuniverse.herolib.core.base import freeflowuniverse.herolib.core.playbook +import freeflowuniverse.herolib.ui.console __global ( runpod_global map[string]&RunPod @@ -10,113 +11,88 @@ __global ( /////////FACTORY -// ArgsGet represents the arguments for getting a RunPod instance @[params] pub struct ArgsGet { pub mut: - name string = 'default' // Name of the RunPod configuration - api_key string // RunPod API key + name string } -// The get method gets an existing RunPod instance or creates a new one -pub fn get(args_ ArgsGet) !&RunPod { +fn args_get(args_ ArgsGet) ArgsGet { mut args := args_ - if args.name == '' { - if runpod_default != '' { - args.name = runpod_default - } else { - args.name = 'default' + args.name = runpod_default + } + if args.name == '' { + args.name = 'default' + } + return args +} + +pub fn get(args_ ArgsGet) !&RunPod { + mut args := args_get(args_) + if args.name !in runpod_global { + if args.name == 'default' { + if !config_exists(args) { + if default { + config_save(args)! + } + } + config_load(args)! } } - - // Return existing instance if available - if args.name in runpod_global { - return runpod_global[args.name] + return runpod_global[args.name] or { + println(runpod_global) + panic('could not get config for runpod with name:${args.name}') } +} - // Load from config if exists +fn config_exists(args_ ArgsGet) bool { + mut args := args_get(args_) + mut context := base.context() or { panic('bug') } + return context.hero_config_exists('runpod', args.name) +} + +fn config_load(args_ ArgsGet) ! { + mut args := args_get(args_) mut context := base.context()! - if context.hero_config_exists('runpod', args.name) { - mut heroscript := context.hero_config_get('runpod', args.name)! - play(heroscript: heroscript)! - return runpod_global[args.name] or { return error('Failed to load RunPod config') } - } - - // Create new instance if API key provided - if args.api_key != '' { - mut rp := new(args.api_key)! - rp.name = args.name - runpod_global[args.name] = rp - return rp - } - - return error('RunPod API key is required for new instances') + mut heroscript := context.hero_config_get('runpod', args.name)! + play(heroscript: heroscript)! } -// save_config saves the RunPod configuration -fn save_config(name string, api_key string) ! { +fn config_save(args_ ArgsGet) ! { + mut args := args_get(args_) mut context := base.context()! - heroscript := " - !!runpod.configure - name:'${name}' - api_key:'${api_key}' - " - context.hero_config_set('runpod', name, heroscript)! + context.hero_config_set('runpod', args.name, heroscript_default()!)! } -// set stores a RunPod instance in the global map -fn set(rp &RunPod) ! { - if rp.api_key == '' { - return error('RunPod API key is required') - } - runpod_global[rp.name] = rp - save_config(rp.name, rp.api_key)! +fn set(o RunPod) ! { + mut o2 := obj_init(o)! + runpod_global[o.name] = &o2 + runpod_default = o.name } -// PlayArgs represents arguments for playing a RunPod configuration @[params] pub struct PlayArgs { pub mut: - name string = 'default' - heroscript string // Heroscript configuration + heroscript string // if filled in then plbook will be made out of it plbook ?playbook.PlayBook - api_key string // RunPod API key + reset bool } -// play processes a RunPod configuration pub fn play(args_ PlayArgs) ! { mut args := args_ - if args.heroscript == '' && args.api_key == '' { - return error('Either heroscript or API key is required') + if args.heroscript == '' { + args.heroscript = heroscript_default()! } - - // If API key provided directly, create configuration - if args.api_key != '' { - save_config(args.name, args.api_key)! - mut rp := new(args.api_key)! - rp.name = args.name - set(rp)! - return - } - - // Process heroscript configuration mut plbook := args.plbook or { playbook.new(text: args.heroscript)! } - mut actions := plbook.find(filter: 'runpod.configure')! - if actions.len == 0 { - return error('No RunPod configuration found in heroscript') - } - - for action in actions { - mut params := action.params - mut name := params.get_default('name', 'default')! - mut api_key := params.get('api_key')! - - mut rp := new(api_key)! - rp.name = name - set(rp)! + mut install_actions := plbook.find(filter: 'runpod.configure')! + if install_actions.len > 0 { + for install_action in install_actions { + mut p := install_action.params + cfg_play(p)! + } } } diff --git a/lib/clients/runpod/runpod_http.v b/lib/clients/runpod/runpod_http.v index d82eba3d..ac221765 100644 --- a/lib/clients/runpod/runpod_http.v +++ b/lib/clients/runpod/runpod_http.v @@ -1,33 +1,15 @@ module runpod import x.json2 -import json +import net.http { Method } +import freeflowuniverse.herolib.core.httpconnection -// fn main() { -// mut fields := []Field{} -// fields << new_field('gpuTypes', { -// 'id': '"NVIDIA GeForce RTX 3090"' -// }, [ -// new_field('displayName', {}, []), -// new_field('d', {}, []), -// new_field('communmemoryInGb', {}, []), -// new_field('secureClouityCloud', {}, []), -// new_field('lowestPrice', { -// 'gpuCount': '1' -// }, [ -// new_field('minimumBidPrice', {}, []), -// new_field('uninterruptablePrice', {}, []), -// ]), -// ]) - -// // Create Query Builder -// mut builder := QueryBuilder{} -// builder.add_operation(.query, fields, {}) - -// // Build and print the query -// query := builder.build_query() -// println('query: ${query}') -// } +// GraphQL response wrapper +struct GqlResponse[T] { +pub mut: + data map[string]T + errors []map[string]string +} // #### Internally method doing a network call to create a new on-demand pod. // - Build the required query based pn the input sent by the user and send the request. @@ -38,50 +20,39 @@ fn (mut rp RunPod) create_on_demand_pod_request(input PodFindAndDeployOnDemandRe mut fields := []Field{} mut machine_fields := []Field{} mut output_fields := []Field{} - mut arguments := map[string]string{} mut builder := QueryBuilder{} - // $for field in input.fields { - // // TODO: Handle option fields - // // TODO: Handle the skip chars \ + machine_fields << new_field(name: 'podHostId') + output_fields << new_field(name: 'id') + output_fields << new_field(name: 'imageName') + output_fields << new_field(name: 'env') + output_fields << new_field(name: 'machineId') + output_fields << new_field(name: 'desiredStatus') + output_fields << new_field(name: 'machine', sub_fields: machine_fields) + fields << new_field( + name: 'podFindAndDeployOnDemand' + arguments: { + 'input': '\$arguments' + } + sub_fields: output_fields + ) - // item := input.$(field.name) - // arguments[get_field_name(field)] = '${item}' - // } + builder.add_operation( + operation: .mutation + fields: fields + variables: { + '\$arguments': 'PodFindAndDeployOnDemandInput' + } + ) + mut variables := { + 'arguments': json2.Any(type_to_map(input)!) + } + query := builder.build_query(variables: variables) - machine_fields << new_field('podHostId', {}, []) - output_fields << new_field('id', {}, []) - output_fields << new_field('imageName', {}, []) - output_fields << new_field('env', {}, []) - output_fields << new_field('machineId', {}, []) - output_fields << new_field('desiredStatus', {}, []) - output_fields << new_field('machine', {}, machine_fields) - fields << new_field('podFindAndDeployOnDemand', { - 'input': '\$arguments' - }, output_fields) - - builder.add_operation(.mutation, fields, { - '\$arguments': 'PodFindAndDeployOnDemandInput' - }) - - query := builder.build_query() - encoded_input := json.encode(input) - decoded_input := json2.raw_decode(encoded_input)!.as_map() - mut q_map := map[string]json2.Any{} - mut variables := map[string]json2.Any{} - - variables['arguments'] = decoded_input - q_map['query'] = json2.Any(query) - q_map['variables'] = json2.Any(variables) - - q := json2.encode(q_map) - - println('query: ${q}') - response := rp.make_request[GqlResponse[PodResult]](.post, '/graphql', q)! + response := rp.make_request[GqlResponse[PodResult]](.post, '/graphql', query)! return response.data['podFindAndDeployOnDemand'] or { return error('Could not find "podFindAndDeployOnDemand" in response data: ${response.data}') } - // return PodResult{} } // #### Internally method doing a network call to create a new spot pod. @@ -90,17 +61,42 @@ fn (mut rp RunPod) create_on_demand_pod_request(input PodFindAndDeployOnDemandRe // - The data field should contains the pod details same as `PodResult` struct. // - The error field should contain the error message. fn (mut rp RunPod) create_spot_pod_request(input PodRentInterruptableInput) !PodResult { - // gql := build_query( - // query_type: .mutation - // method_name: 'podRentInterruptable' - // request_model: input - // response_model: PodResult{} - // ) - // response := rp.make_request[GqlResponse[PodResult]](.post, '/graphql', gql)! - // return response.data['podRentInterruptable'] or { - // return error('Could not find "podRentInterruptable" in response data: ${response.data}') - // } - return PodResult{} + mut fields := []Field{} + mut machine_fields := []Field{} + mut output_fields := []Field{} + mut builder := QueryBuilder{} + + machine_fields << new_field(name: 'podHostId') + output_fields << new_field(name: 'id') + output_fields << new_field(name: 'imageName') + output_fields << new_field(name: 'env') + output_fields << new_field(name: 'machineId') + output_fields << new_field(name: 'desiredStatus') + output_fields << new_field(name: 'machine', sub_fields: machine_fields) + fields << new_field( + name: 'podRentInterruptable' + arguments: { + 'input': '\$arguments' + } + sub_fields: output_fields + ) + + builder.add_operation( + operation: .mutation + fields: fields + variables: { + '\$arguments': 'PodRentInterruptableInput!' + } + ) + mut variables := { + 'arguments': json2.Any(type_to_map(input)!) + } + query := builder.build_query(variables: variables) + + response := rp.make_request[GqlResponse[PodResult]](.post, '/graphql', query)! + return response.data['podRentInterruptable'] or { + return error('Could not find "podRentInterruptable" in response data: ${response.data}') + } } // #### Internally method doing a network call to start on demand pod. @@ -108,18 +104,43 @@ fn (mut rp RunPod) create_spot_pod_request(input PodRentInterruptableInput) !Pod // - Decode the response received from the API into two objects `Data` and `Error`. // - The data field should contains the pod details same as `PodResult` struct. // - The error field should contain the error message. -fn (mut rp RunPod) start_on_demand_pod_request(input PodResume) !PodResult { - // gql := build_query( - // query_type: .mutation - // method_name: 'podResume' - // request_model: input - // response_model: PodResult{} - // ) - // response := rp.make_request[GqlResponse[PodResult]](.post, '/graphql', gql)! - // return response.data['podResume'] or { - // return error('Could not find "podResume" in response data: ${response.data}') - // } - return PodResult{} +fn (mut rp RunPod) start_on_demand_pod_request(input PodResumeInput) !PodResult { + mut fields := []Field{} + mut machine_fields := []Field{} + mut output_fields := []Field{} + mut builder := QueryBuilder{} + + machine_fields << new_field(name: 'podHostId') + output_fields << new_field(name: 'id') + output_fields << new_field(name: 'imageName') + output_fields << new_field(name: 'env') + output_fields << new_field(name: 'machineId') + output_fields << new_field(name: 'desiredStatus') + output_fields << new_field(name: 'machine', sub_fields: machine_fields) + fields << new_field( + name: 'podResume' + arguments: { + 'input': '\$arguments' + } + sub_fields: output_fields + ) + + builder.add_operation( + operation: .mutation + fields: fields + variables: { + '\$arguments': 'PodResumeInput!' + } + ) + mut variables := { + 'arguments': json2.Any(type_to_map(input)!) + } + query := builder.build_query(variables: variables) + + response := rp.make_request[GqlResponse[PodResult]](.post, '/graphql', query)! + return response.data['podResume'] or { + return error('Could not find "podResume" in response data: ${response.data}') + } } // #### Internally method doing a network call to start spot pod. @@ -127,18 +148,43 @@ fn (mut rp RunPod) start_on_demand_pod_request(input PodResume) !PodResult { // - Decode the response received from the API into two objects `Data` and `Error`. // - The data field should contains the pod details same as `PodResult` struct. // - The error field should contain the error message. -fn (mut rp RunPod) start_spot_pod_request(input PodBidResume) !PodResult { - // gql := build_query( - // query_type: .mutation - // method_name: 'podBidResume' - // request_model: input - // response_model: PodResult{} - // ) - // response := rp.make_request[GqlResponse[PodResult]](.post, '/graphql', gql)! - // return response.data['podBidResume'] or { - // return error('Could not find "podBidResume" in response data: ${response.data}') - // } - return PodResult{} +fn (mut rp RunPod) start_spot_pod_request(input PodBidResumeInput) !PodResult { + mut fields := []Field{} + mut machine_fields := []Field{} + mut output_fields := []Field{} + mut builder := QueryBuilder{} + + machine_fields << new_field(name: 'podHostId') + output_fields << new_field(name: 'id') + output_fields << new_field(name: 'imageName') + output_fields << new_field(name: 'env') + output_fields << new_field(name: 'machineId') + output_fields << new_field(name: 'desiredStatus') + output_fields << new_field(name: 'machine', sub_fields: machine_fields) + fields << new_field( + name: 'podBidResume' + arguments: { + 'input': '\$arguments' + } + sub_fields: output_fields + ) + + builder.add_operation( + operation: .mutation + fields: fields + variables: { + '\$arguments': 'PodBidResumeInput!' + } + ) + mut variables := { + 'arguments': json2.Any(type_to_map(input)!) + } + query := builder.build_query(variables: variables) + + response := rp.make_request[GqlResponse[PodResult]](.post, '/graphql', query)! + return response.data['podBidResume'] or { + return error('Could not find "podBidResume" in response data: ${response.data}') + } } // #### Internally method doing a network call to stop a pod. @@ -146,16 +192,163 @@ fn (mut rp RunPod) start_spot_pod_request(input PodBidResume) !PodResult { // - Decode the response received from the API into two objects `Data` and `Error`. // - The data field should contains the pod details same as `PodResult` struct. // - The error field should contain the error message. -fn (mut rp RunPod) stop_pod_request(input PodResume) !PodResult { - // gql := build_query( - // query_type: .mutation - // method_name: 'podStop' - // request_model: input - // response_model: PodResult{} - // ) - // response := rp.make_request[GqlResponse[PodResult]](.post, '/graphql', gql)! - // return response.data['podStop'] or { - // return error('Could not find "podStop" in response data: ${response.data}') - // } - return PodResult{} +fn (mut rp RunPod) stop_pod_request(input PodStopInput) !PodResult { + mut fields := []Field{} + mut machine_fields := []Field{} + mut output_fields := []Field{} + mut builder := QueryBuilder{} + + machine_fields << new_field(name: 'podHostId') + output_fields << new_field(name: 'id') + output_fields << new_field(name: 'imageName') + output_fields << new_field(name: 'env') + output_fields << new_field(name: 'machineId') + output_fields << new_field(name: 'desiredStatus') + output_fields << new_field(name: 'machine', sub_fields: machine_fields) + fields << new_field( + name: 'podStop' + arguments: { + 'input': '\$arguments' + } + sub_fields: output_fields + ) + + builder.add_operation( + operation: .mutation + fields: fields + variables: { + '\$arguments': 'PodStopInput!' + } + ) + mut variables := { + 'arguments': json2.Any(type_to_map(input)!) + } + query := builder.build_query(variables: variables) + + response := rp.make_request[GqlResponse[PodResult]](.post, '/graphql', query)! + return response.data['podStop'] or { + return error('Could not find "podStop" in response data: ${response.data}') + } +} + +fn (mut rp RunPod) terminate_pod_request(input PodTerminateInput) ! { + mut fields := []Field{} + mut builder := QueryBuilder{} + + fields << new_field( + name: 'podTerminate' + arguments: { + 'input': '\$arguments' + } + ) + + builder.add_operation( + operation: .mutation + fields: fields + variables: { + '\$arguments': 'PodTerminateInput!' + } + ) + mut variables := { + 'arguments': json2.Any(type_to_map(input)!) + } + query := builder.build_query(variables: variables) + + response := rp.make_request[GqlResponse[PodResult]](.post, '/graphql', query)! + _ := response.data['podTerminate'] or { + return error('Could not find "podTerminate" in response data: ${response.data}') + } +} + +fn (mut rp RunPod) get_pod_request(input PodFilter) !PodResult { + mut fields := []Field{} + mut machine_fields := []Field{} + mut output_fields := []Field{} + mut builder := QueryBuilder{} + + machine_fields << new_field(name: 'podHostId') + output_fields << new_field(name: 'id') + output_fields << new_field(name: 'imageName') + output_fields << new_field(name: 'env') + output_fields << new_field(name: 'machineId') + output_fields << new_field(name: 'desiredStatus') + output_fields << new_field(name: 'machine', sub_fields: machine_fields) + fields << new_field( + name: 'pod' + arguments: { + 'input': '\$arguments' + } + sub_fields: output_fields + ) + + builder.add_operation( + operation: .query + fields: fields + variables: { + '\$arguments': 'PodFilter' + } + ) + mut variables := { + 'arguments': json2.Any(type_to_map(input)!) + } + query := builder.build_query(variables: variables) + + response := rp.make_request[GqlResponse[PodResult]](.post, '/graphql', query)! + return response.data['pod'] or { + return error('Could not find "pod" in response data: ${response.data}') + } +} + +// Represents the main structure for interacting with the RunPod API. +// Provides utilities to manage HTTP connections and perform GraphQL queries. +fn (mut rp RunPod) httpclient() !&httpconnection.HTTPConnection { + mut http_conn := httpconnection.new( + name: 'runpod_vclient_${rp.name}' + url: rp.base_url + cache: true + retry: 3 + )! + http_conn.default_header.add(.authorization, 'Bearer ${rp.api_key}') + return http_conn +} + +// Sends an HTTP request to the RunPod API with the specified method, path, and data. +fn (mut rp RunPod) make_request[T](method Method, path string, data string) !T { + mut request := httpconnection.Request{ + prefix: path + data: data + debug: true + dataformat: .json + } + + mut http_client := rp.httpclient()! + mut response := T{} + + match method { + .get { + request.method = .get + response = http_client.get_json_generic[T](request)! + } + .post { + request.method = .post + response = http_client.post_json_generic[T](request)! + } + .put { + request.method = .put + response = http_client.put_json_generic[T](request)! + } + .delete { + request.method = .delete + response = http_client.delete_json_generic[T](request)! + } + else { + return error('unsupported method: ${method}') + } + } + + if response.errors.len > 0 { + return error('Error while sending the request due to: ${response.errors[0]['message']}') + } + + return response } diff --git a/lib/clients/runpod/runpod_model.v b/lib/clients/runpod/runpod_model.v index 5a12fbae..ea3a9606 100644 --- a/lib/clients/runpod/runpod_model.v +++ b/lib/clients/runpod/runpod_model.v @@ -1,5 +1,8 @@ module runpod +import freeflowuniverse.herolib.data.paramsparser +import os + pub const version = '1.14.3' const singleton = false const default = true @@ -9,7 +12,7 @@ pub fn heroscript_default() !string { return " !!runpod.configure name:'default' - api_key:'' + api_key:'${os.getenv('RUNPOD_API_KEY')}' base_url:'https://api.runpod.io/' " } @@ -23,50 +26,18 @@ pub mut: base_url string = 'https://api.runpod.io/' } -pub enum CloudType { - all - secure - community -} - -fn (ct CloudType) str() string { - return match ct { - .all { - 'ALL' - } - .secure { - 'SECURE' - } - .community { - 'COMMUNITY' - } +fn cfg_play(p paramsparser.Params) ! { + // THIS IS EXAMPLE CODE AND NEEDS TO BE CHANGED IN LINE WITH struct above + mut mycfg := RunPod{ + name: p.get_default('name', 'default')! + api_key: p.get_default('api_key', os.getenv('RUNPOD_API_KEY'))! + base_url: p.get_default('base_url', 'https://api.runpod.io/')! } + set(mycfg)! } -pub struct EnvironmentVariableInput { -pub mut: - key string - value string -} - -// new creates a new RunPod client -pub fn new(api_key string) !&RunPod { - if api_key == '' { - return error('API key is required') - } - return &RunPod{ - api_key: api_key - } -} - -// GraphQL query structs -struct GqlQuery { - query string -} - -// GraphQL response wrapper -struct GqlResponse[T] { -pub mut: - data map[string]T - errors []map[string]string +fn obj_init(obj_ RunPod) !RunPod { + // never call get here, only thing we can do here is work on object itself + mut obj := obj_ + return obj } diff --git a/lib/clients/runpod/utils.v b/lib/clients/runpod/utils.v index c0aee427..bdd147c4 100644 --- a/lib/clients/runpod/utils.v +++ b/lib/clients/runpod/utils.v @@ -1,200 +1,118 @@ module runpod import freeflowuniverse.herolib.core.httpconnection -import json +import x.json2 -// Represents the main structure for interacting with the RunPod API. -// Provides utilities to manage HTTP connections and perform GraphQL queries. -fn (mut rp RunPod) httpclient() !&httpconnection.HTTPConnection { - mut http_conn := httpconnection.new( - name: 'runpod_vclient_${rp.name}' - url: rp.base_url - cache: true - retry: 3 - )! - http_conn.default_header.add(.authorization, 'Bearer ${rp.api_key}') - return http_conn +enum OperationType { + query + mutation } -// Retrieves the field name from the `FieldData` struct, either from its attributes or its name. -fn get_field_name(field FieldData) string { - mut field_name := '' - if field.attrs.len > 0 { - for attr in field.attrs { - attrs := attr.trim_space().split(':') - if attrs.len == 2 && attrs[0] == 'json' { - field_name = attrs[1].trim_space() - continue - } - } - } else { - field_name = field.name - } - return field_name +struct QueryBuilder { +pub mut: + operation OperationType + fields []Field + variables map[string]string } -// // Constructs JSON-like request fields from a struct. -// fn get_request_fields[T](struct_ T) string { -// mut body_ := '{ ' -// mut fields := []string{} +struct Field { + name string + arguments map[string]string + sub_fields []Field +} -// $for field in T.fields { -// mut string_ := '' -// omit := 'omitempty' in field.attrs -// mut empty_string := false -// $if field.typ is string { -// empty_string = struct_.$(field.name) == '' -// } -// if !omit || !empty_string { -// string_ += get_field_name(field) -// string_ += ': ' +@[params] +pub struct NewFieldArgs { +pub: + name string + arguments map[string]string + sub_fields []Field +} -// $if field.is_enum { -// string_ += struct_.$(field.name).to_string() -// } $else $if field.typ is string { -// item := struct_.$(field.name) -// string_ += "\"${item}\"" -// } $else $if field.is_array { -// string_ += '[' -// for i in struct_.$(field.name) { -// string_ += get_request_fields(i) -// } -// string_ += ']' -// } $else $if field.is_struct { -// string_ += get_request_fields(struct_.$(field.name)) -// } $else { -// item := struct_.$(field.name) -// string_ += '${item}' -// } - -// fields << string_ -// } -// } -// body_ += fields.join(', ') -// body_ += ' }' -// return body_ -// } - -// // Constructs JSON-like response fields for a given struct. -// fn get_response_fields[R](struct_ R) string { -// mut body_ := '{ ' - -// $for field in R.fields { -// $if field.is_struct { -// // Recursively process nested structs -// body_ += '${field.name} ' -// body_ += get_response_fields(struct_.$(field.name)) -// } $else { -// body_ += get_field_name(field) -// body_ += ' ' -// } -// } -// body_ += ' }' -// return body_ -// } - -// // Enum representing the type of GraphQL operation. -// pub enum QueryType { -// query -// mutation -// } - -// // Struct for building GraphQL queries with request and response models. -// @[params] -// pub struct BuildQueryArgs[T, R] { -// pub: -// query_type QueryType // query or mutation -// method_name string -// request_model T @[required] -// response_model R @[required] -// } - -// // Builds a GraphQL query or mutation string from provided arguments. -// fn build_query[T, R](args BuildQueryArgs[T, R]) string { -// mut request_fields := T{} -// mut response_fields := R{} - -// if args.request_model { -// request_fields = get_request_fields(args.request_model) -// } - -// if args.response_model { -// response_fields = get_response_fields(args.response_model) -// } - -// mut query := '' - -// if args.request_model && args.response_model{ -// query := '${args.query_type.to_string()} { ${args.method_name}(input: ${request_fields}) ${response_fields} }' -// } - -// if args.response_model && !args.request_model{ -// query := '${args.query_type.to_string()} { ${response_fields} }' -// } - -// // Wrap in the final structure -// gql := GqlQuery{ -// query: query -// } - -// // Return the final GraphQL query as a JSON string -// return json.encode(gql) -// } - -// Converts the `QueryType` enum to its string representation. -fn (op OperationType) to_string() string { - return match op { - .query { - 'query' - } - .mutation { - 'mutation' - } +fn new_field(args NewFieldArgs) Field { + return Field{ + name: args.name + arguments: args.arguments + sub_fields: args.sub_fields } } -// Enum representing HTTP methods. -enum HTTPMethod { - get - post - put - delete +fn build_arguments(args map[string]string) string { + if args.len == 0 { + return '' + } + + mut sb := '' + sb += '(' + + for key, value in args { + if value.len == 0 { + continue + } + + sb += '${key}: ${value}, ' + } + + return sb.trim_right(', ') + ')' } -// Sends an HTTP request to the RunPod API with the specified method, path, and data. -fn (mut rp RunPod) make_request[T](method HTTPMethod, path string, data string) !T { - mut request := httpconnection.Request{ - prefix: path - data: data - debug: true - dataformat: .json +fn build_fields(fields []Field) string { + mut sb := ' { ' + for field in fields { + sb += field.name + if field.arguments.len > 0 { + sb += build_arguments(field.arguments) + } + + if field.sub_fields.len > 0 { + sb += build_fields(field.sub_fields) + } + + sb += ' ' } - - mut http := rp.httpclient()! - mut response := T{} - - match method { - .get { - request.method = .get - response = http.get_json_generic[T](request)! - } - .post { - request.method = .post - response = http.post_json_generic[T](request)! - } - .put { - request.method = .put - response = http.put_json_generic[T](request)! - } - .delete { - request.method = .delete - response = http.delete_json_generic[T](request)! - } - } - - if response.errors.len > 0 { - return error('Error while sending the request due to: ${response.errors[0]['message']}') - } - - return response + sb += ' } ' + return sb +} + +@[params] +pub struct AddOperationArgs { +pub: + operation OperationType + fields []Field + variables map[string]string +} + +fn (mut q QueryBuilder) add_operation(args AddOperationArgs) { + q.operation = args.operation + q.fields = args.fields + q.variables = args.variables.clone() +} + +@[params] +pub struct BuildQueryArgs { +pub: + variables map[string]json2.Any +} + +fn (q QueryBuilder) build_query(args BuildQueryArgs) string { + mut query := '' + query += '${q.operation}' + ' myOperation' + + if q.variables.len > 0 { + query += build_arguments(q.variables) + } + + query += build_fields(q.fields) + + mut q_map := { + 'query': json2.Any(query) + 'variables': json2.Any(args.variables) + } + + return json2.encode(q_map) +} + +fn type_to_map[T](t T) !map[string]json2.Any { + encoded_input := json2.encode(t) + return json2.raw_decode(encoded_input)!.as_map() }