diff --git a/lib/core/jobs/openrpc/.gitignore b/lib/core/jobs/openrpc/.gitignore new file mode 100644 index 00000000..254defdd --- /dev/null +++ b/lib/core/jobs/openrpc/.gitignore @@ -0,0 +1 @@ +server diff --git a/lib/core/jobs/openrpc/examples/job_client.vsh b/lib/core/jobs/openrpc/examples/job_client.vsh new file mode 100755 index 00000000..00fc14d0 --- /dev/null +++ b/lib/core/jobs/openrpc/examples/job_client.vsh @@ -0,0 +1,183 @@ +#!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run + +import freeflowuniverse.herolib.core.jobs.model +import net.websocket +import json +import rand +import time +import term + +const ws_url = 'ws://localhost:8080' + + +// Helper function to send request and receive response +fn send_request(mut ws websocket.Client, request OpenRPCRequest) !OpenRPCResponse { + // Send request + request_json := json.encode(request) + println(request_json) + ws.write_string(request_json) or { + eprintln(term.red('Failed to send request: ${err}')) + return err + } + + // Wait for response + mut msg := ws.read_next_message() or { + eprintln(term.red('Failed to read response: ${err}')) + return err + } + + if msg.opcode != websocket.OPCode.text_frame { + return error('Invalid response type: expected text frame') + } + + response_text := msg.payload.bytestr() + + // Parse response + response := json.decode(OpenRPCResponse, response_text) or { + eprintln(term.red('Failed to decode response: ${err}')) + return err + } + return response +} + +// OpenRPC request/response structures (copied from handler.v) +struct OpenRPCRequest { + jsonrpc string [required] + method string [required] + params []string + id int [required] +} + +struct OpenRPCResponse { + jsonrpc string [required] + result string + error string + id int [required] +} + + +// Initialize and configure WebSocket client +fn init_client() !&websocket.Client { + mut ws := websocket.new_client(ws_url)! + + ws.on_open(fn (mut ws websocket.Client) ! { + println(term.green('Connected to WebSocket server and ready...')) + }) + + ws.on_error(fn (mut ws websocket.Client, err string) ! { + eprintln(term.red('WebSocket error: ${err}')) + }) + + ws.on_close(fn (mut ws websocket.Client, code int, reason string) ! { + println(term.yellow('WebSocket connection closed: ${reason}')) + }) + + ws.on_message(fn (mut ws websocket.Client, msg &websocket.Message) ! { + if msg.payload.len > 0 { + println(term.blue('Received message: ${msg.payload.bytestr()}')) + } + }) + + ws.connect() or { + eprintln(term.red('Failed to connect: ${err}')) + return err + } + + spawn ws.listen() + return ws +} + +// Main client logic +mut ws := init_client()! +defer { + ws.close(1000, 'normal') or { + eprintln(term.red('Error closing connection: ${err}')) + } +} +println(term.green('Connected to ${ws_url}')) + +// Create a new job +println(term.blue('\nCreating new job...')) +new_job := send_request(mut ws, OpenRPCRequest{ + jsonrpc: '2.0' + method: 'job.new' + params: []string{} + id: rand.i32_in_range(1,10000000)! +}) or { + eprintln(term.red('Failed to create new job: ${err}')) + exit(1) +} +println(term.green('Created new job:')) +println(json.encode_pretty(new_job)) + +// Parse job from response +job := json.decode(model.Job, new_job.result) or { + eprintln(term.red('Failed to parse job: ${err}')) + exit(1) +} + +// Set job properties +println(term.blue('\nSetting job properties...')) +mut updated_job := job +updated_job.guid = 'test-job-1' +updated_job.actor = 'vm_manager' +updated_job.action = 'start' +updated_job.params = { + 'name': 'test-vm' + 'memory': '2048' +} + +// Save job +set_response := send_request(mut ws, OpenRPCRequest{ + jsonrpc: '2.0' + method: 'job.set' + params: [json.encode(updated_job)] + id: rand.int() +}) or { + eprintln(term.red('Failed to save job: ${err}')) + exit(1) +} +println(term.green('Saved job:')) +println(json.encode_pretty(set_response)) + +// Update job status to running +println(term.blue('\nUpdating job status...')) +update_response := send_request(mut ws, OpenRPCRequest{ + jsonrpc: '2.0' + method: 'job.update_status' + params: ['test-job-1', 'running'] + id: rand.int() +}) or { + eprintln(term.red('Failed to update job status: ${err}')) + exit(1) +} +println(term.green('Updated job status:')) +println(json.encode_pretty(update_response)) + +// Get job to verify changes +println(term.blue('\nRetrieving job...')) +get_response := send_request(mut ws, OpenRPCRequest{ + jsonrpc: '2.0' + method: 'job.get' + params: ['test-job-1'] + id: rand.int() +}) or { + eprintln(term.red('Failed to retrieve job: ${err}')) + exit(1) +} +println(term.green('Retrieved job:')) +println(json.encode_pretty(get_response)) + +// List all jobs +println(term.blue('\nListing all jobs...')) +list_response := send_request(mut ws, OpenRPCRequest{ + jsonrpc: '2.0' + method: 'job.list' + params: []string{} + id: rand.int() +}) or { + eprintln(term.red('Failed to list jobs: ${err}')) + exit(1) +} +println(term.green('All jobs:')) +println(json.encode_pretty(list_response)) diff --git a/lib/core/jobs/openrpc/examples/server.vsh b/lib/core/jobs/openrpc/examples/server.vsh new file mode 100755 index 00000000..0f797528 --- /dev/null +++ b/lib/core/jobs/openrpc/examples/server.vsh @@ -0,0 +1,41 @@ +#!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run + +import freeflowuniverse.herolib.core.jobs.openrpc +import freeflowuniverse.herolib.core.jobs.model +import time +import sync +import os + +fn start_rpc_server( mut wg sync.WaitGroup) ! { + defer { wg.done() } + + // Create OpenRPC server + openrpc.server_start()! + +} + +fn start_ws_server( mut wg sync.WaitGroup) ! { + defer { wg.done() } + + // Get port from environment variable or use default + port := if ws_port := os.getenv_opt('WS_PORT') { + ws_port.int() + } else { + 8080 + } + + // Create and start WebSocket server + mut ws_server := openrpc.new_ws_server(port)! + ws_server.start()! +} + +// Create wait group for servers +mut wg := sync.new_waitgroup() +wg.add(2) + +// Start servers in separate threads +spawn start_rpc_server(mut wg) +spawn start_ws_server(mut wg) + +// Wait for servers to finish (they run forever) +wg.wait() diff --git a/lib/core/jobs/openrpc/factory.v b/lib/core/jobs/openrpc/factory.v new file mode 100644 index 00000000..8efc6441 --- /dev/null +++ b/lib/core/jobs/openrpc/factory.v @@ -0,0 +1,27 @@ +module openrpc +import freeflowuniverse.herolib.core.redisclient +import freeflowuniverse.herolib.core.jobs.model + +// Generic OpenRPC server that handles all managers +pub struct OpenRPCServer { +mut: + redis &redisclient.Redis + queue &redisclient.RedisQueue + runner &model.HeroRunner +} + +// Create new OpenRPC server with Redis connection +pub fn server_start() ! { + redis := redisclient.core_get()! + mut runner := model.new()! + mut s:= &OpenRPCServer{ + redis: redis + queue: &redisclient.RedisQueue{ + key: rpc_queue + redis: redis + + } + runner:runner + } + s.start()! +} diff --git a/lib/core/jobs/openrpc/handler.v b/lib/core/jobs/openrpc/handler.v new file mode 100644 index 00000000..b3dd1233 --- /dev/null +++ b/lib/core/jobs/openrpc/handler.v @@ -0,0 +1,71 @@ +module openrpc + +import freeflowuniverse.herolib.core.redisclient +import json + + + +// Start the server and listen for requests +pub fn (mut s OpenRPCServer) start() ! { + println('Starting OpenRPC server.') + + for { + // Get message from queue + msg := s.queue.get(5000)! + + if msg.len==0{ + println("queue '${rpc_queue}' empty") + continue + } + + println("process '${msg}'") + + // Parse OpenRPC request + request := json.decode(OpenRPCRequest, msg) or { + println('Error decoding request: ${err}') + continue + } + + // Process request with appropriate handler + response := s.handle_request(request)! + + // Send response back to Redis using response queue + response_json := json.encode(response) + key:='${rpc_queue}:${request.id}' + println("response: ${} put on return queue ${key} ") + mut response_queue := &redisclient.RedisQueue{ + key: key + redis: s.redis + } + response_queue.add(response_json)! + } +} + +// Get the handler for a specific method based on its prefix +fn (mut s OpenRPCServer) handle_request(request OpenRPCRequest) !OpenRPCResponse { + method := request.method.to_lower() + println("process: method: '${method}'") + if method.starts_with("job.") { + return s.handle_request_job(request) or { + return rpc_response_error(request.id,"error in request job:\n${err}") + } + } + if method.starts_with("agent.") { + return s.handle_request_agent(request) or { + return rpc_response_error(request.id,"error in request agent:\n${err}") + } + } + if method.starts_with("group.") { + return s.handle_request_group(request) or { + return rpc_response_error(request.id,"error in request group:\n${err}") + } + } + if method.starts_with("service.") { + return s.handle_request_service(request) or { + return rpc_response_error(request.id,"error in request service:\n${err}") + } + } + + return rpc_response_error(request.id,"Could not find handler for ${method}") + +} diff --git a/lib/core/jobs/openrpc/handler_agent_manager.v b/lib/core/jobs/openrpc/handler_agent_manager.v new file mode 100644 index 00000000..f265c857 --- /dev/null +++ b/lib/core/jobs/openrpc/handler_agent_manager.v @@ -0,0 +1,69 @@ +module openrpc + +import freeflowuniverse.herolib.core.jobs.model +import json + + +pub fn (mut h OpenRPCServer) handle_request_agent(request OpenRPCRequest) !OpenRPCResponse { + + mut response:=rpc_response_new(request.id) + + match request.method { + 'new' { + agent := h.runner.agents.new() + response.result = json.encode(agent) + } + 'set' { + if request.params.len < 1 { + return error('Missing agent parameter') + } + agent := json.decode(model.Agent, request.params[0])! + h.runner.agents.set(agent)! + response.result = 'true' + } + 'get' { + if request.params.len < 1 { + return error('Missing pubkey parameter') + } + agent := h.runner.agents.get(request.params[0])! + response.result = json.encode(agent) + } + 'list' { + agents := h.runner.agents.list()! + response.result = json.encode(agents) + } + 'delete' { + if request.params.len < 1 { + return error('Missing pubkey parameter') + } + h.runner.agents.delete(request.params[0])! + response.result = 'true' + } + 'update_status' { + if request.params.len < 2 { + return error('Missing pubkey or status parameters') + } + status := match request.params[1] { + 'ok' { model.AgentState.ok } + 'down' { model.AgentState.down } + 'error' { model.AgentState.error } + 'halted' { model.AgentState.halted } + else { return error('Invalid status: ${request.params[1]}') } + } + h.runner.agents.update_status(request.params[0], status)! + response.result = 'true' + } + 'get_by_service' { + if request.params.len < 2 { + return error('Missing actor or action parameters') + } + agents := h.runner.agents.get_by_service(request.params[0], request.params[1])! + response.result = json.encode(agents) + } + else { + return error('Unknown method: ${request.method}') + } + } + + return response +} diff --git a/lib/core/jobs/openrpc/handler_group_manager.v b/lib/core/jobs/openrpc/handler_group_manager.v new file mode 100644 index 00000000..28204d14 --- /dev/null +++ b/lib/core/jobs/openrpc/handler_group_manager.v @@ -0,0 +1,66 @@ +module openrpc + +import freeflowuniverse.herolib.core.jobs.model +import json + +pub fn (mut h OpenRPCServer) handle_request_group(request OpenRPCRequest) !OpenRPCResponse { + mut response:=rpc_response_new(request.id) + match request.method { + 'new' { + group := h.runner.groups.new() + response.result = json.encode(group) + } + 'set' { + if request.params.len < 1 { + return error('Missing group parameter') + } + group := json.decode(model.Group, request.params[0])! + h.runner.groups.set(group)! + response.result = 'true' + } + 'get' { + if request.params.len < 1 { + return error('Missing guid parameter') + } + group := h.runner.groups.get(request.params[0])! + response.result = json.encode(group) + } + 'list' { + groups := h.runner.groups.list()! + response.result = json.encode(groups) + } + 'delete' { + if request.params.len < 1 { + return error('Missing guid parameter') + } + h.runner.groups.delete(request.params[0])! + response.result = 'true' + } + 'add_member' { + if request.params.len < 2 { + return error('Missing guid or member parameters') + } + h.runner.groups.add_member(request.params[0], request.params[1])! + response.result = 'true' + } + 'remove_member' { + if request.params.len < 2 { + return error('Missing guid or member parameters') + } + h.runner.groups.remove_member(request.params[0], request.params[1])! + response.result = 'true' + } + 'get_user_groups' { + if request.params.len < 1 { + return error('Missing user_pubkey parameter') + } + groups := h.runner.groups.get_user_groups(request.params[0])! + response.result = json.encode(groups) + } + else { + return error('Unknown method: ${request.method}') + } + } + + return response +} diff --git a/lib/core/jobs/openrpc/handler_job_manager.v b/lib/core/jobs/openrpc/handler_job_manager.v new file mode 100644 index 00000000..92d3d13f --- /dev/null +++ b/lib/core/jobs/openrpc/handler_job_manager.v @@ -0,0 +1,63 @@ +module openrpc + +import freeflowuniverse.herolib.core.jobs.model +import json + +pub fn (mut h OpenRPCServer) handle_request_job(request OpenRPCRequest) !OpenRPCResponse { + mut response:=rpc_response_new(request.id) + println(request) + match request.method { + 'new' { + job := h.runner.jobs.new() + response.result = json.encode(job) + } + 'set' { + if request.params.len < 1 { + return error('Missing job parameter') + } + job := json.decode(model.Job, request.params[0])! + h.runner.jobs.set(job)! + response.result = 'true' + } + 'get' { + if request.params.len < 1 { + return error('Missing guid parameter') + } + job := h.runner.jobs.get(request.params[0])! + response.result = json.encode(job) + } + 'list' { + jobs := h.runner.jobs.list()! + response.result = json.encode(jobs) + } + 'delete' { + if request.params.len < 1 { + return error('Missing guid parameter') + } + h.runner.jobs.delete(request.params[0])! + response.result = 'true' + } + 'update_status' { + if request.params.len < 2 { + return error('Missing guid or status parameters') + } + status := match request.params[1] { + 'created' { model.Status.created } + 'scheduled' { model.Status.scheduled } + 'planned' { model.Status.planned } + 'running' { model.Status.running } + 'error' { model.Status.error } + 'ok' { model.Status.ok } + else { return error('Invalid status: ${request.params[1]}') } + } + h.runner.jobs.update_status(request.params[0], status)! + job := h.runner.jobs.get(request.params[0])! // Get updated job to return + response.result = json.encode(job) + } + else { + return error('Unknown method: ${request.method}') + } + } + + return response +} diff --git a/lib/core/jobs/openrpc/handler_service_manager.v b/lib/core/jobs/openrpc/handler_service_manager.v new file mode 100644 index 00000000..28bd3f8f --- /dev/null +++ b/lib/core/jobs/openrpc/handler_service_manager.v @@ -0,0 +1,81 @@ +module openrpc + +import freeflowuniverse.herolib.core.jobs.model +import json + +pub fn (mut h OpenRPCServer) handle_request_service(request OpenRPCRequest) !OpenRPCResponse { + mut response:=rpc_response_new(request.id) + + match request.method { + 'new' { + service := h.runner.services.new() + response.result = json.encode(service) + } + 'set' { + if request.params.len < 1 { + return error('Missing service parameter') + } + service := json.decode(model.Service, request.params[0])! + h.runner.services.set(service)! + response.result = 'true' + } + 'get' { + if request.params.len < 1 { + return error('Missing actor parameter') + } + service := h.runner.services.get(request.params[0])! + response.result = json.encode(service) + } + 'list' { + services := h.runner.services.list()! + response.result = json.encode(services) + } + 'delete' { + if request.params.len < 1 { + return error('Missing actor parameter') + } + h.runner.services.delete(request.params[0])! + response.result = 'true' + } + 'update_status' { + if request.params.len < 2 { + return error('Missing actor or status parameters') + } + status := match request.params[1] { + 'ok' { model.ServiceState.ok } + 'down' { model.ServiceState.down } + 'error' { model.ServiceState.error } + 'halted' { model.ServiceState.halted } + else { return error('Invalid status: ${request.params[1]}') } + } + h.runner.services.update_status(request.params[0], status)! + response.result = 'true' + } + 'get_by_action' { + if request.params.len < 1 { + return error('Missing action parameter') + } + services := h.runner.services.get_by_action(request.params[0])! + response.result = json.encode(services) + } + 'check_access' { + if request.params.len < 4 { + return error('Missing parameters: requires actor, action, user_pubkey, and groups') + } + // Parse groups array from JSON string + groups := json.decode([]string, request.params[3])! + has_access := h.runner.services.check_access( + request.params[0], // actor + request.params[1], // action + request.params[2], // user_pubkey + groups + )! + response.result = json.encode(has_access) + } + else { + return error('Unknown method: ${request.method}') + } + } + + return response +} diff --git a/lib/core/jobs/openrpc/model.v b/lib/core/jobs/openrpc/model.v new file mode 100644 index 00000000..7b4d997a --- /dev/null +++ b/lib/core/jobs/openrpc/model.v @@ -0,0 +1,39 @@ +module openrpc + +// Generic OpenRPC request/response structures +pub struct OpenRPCRequest { +pub mut: + jsonrpc string @[required] + method string @[required] + params []string + id int @[required] +} + +pub struct OpenRPCResponse { +pub mut: + jsonrpc string @[required] + result string + error string + id int @[required] +} + + +fn rpc_response_new(id int)OpenRPCResponse { + mut response := OpenRPCResponse{ + jsonrpc: '2.0' + id: id + } + return response +} + +fn rpc_response_error(id int, errormsg string)OpenRPCResponse { + mut response := OpenRPCResponse{ + jsonrpc: '2.0' + id: id + error:errormsg + } + return response +} + + +const rpc_queue = 'herorunner:q:rpc' diff --git a/lib/core/jobs/openrpc/specs/agent_manager_openrpc.json b/lib/core/jobs/openrpc/specs/agent_manager_openrpc.json new file mode 100644 index 00000000..a87d4602 --- /dev/null +++ b/lib/core/jobs/openrpc/specs/agent_manager_openrpc.json @@ -0,0 +1,302 @@ +{ + "openrpc": "1.2.6", + "info": { + "title": "AgentManager Service", + "version": "1.0.0", + "description": "OpenRPC specification for the AgentManager module and its methods." + }, + "methods": [ + { + "name": "new", + "summary": "Create a new Agent instance", + "description": "Returns a new Agent with default or empty fields set. Caller can then fill in details.", + "params": [], + "result": { + "name": "Agent", + "description": "A freshly created Agent object.", + "schema": { + "$ref": "#/components/schemas/Agent" + } + } + }, + { + "name": "set", + "summary": "Add or update an Agent in the system", + "description": "Stores an Agent in Redis by pubkey. Overwrites any previous entry with the same pubkey.", + "params": [ + { + "name": "agent", + "description": "The Agent instance to be added or updated.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Agent" + } + } + ], + "result": { + "name": "success", + "description": "Indicates success. No data returned on success.", + "schema": { + "type": "boolean" + } + } + }, + { + "name": "get", + "summary": "Retrieve an Agent by its public key", + "description": "Looks up a single Agent using its pubkey.", + "params": [ + { + "name": "pubkey", + "description": "The public key to look up.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "Agent", + "description": "The Agent that was requested, if found.", + "schema": { + "$ref": "#/components/schemas/Agent" + } + } + }, + { + "name": "list", + "summary": "List all Agents", + "description": "Returns an array of all known Agents.", + "params": [], + "result": { + "name": "Agents", + "description": "A list of all Agents in the system.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Agent" + } + } + } + }, + { + "name": "delete", + "summary": "Delete an Agent by its public key", + "description": "Removes an Agent from the system by pubkey.", + "params": [ + { + "name": "pubkey", + "description": "The public key of the Agent to be deleted.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "success", + "description": "Indicates success. No data returned on success.", + "schema": { + "type": "boolean" + } + } + }, + { + "name": "update_status", + "summary": "Update the status of an Agent", + "description": "Updates only the status field of the specified Agent.", + "params": [ + { + "name": "pubkey", + "description": "Public key of the Agent to update.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "description": "The new status to set for the Agent.", + "required": true, + "schema": { + "$ref": "#/components/schemas/AgentState" + } + } + ], + "result": { + "name": "success", + "description": "Indicates success. No data returned on success.", + "schema": { + "type": "boolean" + } + } + }, + { + "name": "get_by_service", + "summary": "Retrieve all Agents that provide a specific service action", + "description": "Filters Agents by matching actor and action in any of their declared services.", + "params": [ + { + "name": "actor", + "description": "The actor name to match.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "action", + "description": "The action name to match.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "Agents", + "description": "A list of Agents that match the specified service actor and action.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Agent" + } + } + } + } + ], + "components": { + "schemas": { + "Agent": { + "type": "object", + "properties": { + "pubkey": { + "type": "string", + "description": "Public key (ed25519) of the Agent." + }, + "address": { + "type": "string", + "description": "Network address or domain where the Agent can be reached." + }, + "port": { + "type": "integer", + "description": "Network port for the Agent (default: 9999)." + }, + "description": { + "type": "string", + "description": "Optional human-readable description of the Agent." + }, + "status": { + "$ref": "#/components/schemas/AgentStatus" + }, + "services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentService" + }, + "description": "List of public services provided by the Agent." + }, + "signature": { + "type": "string", + "description": "Signature (by the Agent's private key) of address+port+description+status." + } + }, + "required": ["pubkey", "status", "services"] + }, + "AgentStatus": { + "type": "object", + "properties": { + "guid": { + "type": "string", + "description": "Unique ID for the job or session." + }, + "timestamp_first": { + "$ref": "#/components/schemas/OurTime", + "description": "Timestamp when this Agent first came online." + }, + "timestamp_last": { + "$ref": "#/components/schemas/OurTime", + "description": "Timestamp of the last heartbeat or update from the Agent." + }, + "status": { + "$ref": "#/components/schemas/AgentState" + } + } + }, + "AgentService": { + "type": "object", + "properties": { + "actor": { + "type": "string", + "description": "The actor name providing the service." + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentServiceAction" + }, + "description": "List of actions available for this service." + }, + "description": { + "type": "string", + "description": "Optional human-readable description for the service." + }, + "status": { + "$ref": "#/components/schemas/AgentServiceState" + } + }, + "required": ["actor", "actions", "status"] + }, + "AgentServiceAction": { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "Action name." + }, + "description": { + "type": "string", + "description": "Optional description of this action." + }, + "params": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Dictionary of parameter names to parameter descriptions." + }, + "params_example": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Example values for the parameters." + }, + "status": { + "$ref": "#/components/schemas/AgentServiceState" + }, + "public": { + "type": "boolean", + "description": "Indicates if the action is publicly accessible to all or restricted." + } + }, + "required": ["action", "status", "public"] + }, + "AgentState": { + "type": "string", + "enum": ["ok", "down", "error", "halted"], + "description": "Possible states of an Agent." + }, + "AgentServiceState": { + "type": "string", + "enum": ["ok", "down", "error", "halted"], + "description": "Possible states of an Agent service or action." + }, + "OurTime": { + "type": "string", + "format": "date-time", + "description": "Represents a date/time or timestamp value." + } + } + } +} diff --git a/lib/core/jobs/openrpc/specs/group_manager_openrpc.json b/lib/core/jobs/openrpc/specs/group_manager_openrpc.json new file mode 100644 index 00000000..e931ab87 --- /dev/null +++ b/lib/core/jobs/openrpc/specs/group_manager_openrpc.json @@ -0,0 +1,218 @@ +{ + "openrpc": "1.2.6", + "info": { + "title": "Group Manager API", + "version": "1.0.0", + "description": "An OpenRPC specification for Group Manager methods" + }, + "servers": [ + { + "name": "Local", + "url": "http://localhost:8080" + } + ], + "methods": [ + { + "name": "GroupManager.new", + "summary": "Create a new (in-memory) Group instance", + "description": "Creates a new group object. Note that this does NOT store it in Redis. The caller must set the group’s GUID and then call `GroupManager.set` if they wish to persist it.", + "params": [], + "result": { + "name": "group", + "description": "The newly-created group instance", + "schema": { + "$ref": "#/components/schemas/Group" + } + } + }, + { + "name": "GroupManager.set", + "summary": "Add or update a Group in Redis", + "description": "Stores the specified group in Redis using the group’s GUID as the key.", + "params": [ + { + "name": "group", + "description": "The group object to store", + "schema": { + "$ref": "#/components/schemas/Group" + } + } + ], + "result": { + "name": "result", + "description": "No return value", + "schema": { + "type": "null" + } + } + }, + { + "name": "GroupManager.get", + "summary": "Retrieve a Group by GUID", + "description": "Fetches the group from Redis using the provided GUID.", + "params": [ + { + "name": "guid", + "description": "The group’s unique identifier", + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "group", + "description": "The requested group", + "schema": { + "$ref": "#/components/schemas/Group" + } + } + }, + { + "name": "GroupManager.list", + "summary": "List all Groups", + "description": "Returns an array containing all groups stored in Redis.", + "params": [], + "result": { + "name": "groups", + "description": "All currently stored groups", + "schema": { + "$ref": "#/components/schemas/GroupList" + } + } + }, + { + "name": "GroupManager.delete", + "summary": "Delete a Group by GUID", + "description": "Removes the specified group from Redis by its GUID.", + "params": [ + { + "name": "guid", + "description": "The group’s unique identifier", + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "description": "No return value", + "schema": { + "type": "null" + } + } + }, + { + "name": "GroupManager.add_member", + "summary": "Add a member to a Group", + "description": "Adds a user pubkey or another group’s GUID to the member list of the specified group. Does not add duplicates.", + "params": [ + { + "name": "guid", + "description": "The target group’s unique identifier", + "schema": { + "type": "string" + } + }, + { + "name": "member", + "description": "Pubkey or group GUID to be added to the group", + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "description": "No return value", + "schema": { + "type": "null" + } + } + }, + { + "name": "GroupManager.remove_member", + "summary": "Remove a member from a Group", + "description": "Removes a user pubkey or another group’s GUID from the member list of the specified group.", + "params": [ + { + "name": "guid", + "description": "The target group’s unique identifier", + "schema": { + "type": "string" + } + }, + { + "name": "member", + "description": "Pubkey or group GUID to be removed from the group", + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "description": "No return value", + "schema": { + "type": "null" + } + } + }, + { + "name": "GroupManager.get_user_groups", + "summary": "List Groups that a user belongs to (directly or indirectly)", + "description": "Checks each group (and nested groups) to see if the user pubkey is a member, returning all groups in which the user is included (including membership through nested groups).", + "params": [ + { + "name": "user_pubkey", + "description": "The pubkey of the user to check", + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "groups", + "description": "A list of groups to which the user belongs", + "schema": { + "$ref": "#/components/schemas/GroupList" + } + } + } + ], + "components": { + "schemas": { + "Group": { + "type": "object", + "properties": { + "guid": { + "type": "string", + "description": "Unique ID for the group" + }, + "name": { + "type": "string", + "description": "Name of the group" + }, + "description": { + "type": "string", + "description": "Optional description of the group" + }, + "members": { + "type": "array", + "description": "List of user pubkeys or other group GUIDs", + "items": { + "type": "string" + } + } + }, + "required": ["guid", "members"] + }, + "GroupList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Group" + } + } + } + } + } + \ No newline at end of file diff --git a/lib/core/jobs/openrpc/specs/job_manager_openrpc.json b/lib/core/jobs/openrpc/specs/job_manager_openrpc.json new file mode 100644 index 00000000..c27efc9a --- /dev/null +++ b/lib/core/jobs/openrpc/specs/job_manager_openrpc.json @@ -0,0 +1,304 @@ +{ + "openrpc": "1.2.6", + "info": { + "title": "JobManager OpenRPC Specification", + "version": "1.0.0", + "description": "OpenRPC specification for the JobManager module which handles job operations." + }, + "servers": [ + { + "name": "Local", + "url": "http://localhost:8080/rpc" + } + ], + "methods": [ + { + "name": "newJob", + "summary": "Create a new Job instance", + "description": "Creates a new Job with default/empty values. The GUID is left empty for the caller to fill.", + "params": [], + "result": { + "name": "job", + "description": "A newly created Job object, not yet persisted.", + "schema": { + "$ref": "#/components/schemas/Job" + } + } + }, + { + "name": "setJob", + "summary": "Add or update a Job in the system (Redis)", + "description": "Persists the given Job into the data store. If the GUID already exists, the existing job is overwritten.", + "params": [ + { + "name": "job", + "description": "The Job object to store or update.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Job" + } + } + ], + "result": { + "name": "success", + "description": "Indicates if the operation was successful.", + "schema": { + "type": "boolean" + } + } + }, + { + "name": "getJob", + "summary": "Retrieve a Job by its GUID", + "description": "Fetches an existing Job from the data store using its unique GUID.", + "params": [ + { + "name": "guid", + "description": "The GUID of the Job to retrieve.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "job", + "description": "The retrieved Job object.", + "schema": { + "$ref": "#/components/schemas/Job" + } + } + }, + { + "name": "listJobs", + "summary": "List all Jobs", + "description": "Returns an array of all Jobs present in the data store.", + "params": [], + "result": { + "name": "jobs", + "description": "Array of all Job objects found.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Job" + } + } + } + }, + { + "name": "deleteJob", + "summary": "Remove a Job by its GUID", + "description": "Deletes a specific Job from the data store by its GUID.", + "params": [ + { + "name": "guid", + "description": "The GUID of the Job to delete.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "success", + "description": "Indicates if the job was successfully deleted.", + "schema": { + "type": "boolean" + } + } + }, + { + "name": "updateJobStatus", + "summary": "Update the status of a Job", + "description": "Sets the status field of a Job in the data store.", + "params": [ + { + "name": "guid", + "description": "The GUID of the Job to update.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "description": "The new status for the Job.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Status" + } + } + ], + "result": { + "name": "job", + "description": "The updated Job object with new status applied.", + "schema": { + "$ref": "#/components/schemas/Job" + } + } + } + ], + "components": { + "schemas": { + "Job": { + "type": "object", + "properties": { + "guid": { + "type": "string", + "description": "Unique ID for the Job." + }, + "agents": { + "type": "array", + "description": "Public keys of the agent(s) which will execute the command.", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "description": "Pubkey of the agent who requested the job." + }, + "circle": { + "type": "string", + "description": "Digital-life circle name this Job belongs to.", + "default": "default" + }, + "context": { + "type": "string", + "description": "High-level context for the Job inside a circle.", + "default": "default" + }, + "actor": { + "type": "string", + "description": "Actor name that will handle the Job (e.g. `vm_manager`)." + }, + "action": { + "type": "string", + "description": "Action to be taken by the actor (e.g. `start`)." + }, + "params": { + "type": "object", + "description": "Key-value parameters for the action to be performed.", + "additionalProperties": { + "type": "string" + } + }, + "timeout_schedule": { + "type": "integer", + "description": "Timeout (in seconds) before the job is picked up by an agent.", + "default": 60 + }, + "timeout": { + "type": "integer", + "description": "Timeout (in seconds) for the job to complete.", + "default": 3600 + }, + "log": { + "type": "boolean", + "description": "Whether to log job details.", + "default": true + }, + "ignore_error": { + "type": "boolean", + "description": "If true, job errors do not cause an exception to be raised." + }, + "ignore_error_codes": { + "type": "array", + "description": "Array of error codes to ignore.", + "items": { + "type": "integer" + } + }, + "debug": { + "type": "boolean", + "description": "If true, additional debug information is provided.", + "default": false + }, + "retry": { + "type": "integer", + "description": "Number of retries allowed on error.", + "default": 0 + }, + "status": { + "$ref": "#/components/schemas/JobStatus" + }, + "dependencies": { + "type": "array", + "description": "List of job dependencies that must complete before this job executes.", + "items": { + "$ref": "#/components/schemas/JobDependency" + } + } + }, + "required": [ + "guid", + "status" + ] + }, + "JobStatus": { + "type": "object", + "properties": { + "guid": { + "type": "string", + "description": "Unique ID for the Job (mirrors the parent job GUID)." + }, + "created": { + "type": "string", + "format": "date-time", + "description": "When the job was created." + }, + "start": { + "type": "string", + "format": "date-time", + "description": "When the job was picked up to start." + }, + "end": { + "type": "string", + "format": "date-time", + "description": "When the job ended." + }, + "status": { + "$ref": "#/components/schemas/Status" + } + }, + "required": [ + "guid", + "created", + "status" + ] + }, + "JobDependency": { + "type": "object", + "properties": { + "guid": { + "type": "string", + "description": "Unique ID of the Job this dependency points to." + }, + "agents": { + "type": "array", + "description": "Possible agent(s) who can execute the dependency.", + "items": { + "type": "string" + } + } + }, + "required": [ + "guid" + ] + }, + "Status": { + "type": "string", + "enum": [ + "created", + "scheduled", + "planned", + "running", + "error", + "ok" + ], + "description": "Enumerates the possible states of a Job." + } + } + } + } + \ No newline at end of file diff --git a/lib/core/jobs/openrpc/specs/service_manager_openrpc.json b/lib/core/jobs/openrpc/specs/service_manager_openrpc.json new file mode 100644 index 00000000..c551787d --- /dev/null +++ b/lib/core/jobs/openrpc/specs/service_manager_openrpc.json @@ -0,0 +1,301 @@ +{ + "openrpc": "1.2.6", + "info": { + "title": "ServiceManager API", + "version": "1.0.0", + "description": "OpenRPC 2.0 spec for managing services with ServiceManager." + }, + "servers": [ + { + "name": "Local", + "url": "http://localhost:8080" + } + ], + "methods": [ + { + "name": "ServiceManager_new", + "summary": "Create a new Service instance (not saved to Redis yet).", + "description": "Creates and returns a new empty Service object with default values. The `actor` field remains empty until the caller sets it.", + "params": [], + "result": { + "name": "service", + "$ref": "#/components/schemas/Service" + } + }, + { + "name": "ServiceManager_set", + "summary": "Add or update a Service in Redis.", + "description": "Stores the Service in Redis, identified by its `actor` property.", + "params": [ + { + "name": "service", + "schema": { + "$ref": "#/components/schemas/Service" + } + } + ], + "result": { + "name": "success", + "schema": { + "type": "boolean", + "description": "True if operation succeeds." + } + } + }, + { + "name": "ServiceManager_get", + "summary": "Retrieve a Service by actor name.", + "description": "Gets the Service object from Redis using the given actor name.", + "params": [ + { + "name": "actor", + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "service", + "$ref": "#/components/schemas/Service" + } + }, + { + "name": "ServiceManager_list", + "summary": "List all Services.", + "description": "Returns an array of all Services stored in Redis.", + "params": [], + "result": { + "name": "services", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Service" + } + } + } + }, + { + "name": "ServiceManager_delete", + "summary": "Delete a Service by actor name.", + "description": "Removes the Service from Redis using the given actor name.", + "params": [ + { + "name": "actor", + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "success", + "schema": { + "type": "boolean" + } + } + }, + { + "name": "ServiceManager_update_status", + "summary": "Update the status of a given Service.", + "description": "Updates only the `status` field of a Service specified by its actor name.", + "params": [ + { + "name": "actor", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "schema": { + "$ref": "#/components/schemas/ServiceState" + } + } + ], + "result": { + "name": "success", + "schema": { + "type": "boolean" + } + } + }, + { + "name": "ServiceManager_get_by_action", + "summary": "Retrieve Services by action name.", + "description": "Returns all Services that provide the specified action.", + "params": [ + { + "name": "action", + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "services", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Service" + } + } + } + }, + { + "name": "ServiceManager_check_access", + "summary": "Check if a user has access to a Service action.", + "description": "Verifies if a user (and any groups they belong to) has the right to invoke a specified action on a given Service.", + "params": [ + { + "name": "actor", + "schema": { + "type": "string" + } + }, + { + "name": "action", + "schema": { + "type": "string" + } + }, + { + "name": "user_pubkey", + "schema": { + "type": "string" + } + }, + { + "name": "groups", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "result": { + "name": "hasAccess", + "schema": { + "type": "boolean" + } + } + } + ], + "components": { + "schemas": { + "Service": { + "type": "object", + "properties": { + "actor": { + "type": "string", + "description": "The actor (unique name) providing the service." + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceAction" + }, + "description": "A list of actions available in this service." + }, + "description": { + "type": "string", + "description": "Optional description of the service." + }, + "status": { + "$ref": "#/components/schemas/ServiceState", + "description": "The current state of the service." + }, + "acl": { + "$ref": "#/components/schemas/ACL", + "description": "An optional access control list for the entire service." + } + }, + "required": ["actor", "actions", "status"] + }, + "ServiceAction": { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "A unique identifier for the action." + }, + "description": { + "type": "string", + "description": "Optional description of this action." + }, + "params": { + "type": "object", + "description": "Parameter definitions for this action.", + "additionalProperties": { + "type": "string" + } + }, + "params_example": { + "type": "object", + "description": "Example parameters for this action.", + "additionalProperties": { + "type": "string" + } + }, + "acl": { + "$ref": "#/components/schemas/ACL", + "description": "Optional ACL specifically for this action." + } + }, + "required": ["action"] + }, + "ACL": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "A friendly name for the ACL." + }, + "ace": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ACE" + }, + "description": "A list of Access Control Entries." + } + }, + "required": ["ace"] + }, + "ACE": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of group IDs that have this permission." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of user public keys that have this permission." + }, + "right": { + "type": "string", + "description": "Permission type (e.g. 'read', 'write', 'admin', 'block')." + } + }, + "required": ["right"] + }, + "ServiceState": { + "type": "string", + "enum": [ + "ok", + "down", + "error", + "halted" + ], + "description": "Possible states of a service." + } + } + } + } + \ No newline at end of file diff --git a/lib/core/jobs/openrpc/ws_server.v b/lib/core/jobs/openrpc/ws_server.v new file mode 100644 index 00000000..7c652b29 --- /dev/null +++ b/lib/core/jobs/openrpc/ws_server.v @@ -0,0 +1,82 @@ +module openrpc + +import net.websocket +import freeflowuniverse.herolib.core.redisclient +import json +import rand + +// WebSocket server that receives RPC requests +pub struct WSServer { +mut: + redis &redisclient.Redis + port int = 8080 // Default port, can be configured +} + +// Create new WebSocket server +pub fn new_ws_server( port int) !&WSServer { + return &WSServer{ + redis: redisclient.core_get()! + port: port + } +} + +// Start the WebSocket server +pub fn (mut s WSServer) start() ! { + mut ws_server := websocket.new_server(.ip, s.port, '') + + // Handle new WebSocket connections + ws_server.on_connect(fn (mut ws websocket.ServerClient) !bool { + println('New WebSocket client connected') + return true + })! + + // Handle client disconnections + ws_server.on_close(fn (mut ws websocket.Client, code int, reason string) ! { + println('WebSocket client disconnected (code: ${code}, reason: ${reason})') + }) + + // Handle incoming messages + ws_server.on_message(fn [mut s] (mut ws websocket.Client, msg &websocket.Message) ! { + if msg.opcode != .text_frame { + println('WebSocket unknown msg opcode (code: ${msg.opcode})') + return + } + + // Parse request + request := json.decode(OpenRPCRequest, msg.payload.bytestr()) or { + error_msg := '{"jsonrpc":"2.0","error":"Invalid JSON-RPC request","id":null}' + println(error_msg) + ws.write(error_msg.bytes(), websocket.OPCode.text_frame) or { panic(err) } + return + } + + // Generate unique request ID if not provided + mut req_id := request.id + if req_id == 0 { + req_id = rand.i32_in_range(1,10000000)! + } + + println('WebSocket put on queue: \'${rpc_queue}\' (msg: ${msg.payload.bytestr()})') + // Send request to Redis queue + s.redis.lpush(rpc_queue, msg.payload.bytestr())! + + // Wait for response + response := s.redis.brpop(['${rpc_queue}:${req_id}'], 30)! // 30 second timeout + if response.len < 2 { + error_msg := '{"jsonrpc":"2.0","error":"Timeout waiting for response","id":${req_id}}' + println('WebSocket error response (err: ${response})') + ws.write(error_msg.bytes(), websocket.OPCode.text_frame) or { panic(err) } + return + } + + println('WebSocket ok response (msg: ${response[1].bytes()})') + // Send response back to WebSocket client + ws.write(response[1].bytes(), websocket.OPCode.text_frame) or { panic(err) } + }) + + // Start server + println('WebSocket server listening on port ${s.port}') + ws_server.listen() or { + return error('Failed to start WebSocket server: ${err}') + } +}