From f6734a3568da807431c7893b29c1e85566586180 Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Wed, 29 Oct 2025 11:42:44 +0300 Subject: [PATCH 1/7] chore: Remove openrouter client - Remove call to openrouter.play from the main play function - Used the OpenAI client instead - Updated the examples - Updated the README --- examples/ai/openai/README.md | 120 +++++++++++++++ .../openai_example.vsh} | 22 ++- .../openai_hello.vsh} | 14 +- .../openai_init.vsh} | 4 +- .../openai_two_model_pipeline.vsh} | 44 ++++-- examples/ai/openrouter/README.md | 78 ---------- lib/clients/openrouter/.heroscript | 7 - lib/clients/openrouter/client_test.v | 13 -- lib/clients/openrouter/completions.v | 97 ------------ lib/clients/openrouter/openrouter_factory_.v | 140 ------------------ lib/clients/openrouter/openrouter_model.v | 67 --------- lib/clients/openrouter/openrouter_raw.v | 38 ----- lib/clients/openrouter/readme.md | 97 ------------ lib/core/playcmds/play_all.v | 2 - 14 files changed, 182 insertions(+), 561 deletions(-) create mode 100644 examples/ai/openai/README.md rename examples/ai/{openrouter/openrouter_example.vsh => openai/openai_example.vsh} (75%) rename examples/ai/{openrouter/openrouter_hello.vsh => openai/openai_hello.vsh} (74%) rename examples/ai/{openrouter/openrouter_init.vsh => openai/openai_init.vsh} (84%) rename examples/ai/{openrouter/openrouter_two_model_pipeline.v => openai/openai_two_model_pipeline.vsh} (71%) delete mode 100644 examples/ai/openrouter/README.md delete mode 100644 lib/clients/openrouter/.heroscript delete mode 100644 lib/clients/openrouter/client_test.v delete mode 100644 lib/clients/openrouter/completions.v delete mode 100644 lib/clients/openrouter/openrouter_factory_.v delete mode 100644 lib/clients/openrouter/openrouter_model.v delete mode 100644 lib/clients/openrouter/openrouter_raw.v delete mode 100644 lib/clients/openrouter/readme.md diff --git a/examples/ai/openai/README.md b/examples/ai/openai/README.md new file mode 100644 index 00000000..99d36aeb --- /dev/null +++ b/examples/ai/openai/README.md @@ -0,0 +1,120 @@ +# OpenRouter Examples - Proof of Concept + +## Overview + +This folder contains **example scripts** demonstrating how to use the **OpenAI client** (`herolib.clients.openai`) configured to work with **OpenRouter**. + +* **Goal:** Show how to send messages to OpenRouter models using the OpenAI client, run a **two-model pipeline** for code enhancement, and illustrate multi-model usage. +* **Key Insight:** The OpenAI client is OpenRouter-compatible by design - simply configure it with OpenRouter's base URL (`https://openrouter.ai/api/v1`) and API key. + +--- + +## Configuration + +All examples configure the OpenAI client to use OpenRouter by setting: + +* **URL**: `https://openrouter.ai/api/v1` +* **API Key**: Read from `OPENROUTER_API_KEY` environment variable +* **Model**: OpenRouter model IDs (e.g., `qwen/qwen-2.5-coder-32b-instruct`) + +Example configuration: + +```v +playcmds.run( + heroscript: ' + !!openai.configure + name: "default" + url: "https://openrouter.ai/api/v1" + model_default: "qwen/qwen-2.5-coder-32b-instruct" + ' +)! +``` + +--- + +## Example Scripts + +### 1. `openai_init.vsh` + +* **Purpose:** Basic initialization example showing OpenAI client configured for OpenRouter. +* **Demonstrates:** Client configuration and simple chat completion. +* **Usage:** + +```bash +examples/ai/openai/openai_init.vsh +``` + +--- + +### 2. `openai_hello.vsh` + +* **Purpose:** Simple hello message to OpenRouter. +* **Demonstrates:** Sending a single message using `client.chat_completion`. +* **Usage:** + +```bash +examples/ai/openai/openai_hello.vsh +``` + +* **Expected output:** A friendly "hello" response from the AI and token usage. + +--- + +### 3. `openai_example.vsh` + +* **Purpose:** Demonstrates basic conversation features. +* **Demonstrates:** + * Sending a single message + * Using system + user messages for conversation context + * Printing token usage +* **Usage:** + +```bash +examples/ai/openai/openai_example.vsh +``` + +* **Expected output:** Responses from the AI for both simple and system-prompt conversations. + +--- + +### 4. `openai_two_model_pipeline.vsh` + +* **Purpose:** Two-model code enhancement pipeline (proof of concept). +* **Demonstrates:** + * Model A (`Qwen3 Coder`) suggests code improvements. + * Model B (`morph-v3-fast`) applies the suggested edits. + * Tracks tokens and shows before/after code. + * Using two separate OpenAI client instances with different models +* **Usage:** + +```bash +examples/ai/openai/openai_two_model_pipeline.vsh +``` + +* **Expected output:** + * Original code + * Suggested edits + * Final updated code + * Token usage summary + +--- + +## Environment Variables + +Set your OpenRouter API key before running the examples: + +```bash +export OPENROUTER_API_KEY="sk-or-v1-..." +``` + +The OpenAI client automatically detects when the URL contains "openrouter" and will use the `OPENROUTER_API_KEY` environment variable. + +--- + +## Notes + +1. **No separate OpenRouter client needed** - The OpenAI client is fully compatible with OpenRouter's API. +2. All scripts configure the OpenAI client with OpenRouter's base URL. +3. The two-model pipeline uses **two separate client instances** (one per model) to demonstrate multi-model workflows. +4. Scripts can be run individually using the `v -enable-globals run` command. +5. The two-model pipeline is a **proof of concept**; the flow can later be extended to multiple files or OpenRPC specs. diff --git a/examples/ai/openrouter/openrouter_example.vsh b/examples/ai/openai/openai_example.vsh similarity index 75% rename from examples/ai/openrouter/openrouter_example.vsh rename to examples/ai/openai/openai_example.vsh index 5771adc8..add6937e 100755 --- a/examples/ai/openrouter/openrouter_example.vsh +++ b/examples/ai/openai/openai_example.vsh @@ -1,12 +1,22 @@ #!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run -import incubaid.herolib.clients.openrouter +import incubaid.herolib.clients.openai import incubaid.herolib.core.playcmds -// Get the client instance -mut client := openrouter.get()! +// Configure OpenAI client to use OpenRouter +playcmds.run( + heroscript: ' + !!openai.configure + name: "default" + url: "https://openrouter.ai/api/v1" + model_default: "qwen/qwen-2.5-coder-32b-instruct" + ' +)! -println('šŸ¤– OpenRouter Client Example') +// Get the client instance +mut client := openai.get()! + +println('šŸ¤– OpenRouter Client Example (using OpenAI client)') println('═'.repeat(50)) println('') @@ -29,11 +39,11 @@ println('─'.repeat(50)) r = client.chat_completion( model: 'qwen/qwen-2.5-coder-32b-instruct' messages: [ - openrouter.Message{ + openai.Message{ role: .system content: 'You are a helpful coding assistant who speaks concisely.' }, - openrouter.Message{ + openai.Message{ role: .user content: 'What is V programming language?' }, diff --git a/examples/ai/openrouter/openrouter_hello.vsh b/examples/ai/openai/openai_hello.vsh similarity index 74% rename from examples/ai/openrouter/openrouter_hello.vsh rename to examples/ai/openai/openai_hello.vsh index 83247892..1fa04877 100755 --- a/examples/ai/openrouter/openrouter_hello.vsh +++ b/examples/ai/openai/openai_hello.vsh @@ -1,10 +1,20 @@ #!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run -import incubaid.herolib.clients.openrouter +import incubaid.herolib.clients.openai import incubaid.herolib.core.playcmds +// Configure OpenAI client to use OpenRouter +playcmds.run( + heroscript: ' + !!openai.configure + name: "default" + url: "https://openrouter.ai/api/v1" + model_default: "qwen/qwen-2.5-coder-32b-instruct" + ' +)! + // Get the client instance -mut client := openrouter.get() or { +mut client := openai.get() or { eprintln('Failed to get client: ${err}') return } diff --git a/examples/ai/openrouter/openrouter_init.vsh b/examples/ai/openai/openai_init.vsh similarity index 84% rename from examples/ai/openrouter/openrouter_init.vsh rename to examples/ai/openai/openai_init.vsh index 8b2f17b3..dadb2622 100755 --- a/examples/ai/openrouter/openrouter_init.vsh +++ b/examples/ai/openai/openai_init.vsh @@ -3,7 +3,7 @@ import incubaid.herolib.clients.openai import incubaid.herolib.core.playcmds -//to set the API key, either set it here, or set the OPENAI_API_KEY environment variable +// to set the API key, either set it here, or set the OPENAI_API_KEY environment variable playcmds.run( heroscript: ' @@ -20,3 +20,5 @@ mut r := client.chat_completion( temperature: 0.3 max_completion_tokens: 1024 )! + +println(r.result) diff --git a/examples/ai/openrouter/openrouter_two_model_pipeline.v b/examples/ai/openai/openai_two_model_pipeline.vsh similarity index 71% rename from examples/ai/openrouter/openrouter_two_model_pipeline.v rename to examples/ai/openai/openai_two_model_pipeline.vsh index 3850c111..38b097db 100755 --- a/examples/ai/openrouter/openrouter_two_model_pipeline.v +++ b/examples/ai/openai/openai_two_model_pipeline.vsh @@ -1,6 +1,6 @@ #!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run -import incubaid.herolib.clients.openrouter +import incubaid.herolib.clients.openai import incubaid.herolib.core.playcmds // Sample code file to be improved @@ -19,23 +19,41 @@ def find_max(lst): return max ' -mut modifier := openrouter.get(name: 'modifier', create: true) or { - panic('Failed to get modifier client: ${err}') -} +// Configure two OpenAI client instances to use OpenRouter with different models +// Model A: Enhancement model (Qwen Coder) +playcmds.run( + heroscript: ' + !!openai.configure + name: "enhancer" + url: "https://openrouter.ai/api/v1" + model_default: "qwen/qwen-2.5-coder-32b-instruct" + ' +)! -mut enhancer := openrouter.get(name: 'enhancer', create: true) or { - panic('Failed to get enhancer client: ${err}') -} +// Model B: Modification model (Llama 3.3 70B) +playcmds.run( + heroscript: ' + !!openai.configure + name: "modifier" + url: "https://openrouter.ai/api/v1" + model_default: "meta-llama/llama-3.3-70b-instruct" + ' +)! + +mut enhancer := openai.get(name: 'enhancer') or { panic('Failed to get enhancer client: ${err}') } + +mut modifier := openai.get(name: 'modifier') or { panic('Failed to get modifier client: ${err}') } println('═'.repeat(70)) println('šŸ”§ Two-Model Code Enhancement Pipeline - Proof of Concept') +println('šŸ”§ Using OpenAI client configured for OpenRouter') println('═'.repeat(70)) println('') -// Step 1: Get enhancement suggestions from Model A (Qwen3 Coder 480B) +// Step 1: Get enhancement suggestions from Model A (Qwen Coder) println('šŸ“ STEP 1: Code Enhancement Analysis') println('─'.repeat(70)) -println('Model: Qwen3 Coder 480B A35B') +println('Model: qwen/qwen-2.5-coder-32b-instruct') println('Task: Analyze code and suggest improvements\n') enhancement_prompt := 'You are a code enhancement agent. @@ -68,10 +86,10 @@ println(enhancement_result.result) println('─'.repeat(70)) println('Tokens used: ${enhancement_result.usage.total_tokens}\n') -// Step 2: Apply edits using Model B (morph-v3-fast) +// Step 2: Apply edits using Model B (Llama 3.3 70B) println('\nšŸ“ STEP 2: Apply Code Modifications') println('─'.repeat(70)) -println('Model: morph-v3-fast') +println('Model: meta-llama/llama-3.3-70b-instruct') println('Task: Apply the suggested edits to produce updated code\n') modification_prompt := 'You are a file editing agent. @@ -106,9 +124,9 @@ println('Tokens used: ${modification_result.usage.total_tokens}\n') println('\nšŸ“Š PIPELINE SUMMARY') println('═'.repeat(70)) println('Original code length: ${sample_code.len} chars') -println('Enhancement model: qwen/qwq-32b-preview (Qwen3 Coder 480B A35B)') +println('Enhancement model: qwen/qwen-2.5-coder-32b-instruct') println('Enhancement tokens: ${enhancement_result.usage.total_tokens}') -println('Modification model: neversleep/llama-3.3-70b-instruct (morph-v3-fast)') +println('Modification model: meta-llama/llama-3.3-70b-instruct') println('Modification tokens: ${modification_result.usage.total_tokens}') println('Total tokens: ${enhancement_result.usage.total_tokens + modification_result.usage.total_tokens}') diff --git a/examples/ai/openrouter/README.md b/examples/ai/openrouter/README.md deleted file mode 100644 index c3c31327..00000000 --- a/examples/ai/openrouter/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# OpenRouter Examples - Proof of Concept - -## Overview - -This folder contains **example scripts** demonstrating the usage of the OpenRouter V client (`herolib.clients.openrouter`). - -* **Goal:** Show how to send messages to OpenRouter models, run a **two-model pipeline** for code enhancement, and illustrate multi-model usage. - ---- - -## Example Scripts - -### 1. `say_hello.vsh` - -* **Purpose:** Simple hello message to OpenRouter. -* **Demonstrates:** Sending a single message using `client.chat_completion`. -* **Usage:** - -```bash -examples/clients/openrouter/openrouter_hello.vsh -``` - -* **Expected output:** A friendly "hello" response from the AI and token usage. - ---- - -### 2. `openrouter_example.vsh` - -* **Purpose:** Demonstrates basic conversation features. -* **Demonstrates:** - - * Sending a single message - * Using system + user messages for conversation context - * Printing token usage -* **Usage:** - -```bash -examples/clients/openrouter/openrouter_example.vsh -``` - -* **Expected output:** Responses from the AI for both simple and system-prompt conversations. - ---- - -### 3. `openrouter_two_model_pipeline.vsh` - -* **Purpose:** Two-model code enhancement pipeline (proof of concept). -* **Demonstrates:** - - * Model A (`Qwen3 Coder`) suggests code improvements. - * Model B (`morph-v3-fast`) applies the suggested edits. - * Tracks tokens and shows before/after code. -* **Usage:** - -```bash -examples/clients/openrouter/openrouter_two_model_pipeline.vsh -``` - -* **Expected output:** - - * Original code - * Suggested edits - * Final updated code - * Token usage summary - ---- - -## Notes - -1. Ensure your **OpenRouter API key** is set: - -```bash -export OPENROUTER_API_KEY="sk-or-v1-..." -``` - -2. All scripts use the **same OpenRouter client** instance for simplicity, except the two-model pipeline which uses **two separate client instances** (one per model). -3. Scripts can be run individually using the `v -enable-globals run` command. -4. The two-model pipeline is a **proof of concept**; the flow can later be extended to multiple files or OpenRPC specs. diff --git a/lib/clients/openrouter/.heroscript b/lib/clients/openrouter/.heroscript deleted file mode 100644 index 28da0316..00000000 --- a/lib/clients/openrouter/.heroscript +++ /dev/null @@ -1,7 +0,0 @@ -!!hero_code.generate_client - name:'openrouter' - classname:'OpenRouter' - singleton:0 - default:1 - hasconfig:1 - reset:0 \ No newline at end of file diff --git a/lib/clients/openrouter/client_test.v b/lib/clients/openrouter/client_test.v deleted file mode 100644 index a37d5f66..00000000 --- a/lib/clients/openrouter/client_test.v +++ /dev/null @@ -1,13 +0,0 @@ -module openrouter - -fn test_factory() { - mut client := get(name: 'default', create: true)! - assert client.name == 'default' - assert client.url == 'https://openrouter.ai/api/v1' - assert client.model_default == 'qwen/qwen-2.5-coder-32b-instruct' -} - -fn test_client_creation() { - mut client := new(name: 'test_client')! - assert client.name == 'test_client' -} diff --git a/lib/clients/openrouter/completions.v b/lib/clients/openrouter/completions.v deleted file mode 100644 index 47f38167..00000000 --- a/lib/clients/openrouter/completions.v +++ /dev/null @@ -1,97 +0,0 @@ -module openrouter - -import json - -@[params] -pub struct CompletionArgs { -pub mut: - model string - messages []Message // optional because we can use message, which means we just pass a string - message string - temperature f64 = 0.2 - max_completion_tokens int = 32000 -} - -pub struct Message { -pub mut: - role RoleType - content string -} - -pub enum RoleType { - system - user - assistant - function -} - -fn roletype_str(x RoleType) string { - return match x { - .system { - 'system' - } - .user { - 'user' - } - .assistant { - 'assistant' - } - .function { - 'function' - } - } -} - -pub struct ChatCompletion { -pub mut: - id string - created u32 - result string - usage Usage -} - -// creates a new chat completion given a list of messages -// each message consists of message content and the role of the author -pub fn (mut f OpenRouter) chat_completion(args_ CompletionArgs) !ChatCompletion { - mut args := args_ - if args.model == '' { - args.model = f.model_default - } - mut m := ChatMessagesRaw{ - model: args.model - temperature: args.temperature - max_completion_tokens: args.max_completion_tokens - } - for msg in args.messages { - mr := MessageRaw{ - role: roletype_str(msg.role) - content: msg.content - } - m.messages << mr - } - if args.message != '' { - mr := MessageRaw{ - role: 'user' - content: args.message - } - m.messages << mr - } - data := json.encode(m) - mut conn := f.connection()! - r := conn.post_json_str(prefix: 'chat/completions', data: data)! - - res := json.decode(ChatCompletionRaw, r)! - - mut result := '' - for choice in res.choices { - result += choice.message.content - } - - mut chat_completion_result := ChatCompletion{ - id: res.id - created: res.created - result: result - usage: res.usage - } - return chat_completion_result -} diff --git a/lib/clients/openrouter/openrouter_factory_.v b/lib/clients/openrouter/openrouter_factory_.v deleted file mode 100644 index 1af759f2..00000000 --- a/lib/clients/openrouter/openrouter_factory_.v +++ /dev/null @@ -1,140 +0,0 @@ -module openrouter - -import incubaid.herolib.core.base -import incubaid.herolib.core.playbook { PlayBook } -import incubaid.herolib.ui.console -import json - -__global ( - openrouter_global map[string]&OpenRouter - openrouter_default string -) - -/////////FACTORY - -@[params] -pub struct ArgsGet { -pub mut: - name string = 'default' - fromdb bool // will load from filesystem - create bool // default will not create if not exist -} - -pub fn new(args ArgsGet) !&OpenRouter { - mut obj := OpenRouter{ - name: args.name - } - set(obj)! - return get(name: args.name)! -} - -pub fn get(args ArgsGet) !&OpenRouter { - mut context := base.context()! - openrouter_default = args.name - if args.fromdb || args.name !in openrouter_global { - mut r := context.redis()! - if r.hexists('context:openrouter', args.name)! { - data := r.hget('context:openrouter', args.name)! - if data.len == 0 { - print_backtrace() - return error('OpenRouter with name: ${args.name} does not exist, prob bug.') - } - mut obj := json.decode(OpenRouter, data)! - set_in_mem(obj)! - } else { - if args.create { - new(args)! - } else { - print_backtrace() - return error("OpenRouter with name '${args.name}' does not exist") - } - } - return get(name: args.name)! // no longer from db nor create - } - return openrouter_global[args.name] or { - print_backtrace() - return error('could not get config for openrouter with name:${args.name}') - } -} - -// register the config for the future -pub fn set(o OpenRouter) ! { - mut o2 := set_in_mem(o)! - openrouter_default = o2.name - mut context := base.context()! - mut r := context.redis()! - r.hset('context:openrouter', o2.name, json.encode(o2))! -} - -// does the config exists? -pub fn exists(args ArgsGet) !bool { - mut context := base.context()! - mut r := context.redis()! - return r.hexists('context:openrouter', args.name)! -} - -pub fn delete(args ArgsGet) ! { - mut context := base.context()! - mut r := context.redis()! - r.hdel('context:openrouter', args.name)! -} - -@[params] -pub struct ArgsList { -pub mut: - fromdb bool // will load from filesystem -} - -// if fromdb set: load from filesystem, and not from mem, will also reset what is in mem -pub fn list(args ArgsList) ![]&OpenRouter { - mut res := []&OpenRouter{} - mut context := base.context()! - if args.fromdb { - // reset what is in mem - openrouter_global = map[string]&OpenRouter{} - openrouter_default = '' - } - if args.fromdb { - mut r := context.redis()! - mut l := r.hkeys('context:openrouter')! - - for name in l { - res << get(name: name, fromdb: true)! - } - return res - } else { - // load from memory - for _, client in openrouter_global { - res << client - } - } - return res -} - -// only sets in mem, does not set as config -fn set_in_mem(o OpenRouter) !OpenRouter { - mut o2 := obj_init(o)! - openrouter_global[o2.name] = &o2 - openrouter_default = o2.name - return o2 -} - -pub fn play(mut plbook PlayBook) ! { - if !plbook.exists(filter: 'openrouter.') { - return - } - mut install_actions := plbook.find(filter: 'openrouter.configure')! - if install_actions.len > 0 { - for mut install_action in install_actions { - heroscript := install_action.heroscript() - mut obj2 := heroscript_loads(heroscript)! - set(obj2)! - install_action.done = true - } - } -} - -// switch instance to be used for openrouter -pub fn switch(name string) { - openrouter_default = name -} diff --git a/lib/clients/openrouter/openrouter_model.v b/lib/clients/openrouter/openrouter_model.v deleted file mode 100644 index c6c44d63..00000000 --- a/lib/clients/openrouter/openrouter_model.v +++ /dev/null @@ -1,67 +0,0 @@ -module openrouter - -import incubaid.herolib.data.encoderhero -import incubaid.herolib.core.httpconnection -import os - -pub const version = '0.0.0' -const singleton = false -const default = true - -@[heap] -pub struct OpenRouter { -pub mut: - name string = 'default' - api_key string - url string = 'https://openrouter.ai/api/v1' - model_default string = 'qwen/qwen-2.5-coder-32b-instruct' -} - -// your checking & initialization code if needed -fn obj_init(mycfg_ OpenRouter) !OpenRouter { - mut mycfg := mycfg_ - if mycfg.model_default == '' { - k := os.getenv('OPENROUTER_AI_MODEL') - if k != '' { - mycfg.model_default = k - } - } - - if mycfg.url == '' { - k := os.getenv('OPENROUTER_URL') - if k != '' { - mycfg.url = k - } - } - if mycfg.api_key == '' { - k := os.getenv('OPENROUTER_API_KEY') - if k != '' { - mycfg.api_key = k - } else { - return error('OPENROUTER_API_KEY environment variable not set') - } - } - return mycfg -} - -pub fn (mut client OpenRouter) connection() !&httpconnection.HTTPConnection { - mut c2 := httpconnection.new( - name: 'openrouterconnection_${client.name}' - url: client.url - cache: false - retry: 20 - )! - c2.default_header.set(.authorization, 'Bearer ${client.api_key}') - return c2 -} - -/////////////NORMALLY NO NEED TO TOUCH - -pub fn heroscript_dumps(obj OpenRouter) !string { - return encoderhero.encode[OpenRouter](obj)! -} - -pub fn heroscript_loads(heroscript string) !OpenRouter { - mut obj := encoderhero.decode[OpenRouter](heroscript)! - return obj -} diff --git a/lib/clients/openrouter/openrouter_raw.v b/lib/clients/openrouter/openrouter_raw.v deleted file mode 100644 index 21090abd..00000000 --- a/lib/clients/openrouter/openrouter_raw.v +++ /dev/null @@ -1,38 +0,0 @@ -module openrouter - -struct ChatCompletionRaw { -mut: - id string - object string - created u32 - choices []ChoiceRaw - usage Usage -} - -struct ChoiceRaw { -mut: - index int - message MessageRaw - finish_reason string -} - -struct MessageRaw { -mut: - role string - content string -} - -struct ChatMessagesRaw { -mut: - model string - messages []MessageRaw - temperature f64 = 0.5 - max_completion_tokens int = 32000 -} - -pub struct Usage { -pub mut: - prompt_tokens int - completion_tokens int - total_tokens int -} diff --git a/lib/clients/openrouter/readme.md b/lib/clients/openrouter/readme.md deleted file mode 100644 index cc331594..00000000 --- a/lib/clients/openrouter/readme.md +++ /dev/null @@ -1,97 +0,0 @@ -# OpenRouter V Client - -A V client for the OpenRouter API, providing access to multiple AI models through a unified interface. - -## Quick Start - -```v -import incubaid.herolib.clients.openrouter -import incubaid.herolib.core.playcmds - -// Configure client (key can be read from env vars) -playcmds.run( - heroscript: ' - !!openrouter.configure name:"default" - key:"${YOUR_OPENROUTER_KEY}" - url:"https://openrouter.ai/api/v1" - model_default:"qwen/qwen-2.5-coder-32b-instruct" - ' - reset: false -)! - -mut client := openrouter.get()! - -// Simple chat example -resp := client.chat_completion( - model: "qwen/qwen-2.5-coder-32b-instruct" - message: "Hello, world!" - temperature: 0.6 -)! - -println('Answer: ${resp.result}') -``` - -## Environment Variables - -The client automatically reads API keys from environment variables if not explicitly configured: - -- `OPENROUTER_API_KEY` - OpenRouter API key -- `AIKEY` - Alternative API key variable -- `AIURL` - API base URL (defaults to `https://openrouter.ai/api/v1`) -- `AIMODEL` - Default model (defaults to `qwen/qwen-2.5-coder-32b-instruct`) - -## Example with Multiple Messages - -```v -import incubaid.herolib.clients.openrouter - -mut client := openrouter.get()! - -resp := client.chat_completion( - messages: [ - openrouter.Message{ - role: .system - content: 'You are a helpful coding assistant.' - }, - openrouter.Message{ - role: .user - content: 'Write a hello world in V' - }, - ] - temperature: 0.3 - max_completion_tokens: 1024 -)! - -println(resp.result) -``` - -## Configuration via Heroscript - -```hero -!!openrouter.configure - name: "default" - key: "sk-or-v1-..." - url: "https://openrouter.ai/api/v1" - model_default: "qwen/qwen-2.5-coder-32b-instruct" -``` - -## Features - -- **Chat Completion**: Generate text completions using various AI models -- **Multiple Models**: Access to OpenRouter's extensive model catalog -- **Environment Variable Support**: Automatic configuration from environment -- **Factory Pattern**: Manage multiple client instances -- **Retry Logic**: Built-in retry mechanism for failed requests - -## Available Models - -OpenRouter provides access to many models including: - -- `qwen/qwen-2.5-coder-32b-instruct` - Qwen 2.5 Coder (default) -- `anthropic/claude-3.5-sonnet` -- `openai/gpt-4-turbo` -- `google/gemini-pro` -- `meta-llama/llama-3.1-70b-instruct` -- And many more... - -Check the [OpenRouter documentation](https://openrouter.ai/docs) for the full list of available models. diff --git a/lib/core/playcmds/play_all.v b/lib/core/playcmds/play_all.v index b3dfcd85..8632e5a3 100644 --- a/lib/core/playcmds/play_all.v +++ b/lib/core/playcmds/play_all.v @@ -10,7 +10,6 @@ import incubaid.herolib.clients.meilisearch import incubaid.herolib.clients.mycelium import incubaid.herolib.clients.mycelium_rpc import incubaid.herolib.clients.openai -import incubaid.herolib.clients.openrouter import incubaid.herolib.clients.postgresql_client import incubaid.herolib.clients.qdrant import incubaid.herolib.clients.rcloneclient @@ -66,7 +65,6 @@ pub fn run_all(args_ PlayArgs) ! { mycelium.play(mut plbook)! mycelium_rpc.play(mut plbook)! openai.play(mut plbook)! - openrouter.play(mut plbook)! postgresql_client.play(mut plbook)! qdrant.play(mut plbook)! rcloneclient.play(mut plbook)! From 79b78aa6fe3280796c484e466ae5801af0496ef1 Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Wed, 29 Oct 2025 13:32:43 +0300 Subject: [PATCH 2/7] feat: Implement Kubernetes installer for kubectl - Add install functionality for kubectl - Implement destroy functionality for kubectl - Add platform-specific download URLs for kubectl - Ensure .kube directory is created with correct permissions --- examples/installers/virt/kubernetes.vsh | 11 ++ .../virt/kubernetes_installer/.heroscript | 12 ++ .../kubernetes_installer_actions.v | 120 ++++++++++++ .../kubernetes_installer_factory_.v | 179 ++++++++++++++++++ .../kubernetes_installer_model.v | 36 ++++ .../virt/kubernetes_installer/readme.md | 44 +++++ .../templates/atemplate.yaml | 5 + 7 files changed, 407 insertions(+) create mode 100755 examples/installers/virt/kubernetes.vsh create mode 100644 lib/installers/virt/kubernetes_installer/.heroscript create mode 100644 lib/installers/virt/kubernetes_installer/kubernetes_installer_actions.v create mode 100644 lib/installers/virt/kubernetes_installer/kubernetes_installer_factory_.v create mode 100644 lib/installers/virt/kubernetes_installer/kubernetes_installer_model.v create mode 100644 lib/installers/virt/kubernetes_installer/readme.md create mode 100644 lib/installers/virt/kubernetes_installer/templates/atemplate.yaml diff --git a/examples/installers/virt/kubernetes.vsh b/examples/installers/virt/kubernetes.vsh new file mode 100755 index 00000000..077c8955 --- /dev/null +++ b/examples/installers/virt/kubernetes.vsh @@ -0,0 +1,11 @@ +#!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run + +import incubaid.herolib.installers.virt.kubernetes_installer + +mut kubectl := kubernetes_installer.get(name: 'k_installer', create: true)! + +// To install +kubectl.install()! + +// To remove +kubectl.destroy()! diff --git a/lib/installers/virt/kubernetes_installer/.heroscript b/lib/installers/virt/kubernetes_installer/.heroscript new file mode 100644 index 00000000..686474a0 --- /dev/null +++ b/lib/installers/virt/kubernetes_installer/.heroscript @@ -0,0 +1,12 @@ + +!!hero_code.generate_installer + name:'' + classname:'KubernetesInstaller' + singleton:1 + templates:1 + default:1 + title:'' + supported_platforms:'' + startupmanager:0 + hasconfig:1 + build:0 \ No newline at end of file diff --git a/lib/installers/virt/kubernetes_installer/kubernetes_installer_actions.v b/lib/installers/virt/kubernetes_installer/kubernetes_installer_actions.v new file mode 100644 index 00000000..e10a2fb0 --- /dev/null +++ b/lib/installers/virt/kubernetes_installer/kubernetes_installer_actions.v @@ -0,0 +1,120 @@ +module kubernetes_installer + +import incubaid.herolib.osal.core as osal +import incubaid.herolib.ui.console +import incubaid.herolib.core.texttools +import incubaid.herolib.core +import incubaid.herolib.installers.ulist +import os + +//////////////////// following actions are not specific to instance of the object + +// checks if kubectl is installed and meets minimum version requirement +fn installed() !bool { + if !osal.cmd_exists('kubectl') { + return false + } + + res := os.execute('${osal.profile_path_source_and()!} kubectl version --client --output=json') + if res.exit_code != 0 { + // Try older kubectl version command format + res2 := os.execute('${osal.profile_path_source_and()!} kubectl version --client --short') + if res2.exit_code != 0 { + return false + } + // Parse version from output like "Client Version: v1.31.0" + lines := res2.output.split_into_lines().filter(it.contains('Client Version')) + if lines.len == 0 { + return false + } + version_str := lines[0].all_after('v').trim_space() + if texttools.version(version) <= texttools.version(version_str) { + return true + } + return false + } + + // For newer kubectl versions with JSON output + // Just check if kubectl exists and runs - version checking is optional + return true +} + +// get the Upload List of the files +fn ulist_get() !ulist.UList { + return ulist.UList{} +} + +// uploads to S3 server if configured +fn upload() ! { + // Not applicable for kubectl +} + +fn install() ! { + console.print_header('install kubectl') + + mut url := '' + mut dest_path := '/tmp/kubectl' + + // Determine download URL based on platform + if core.is_linux_arm()! { + url = 'https://dl.k8s.io/release/v${version}/bin/linux/arm64/kubectl' + } else if core.is_linux_intel()! { + url = 'https://dl.k8s.io/release/v${version}/bin/linux/amd64/kubectl' + } else if core.is_osx_arm()! { + url = 'https://dl.k8s.io/release/v${version}/bin/darwin/arm64/kubectl' + } else if core.is_osx_intel()! { + url = 'https://dl.k8s.io/release/v${version}/bin/darwin/amd64/kubectl' + } else { + return error('unsupported platform for kubectl installation') + } + + console.print_header('downloading kubectl from ${url}') + + // Download kubectl binary + osal.download( + url: url + // minsize_kb: 40000 // kubectl is ~45MB + dest: dest_path + )! + + // Make it executable + os.chmod(dest_path, 0o755)! + + // Install to system + osal.cmd_add( + cmdname: 'kubectl' + source: dest_path + )! + + // Create .kube directory with proper permissions + kube_dir := os.join_path(os.home_dir(), '.kube') + if !os.exists(kube_dir) { + console.print_header('creating ${kube_dir} directory') + os.mkdir_all(kube_dir)! + os.chmod(kube_dir, 0o700)! // read/write/execute for owner only + console.print_header('${kube_dir} directory created with permissions 0700') + } else { + // Ensure correct permissions even if directory exists + os.chmod(kube_dir, 0o700)! + console.print_header('${kube_dir} directory permissions set to 0700') + } + + console.print_header('kubectl installed successfully') +} + +fn destroy() ! { + console.print_header('destroy kubectl') + + if !installed()! { + console.print_header('kubectl is not installed') + return + } + + // Remove kubectl command + osal.cmd_delete('kubectl')! + + // Clean up any temporary files + osal.rm('/tmp/kubectl')! + + console.print_header('kubectl destruction completed') +} diff --git a/lib/installers/virt/kubernetes_installer/kubernetes_installer_factory_.v b/lib/installers/virt/kubernetes_installer/kubernetes_installer_factory_.v new file mode 100644 index 00000000..e90c7d12 --- /dev/null +++ b/lib/installers/virt/kubernetes_installer/kubernetes_installer_factory_.v @@ -0,0 +1,179 @@ +module kubernetes_installer + +import incubaid.herolib.core.base +import incubaid.herolib.core.playbook { PlayBook } +import incubaid.herolib.ui.console +import json + +__global ( + kubernetes_installer_global map[string]&KubernetesInstaller + kubernetes_installer_default string +) + +/////////FACTORY + +@[params] +pub struct ArgsGet { +pub mut: + name string = 'default' + fromdb bool // will load from filesystem + create bool // default will not create if not exist +} + +pub fn new(args ArgsGet) !&KubernetesInstaller { + mut obj := KubernetesInstaller{ + name: args.name + } + set(obj)! + return get(name: args.name)! +} + +pub fn get(args ArgsGet) !&KubernetesInstaller { + mut context := base.context()! + kubernetes_installer_default = args.name + if args.fromdb || args.name !in kubernetes_installer_global { + mut r := context.redis()! + if r.hexists('context:kubernetes_installer', args.name)! { + data := r.hget('context:kubernetes_installer', args.name)! + if data.len == 0 { + print_backtrace() + return error('KubernetesInstaller with name: ${args.name} does not exist, prob bug.') + } + mut obj := json.decode(KubernetesInstaller, data)! + set_in_mem(obj)! + } else { + if args.create { + new(args)! + } else { + print_backtrace() + return error("KubernetesInstaller with name '${args.name}' does not exist") + } + } + return get(name: args.name)! // no longer from db nor create + } + return kubernetes_installer_global[args.name] or { + print_backtrace() + return error('could not get config for kubernetes_installer with name:${args.name}') + } +} + +// register the config for the future +pub fn set(o KubernetesInstaller) ! { + mut o2 := set_in_mem(o)! + kubernetes_installer_default = o2.name + mut context := base.context()! + mut r := context.redis()! + r.hset('context:kubernetes_installer', o2.name, json.encode(o2))! +} + +// does the config exists? +pub fn exists(args ArgsGet) !bool { + mut context := base.context()! + mut r := context.redis()! + return r.hexists('context:kubernetes_installer', args.name)! +} + +pub fn delete(args ArgsGet) ! { + mut context := base.context()! + mut r := context.redis()! + r.hdel('context:kubernetes_installer', args.name)! +} + +@[params] +pub struct ArgsList { +pub mut: + fromdb bool // will load from filesystem +} + +// if fromdb set: load from filesystem, and not from mem, will also reset what is in mem +pub fn list(args ArgsList) ![]&KubernetesInstaller { + mut res := []&KubernetesInstaller{} + mut context := base.context()! + if args.fromdb { + // reset what is in mem + kubernetes_installer_global = map[string]&KubernetesInstaller{} + kubernetes_installer_default = '' + } + if args.fromdb { + mut r := context.redis()! + mut l := r.hkeys('context:kubernetes_installer')! + + for name in l { + res << get(name: name, fromdb: true)! + } + return res + } else { + // load from memory + for _, client in kubernetes_installer_global { + res << client + } + } + return res +} + +// only sets in mem, does not set as config +fn set_in_mem(o KubernetesInstaller) !KubernetesInstaller { + mut o2 := obj_init(o)! + kubernetes_installer_global[o2.name] = &o2 + kubernetes_installer_default = o2.name + return o2 +} + +pub fn play(mut plbook PlayBook) ! { + if !plbook.exists(filter: 'kubernetes_installer.') { + return + } + mut install_actions := plbook.find(filter: 'kubernetes_installer.configure')! + if install_actions.len > 0 { + return error("can't configure kubernetes_installer, because no configuration allowed for this installer.") + } + mut other_actions := plbook.find(filter: 'kubernetes_installer.')! + for mut other_action in other_actions { + if other_action.name in ['destroy', 'install'] { + mut p := other_action.params + reset := p.get_default_false('reset') + if other_action.name == 'destroy' || reset { + console.print_debug('install action kubernetes_installer.destroy') + destroy()! + } + if other_action.name == 'install' { + console.print_debug('install action kubernetes_installer.install') + install()! + } + } + other_action.done = true + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +//////////////////////////# LIVE CYCLE MANAGEMENT FOR INSTALLERS /////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////////////////// + +// load from disk and make sure is properly intialized +pub fn (mut self KubernetesInstaller) reload() ! { + switch(self.name) + self = obj_init(self)! +} + +@[params] +pub struct InstallArgs { +pub mut: + reset bool +} + +pub fn (mut self KubernetesInstaller) install(args InstallArgs) ! { + switch(self.name) + if args.reset || (!installed()!) { + install()! + } +} + +pub fn (mut self KubernetesInstaller) destroy() ! { + switch(self.name) + destroy()! +} + +// switch instance to be used for kubernetes_installer +pub fn switch(name string) { + kubernetes_installer_default = name +} diff --git a/lib/installers/virt/kubernetes_installer/kubernetes_installer_model.v b/lib/installers/virt/kubernetes_installer/kubernetes_installer_model.v new file mode 100644 index 00000000..4aed7a79 --- /dev/null +++ b/lib/installers/virt/kubernetes_installer/kubernetes_installer_model.v @@ -0,0 +1,36 @@ +module kubernetes_installer + +import incubaid.herolib.data.encoderhero + +pub const version = '1.31.0' +const singleton = true +const default = true + +// Kubernetes installer - handles kubectl installation +@[heap] +pub struct KubernetesInstaller { +pub mut: + name string = 'default' +} + +// your checking & initialization code if needed +fn obj_init(mycfg_ KubernetesInstaller) !KubernetesInstaller { + mut mycfg := mycfg_ + return mycfg +} + +// called before start if done +fn configure() ! { + // No configuration needed for kubectl +} + +/////////////NORMALLY NO NEED TO TOUCH + +pub fn heroscript_dumps(obj KubernetesInstaller) !string { + return encoderhero.encode[KubernetesInstaller](obj)! +} + +pub fn heroscript_loads(heroscript string) !KubernetesInstaller { + mut obj := encoderhero.decode[KubernetesInstaller](heroscript)! + return obj +} diff --git a/lib/installers/virt/kubernetes_installer/readme.md b/lib/installers/virt/kubernetes_installer/readme.md new file mode 100644 index 00000000..fb0ca674 --- /dev/null +++ b/lib/installers/virt/kubernetes_installer/readme.md @@ -0,0 +1,44 @@ +# kubernetes_installer + + + +To get started + +```v + + +import incubaid.herolib.installers.something.kubernetes_installer as kubernetes_installer_installer + +heroscript:=" +!!kubernetes_installer.configure name:'test' + password: '1234' + port: 7701 + +!!kubernetes_installer.start name:'test' reset:1 +" + +kubernetes_installer_installer.play(heroscript=heroscript)! + +//or we can call the default and do a start with reset +//mut installer:= kubernetes_installer_installer.get()! +//installer.start(reset:true)! + + + + +``` + +## example heroscript + + +```hero +!!kubernetes_installer.configure + homedir: '/home/user/kubernetes_installer' + username: 'admin' + password: 'secretpassword' + title: 'Some Title' + host: 'localhost' + port: 8888 + +``` + diff --git a/lib/installers/virt/kubernetes_installer/templates/atemplate.yaml b/lib/installers/virt/kubernetes_installer/templates/atemplate.yaml new file mode 100644 index 00000000..a4c386dd --- /dev/null +++ b/lib/installers/virt/kubernetes_installer/templates/atemplate.yaml @@ -0,0 +1,5 @@ + + +name: ${cfg.configpath} + + From c556cc71d4a2cdbde577c1c695d64fd1a791d4d8 Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Wed, 29 Oct 2025 16:46:37 +0300 Subject: [PATCH 3/7] feat: Implement Kubernetes client and example - Add Kubernetes client module for interacting with kubectl - Implement methods to get cluster info, pods, deployments, and services - Create a Kubernetes example script demonstrating client usage - Add JSON response structs for parsing kubectl output - Define runtime resource structs (Pod, Deployment, Service) for structured data - Include comprehensive unit tests for data structures and client logic --- .gitignore | 3 +- examples/virt/kubernetes/.gitignore | 1 + examples/virt/kubernetes/README.md | 177 ++++++++++ .../virt/kubernetes/kubernetes_example.vsh | 231 +++++++++++++ .../markdown/elements/element_frontmatter2.v | 2 - lib/virt/kubernetes/.heroscript | 2 +- lib/virt/kubernetes/kubernetes_actions.v | 59 ---- lib/virt/kubernetes/kubernetes_client.v | 214 +++++++++---- lib/virt/kubernetes/kubernetes_factory_.v | 173 ---------- lib/virt/kubernetes/kubernetes_model.v | 8 +- .../kubernetes/kubernetes_resources_model.v | 191 ++++++++++- lib/virt/kubernetes/kubernetes_test.v | 303 ++++++++++++++---- lib/virt/kubernetes/kubernetes_yaml.v | 3 - lib/virt/kubernetes/templates/atemplate.yaml | 5 - 14 files changed, 987 insertions(+), 385 deletions(-) create mode 100644 examples/virt/kubernetes/.gitignore create mode 100644 examples/virt/kubernetes/README.md create mode 100755 examples/virt/kubernetes/kubernetes_example.vsh delete mode 100644 lib/virt/kubernetes/kubernetes_actions.v delete mode 100644 lib/virt/kubernetes/templates/atemplate.yaml diff --git a/.gitignore b/.gitignore index 33eabe13..bf0ebdf4 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,5 @@ MCP_HTTP_REST_IMPLEMENTATION_PLAN.md tmux_logger release install_herolib -doc \ No newline at end of file +doc +priv_key.bin \ No newline at end of file diff --git a/examples/virt/kubernetes/.gitignore b/examples/virt/kubernetes/.gitignore new file mode 100644 index 00000000..1c2b83a7 --- /dev/null +++ b/examples/virt/kubernetes/.gitignore @@ -0,0 +1 @@ +kubernetes_example diff --git a/examples/virt/kubernetes/README.md b/examples/virt/kubernetes/README.md new file mode 100644 index 00000000..27c468a4 --- /dev/null +++ b/examples/virt/kubernetes/README.md @@ -0,0 +1,177 @@ +# Kubernetes Client Example + +This example demonstrates the Kubernetes client functionality in HeroLib, including JSON parsing and cluster interaction. + +## Prerequisites + +1. **kubectl installed**: The Kubernetes command-line tool must be installed on your system. + - macOS: `brew install kubectl` + - Linux: See [official installation guide](https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/) + - Windows: See [official installation guide](https://kubernetes.io/docs/tasks/tools/install-kubectl-windows/) + +2. **Kubernetes cluster**: You need access to a Kubernetes cluster. For local development, you can use: + - **Minikube**: `brew install minikube && minikube start` + - **Kind**: `brew install kind && kind create cluster` + - **Docker Desktop**: Enable Kubernetes in Docker Desktop settings + - **k3s**: Lightweight Kubernetes distribution + +## Running the Example + +### Method 1: Direct Execution (Recommended) + +```bash +# Make the script executable +chmod +x examples/virt/kubernetes/kubernetes_example.vsh + +# Run the script +./examples/virt/kubernetes/kubernetes_example.vsh +``` + +### Method 2: Using V Command + +```bash +v -enable-globals run examples/virt/kubernetes/kubernetes_example.vsh +``` + +## What the Example Demonstrates + +The example script demonstrates the following functionality: + +### 1. **Cluster Information** + +- Retrieves Kubernetes cluster version +- Counts total nodes in the cluster +- Counts total namespaces +- Counts running pods across all namespaces + +### 2. **Pod Management** + +- Lists all pods in the `default` namespace +- Displays pod details: + - Name, namespace, status + - Node assignment and IP address + - Container names + - Labels and creation timestamp + +### 3. **Deployment Management** + +- Lists all deployments in the `default` namespace +- Shows deployment information: + - Name and namespace + - Replica counts (desired, ready, available, updated) + - Labels and creation timestamp + +### 4. **Service Management** + +- Lists all services in the `default` namespace +- Displays service details: + - Name, namespace, and type (ClusterIP, NodePort, LoadBalancer) + - Cluster IP and external IP (if applicable) + - Exposed ports and protocols + - Labels and creation timestamp + +## Expected Output + +### With a Running Cluster + +When connected to a Kubernetes cluster with resources, you'll see formatted output like: + +``` +╔════════════════════════════════════════════════════════════════╗ +ā•‘ Kubernetes Client Example - HeroLib ā•‘ +ā•‘ Demonstrates JSON parsing and cluster interaction ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +[INFO] Creating Kubernetes client instance... +[SUCCESS] Kubernetes client created successfully + + - 1. Cluster Information +[INFO] Retrieving cluster information... + +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Cluster Overview │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ API Server: https://127.0.0.1:6443 │ +│ Version: v1.31.0 │ +│ Nodes: 3 │ +│ Namespaces: 5 │ +│ Running Pods: 12 │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +### Without a Cluster + +If kubectl is not installed or no cluster is configured, you'll see helpful error messages: + +``` +Error: Failed to get cluster information +... +This usually means: + - kubectl is not installed + - No Kubernetes cluster is configured (check ~/.kube/config) + - The cluster is not accessible + +To set up a local cluster, you can use: + - Minikube: https://minikube.sigs.k8s.io/docs/start/ + - Kind: https://kind.sigs.k8s.io/docs/user/quick-start/ + - Docker Desktop (includes Kubernetes) +``` + +## Creating Test Resources + +If your cluster is empty, you can create test resources to see the example in action: + +```bash +# Create a test pod +kubectl run nginx --image=nginx + +# Create a test deployment +kubectl create deployment nginx-deployment --image=nginx --replicas=3 + +# Expose the deployment as a service +kubectl expose deployment nginx-deployment --port=80 --type=ClusterIP +``` + +## Code Structure + +The example demonstrates proper usage of the HeroLib Kubernetes client: + +1. **Factory Pattern**: Uses `kubernetes.new()` to create a client instance +2. **Error Handling**: Proper use of V's `!` error propagation and `or {}` blocks +3. **JSON Parsing**: All kubectl JSON output is parsed into structured V types +4. **Console Output**: Clear, formatted output using the `console` module + +## Implementation Details + +The Kubernetes client module uses: + +- **Struct-based JSON decoding**: V's `json.decode(Type, data)` for type-safe parsing +- **Kubernetes API response structs**: Matching kubectl's JSON output format +- **Runtime resource structs**: Clean data structures for application use (`Pod`, `Deployment`, `Service`) + +## Troubleshooting + +### "kubectl: command not found" + +Install kubectl using your package manager (see Prerequisites above). + +### "The connection to the server was refused" + +Start a local Kubernetes cluster: + +```bash +minikube start +# or +kind create cluster +``` + +### "No resources found in default namespace" + +Create test resources using the commands in the "Creating Test Resources" section above. + +## Related Files + +- **Implementation**: `lib/virt/kubernetes/kubernetes_client.v` +- **Data Models**: `lib/virt/kubernetes/kubernetes_resources_model.v` +- **Unit Tests**: `lib/virt/kubernetes/kubernetes_test.v` +- **Factory**: `lib/virt/kubernetes/kubernetes_factory_.v` diff --git a/examples/virt/kubernetes/kubernetes_example.vsh b/examples/virt/kubernetes/kubernetes_example.vsh new file mode 100755 index 00000000..f1b955ae --- /dev/null +++ b/examples/virt/kubernetes/kubernetes_example.vsh @@ -0,0 +1,231 @@ +#!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run + +import incubaid.herolib.virt.kubernetes +import incubaid.herolib.ui.console + +println('╔════════════════════════════════════════════════════════════════╗') +println('ā•‘ Kubernetes Client Example - HeroLib ā•‘') +println('ā•‘ Demonstrates JSON parsing and cluster interaction ā•‘') +println('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•') +println('') + +// Create a Kubernetes client instance using the factory pattern +println('[INFO] Creating Kubernetes client instance...') +mut client := kubernetes.new() or { + console.print_header('Error: Failed to create Kubernetes client') + eprintln('${err}') + eprintln('') + eprintln('Make sure kubectl is installed and configured properly.') + eprintln('You can install kubectl ') + exit(1) +} + +println('[SUCCESS] Kubernetes client created successfully') +println('') + +// ============================================================================ +// 1. Get Cluster Information +// ============================================================================ +console.print_header('1. Cluster Information') +println('[INFO] Retrieving cluster information...') +println('') + +cluster := client.cluster_info() or { + console.print_header('Error: Failed to get cluster information') + eprintln('${err}') + eprintln('') + eprintln('This usually means:') + eprintln(' - kubectl is not installed') + eprintln(' - No Kubernetes cluster is configured (check ~/.kube/config)') + eprintln(' - The cluster is not accessible') + eprintln('') + eprintln('To set up a local cluster, you can use:') + eprintln(' - Minikube: https://minikube.sigs.k8s.io/docs/start/') + eprintln(' - Kind: https://kind.sigs.k8s.io/docs/user/quick-start/') + eprintln(' - Docker Desktop (includes Kubernetes)') + exit(1) +} + +println('ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”') +println('│ Cluster Overview │') +println('ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤') +println('│ API Server: ${cluster.api_server:-50}│') +println('│ Version: ${cluster.version:-50}│') +println('│ Nodes: ${cluster.nodes.str():-50}│') +println('│ Namespaces: ${cluster.namespaces.str():-50}│') +println('│ Running Pods: ${cluster.running_pods.str():-50}│') +println('ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜') +println('') + +// ============================================================================ +// 2. Get Pods in the 'default' namespace +// ============================================================================ +console.print_header('2. Pods in "default" Namespace') +println('[INFO] Retrieving pods from the default namespace...') +println('') + +pods := client.get_pods('default') or { + console.print_header('Warning: Failed to get pods') + eprintln('${err}') + eprintln('') + []kubernetes.Pod{} +} + +if pods.len == 0 { + println('No pods found in the default namespace.') + println('') + println('To create a test pod, run:') + println(' kubectl run nginx --image=nginx') + println('') +} else { + println('Found ${pods.len} pod(s) in the default namespace:') + println('') + + for i, pod in pods { + println('ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”') + println('│ Pod #${i + 1:-56}│') + println('ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤') + println('│ Name: ${pod.name:-50}│') + println('│ Namespace: ${pod.namespace:-50}│') + println('│ Status: ${pod.status:-50}│') + println('│ Node: ${pod.node:-50}│') + println('│ IP: ${pod.ip:-50}│') + println('│ Containers: ${pod.containers.join(', '):-50}│') + println('│ Created: ${pod.created_at:-50}│') + + if pod.labels.len > 0 { + println('│ Labels: │') + for key, value in pod.labels { + label_str := ' ${key}=${value}' + println('│ ${label_str:-58}│') + } + } + + println('ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜') + println('') + } +} + +// ============================================================================ +// 3. Get Deployments in the 'default' namespace +// ============================================================================ +console.print_header('3. Deployments in "default" Namespace') +println('[INFO] Retrieving deployments from the default namespace...') +println('') + +deployments := client.get_deployments('default') or { + console.print_header('Warning: Failed to get deployments') + eprintln('${err}') + eprintln('') + []kubernetes.Deployment{} +} + +if deployments.len == 0 { + println('No deployments found in the default namespace.') + println('') + println('To create a test deployment, run:') + println(' kubectl create deployment nginx --image=nginx --replicas=3') + println('') +} else { + println('Found ${deployments.len} deployment(s) in the default namespace:') + println('') + + for i, deploy in deployments { + ready_status := if deploy.ready_replicas == deploy.replicas { 'āœ“' } else { '⚠' } + + println('ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”') + println('│ Deployment #${i + 1:-53}│') + println('ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤') + println('│ Name: ${deploy.name:-44}│') + println('│ Namespace: ${deploy.namespace:-44}│') + println('│ Replicas: ${deploy.replicas.str():-44}│') + println('│ Ready Replicas: ${deploy.ready_replicas.str():-44}│') + println('│ Available: ${deploy.available_replicas.str():-44}│') + println('│ Updated: ${deploy.updated_replicas.str():-44}│') + println('│ Status: ${ready_status:-44}│') + println('│ Created: ${deploy.created_at:-44}│') + + if deploy.labels.len > 0 { + println('│ Labels: │') + for key, value in deploy.labels { + label_str := ' ${key}=${value}' + println('│ ${label_str:-58}│') + } + } + + println('ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜') + println('') + } +} + +// ============================================================================ +// 4. Get Services in the 'default' namespace +// ============================================================================ +console.print_header('4. Services in "default" Namespace') +println('[INFO] Retrieving services from the default namespace...') +println('') + +services := client.get_services('default') or { + console.print_header('Warning: Failed to get services') + eprintln('${err}') + eprintln('') + []kubernetes.Service{} +} + +if services.len == 0 { + println('No services found in the default namespace.') + println('') + println('To create a test service, run:') + println(' kubectl expose deployment nginx --port=80 --type=ClusterIP') + println('') +} else { + println('Found ${services.len} service(s) in the default namespace:') + println('') + + for i, svc in services { + println('ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”') + println('│ Service #${i + 1:-54}│') + println('ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤') + println('│ Name: ${svc.name:-48}│') + println('│ Namespace: ${svc.namespace:-48}│') + println('│ Type: ${svc.service_type:-48}│') + println('│ Cluster IP: ${svc.cluster_ip:-48}│') + + if svc.external_ip.len > 0 { + println('│ External IP: ${svc.external_ip:-48}│') + } + + if svc.ports.len > 0 { + println('│ Ports: ${svc.ports.join(', '):-48}│') + } + + println('│ Created: ${svc.created_at:-48}│') + + if svc.labels.len > 0 { + println('│ Labels: │') + for key, value in svc.labels { + label_str := ' ${key}=${value}' + println('│ ${label_str:-58}│') + } + } + + println('ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜') + println('') + } +} + +// ============================================================================ +// Summary +// ============================================================================ +console.print_header('Summary') +println('āœ“ Successfully demonstrated Kubernetes client functionality') +println('āœ“ Cluster information retrieved and parsed') +println('āœ“ Pods: ${pods.len} found') +println('āœ“ Deployments: ${deployments.len} found') +println('āœ“ Services: ${services.len} found') +println('') +println('All JSON parsing operations completed successfully!') +println('') +println('╔════════════════════════════════════════════════════════════════╗') +println('ā•‘ Example Complete ā•‘') +println('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•') diff --git a/lib/data/markdown/elements/element_frontmatter2.v b/lib/data/markdown/elements/element_frontmatter2.v index 5ec83b8e..6cfb0e35 100644 --- a/lib/data/markdown/elements/element_frontmatter2.v +++ b/lib/data/markdown/elements/element_frontmatter2.v @@ -1,7 +1,5 @@ module elements -import toml - // Frontmatter2 struct @[heap] pub struct Frontmatter2 { diff --git a/lib/virt/kubernetes/.heroscript b/lib/virt/kubernetes/.heroscript index 30ecf58f..9d0e3664 100644 --- a/lib/virt/kubernetes/.heroscript +++ b/lib/virt/kubernetes/.heroscript @@ -1,5 +1,5 @@ -!!hero_code.generate_installer +!!hero_code.generate_client name:'' classname:'KubeClient' singleton:0 diff --git a/lib/virt/kubernetes/kubernetes_actions.v b/lib/virt/kubernetes/kubernetes_actions.v deleted file mode 100644 index 2d84cdc0..00000000 --- a/lib/virt/kubernetes/kubernetes_actions.v +++ /dev/null @@ -1,59 +0,0 @@ -module kubernetes - -import incubaid.herolib.osal.core as osal -import incubaid.herolib.ui.console -import incubaid.herolib.core.texttools -import incubaid.herolib.osal.startupmanager - -fn startupcmd() ![]startupmanager.ZProcessNewArgs { - return []startupmanager.ZProcessNewArgs{} -} - -fn running() !bool { - // Check if kubectl is available and can connect - job := osal.exec(cmd: 'kubectl cluster-info', raise_error: false)! - return job.exit_code == 0 -} - -fn start_pre() ! { - console.print_header('Pre-start checks') - if !osal.cmd_exists('kubectl') { - return error('kubectl not found in PATH') - } -} - -fn start_post() ! { - console.print_header('Post-start validation') -} - -fn stop_pre() ! { -} - -fn stop_post() ! { -} - -fn installed() !bool { - return osal.cmd_exists('kubectl') -} - -fn install() ! { - console.print_header('install kubectl') - // kubectl is typically installed separately via package manager - // This can be enhanced to auto-download if needed - if !osal.cmd_exists('kubectl') { - return error('Please install kubectl: https://kubernetes.io/docs/tasks/tools/') - } -} - -fn build() ! { - // Not applicable for kubectl wrapper -} - -fn destroy() ! { - console.print_header('destroy kubernetes client') - // No cleanup needed for kubectl wrapper -} - -// fn configure() ! { -// console.print_debug('Kubernetes client configured') -// } diff --git a/lib/virt/kubernetes/kubernetes_client.v b/lib/virt/kubernetes/kubernetes_client.v index 24f47b91..cf727cdc 100644 --- a/lib/virt/kubernetes/kubernetes_client.v +++ b/lib/virt/kubernetes/kubernetes_client.v @@ -1,19 +1,15 @@ module kubernetes import incubaid.herolib.osal.core as osal -import incubaid.herolib.core.httpconnection -import incubaid.herolib.core.pathlib import incubaid.herolib.ui.console import json -import os - @[params] pub struct KubectlExecArgs { pub mut: command string timeout int = 30 - retry int = 0 + retry int } pub struct KubectlResult { @@ -29,18 +25,18 @@ pub fn (mut k KubeClient) kubectl_exec(args KubectlExecArgs) !KubectlResult { mut cmd := 'kubectl' if k.config.namespace.len > 0 { - cmd += '--namespace=${k.config.namespace} ' + cmd += ' --namespace=${k.config.namespace}' } if k.kubeconfig_path.len > 0 { - cmd += '--kubeconfig=${k.kubeconfig_path} ' + cmd += ' --kubeconfig=${k.kubeconfig_path}' } if k.config.context.len > 0 { - cmd += '--context=${k.config.context} ' + cmd += ' --context=${k.config.context}' } - cmd += args.command + cmd += ' ${args.command}' console.print_debug('executing: ${cmd}') @@ -59,7 +55,6 @@ pub fn (mut k KubeClient) kubectl_exec(args KubectlExecArgs) !KubectlResult { } } - // Test connection to cluster pub fn (mut k KubeClient) test_connection() !bool { result := k.kubectl_exec(command: 'cluster-info')! @@ -78,90 +73,175 @@ pub fn (mut k KubeClient) cluster_info() !ClusterInfo { return error('Failed to get cluster version: ${result.stderr}') } - println(result.stdout) + // Parse version JSON using struct-based decoding + mut version_str := 'unknown' + version_response := json.decode(KubectlVersionResponse, result.stdout) or { + console.print_debug('Failed to parse version JSON: ${err}') + KubectlVersionResponse{} + } + if version_response.server_version.git_version.len > 0 { + version_str = version_response.server_version.git_version + } - $dbg; - // version_data := json.decode(map[string]interface{}, result.stdout)! - // server_version := version_data['serverVersion'] or { return error('No serverVersion') } + // Get node count + nodes_result := k.kubectl_exec(command: 'get nodes -o json')! + mut nodes_count := 0 + if nodes_result.success { + nodes_list := json.decode(KubectlListResponse, nodes_result.stdout) or { + console.print_debug('Failed to parse nodes JSON: ${err}') + KubectlListResponse{} + } + nodes_count = nodes_list.items.len + } - // // Get node count - // nodes_result := k.kubectl_exec(command: 'get nodes -o json')! - // nodes_count := if nodes_result.success { - // nodes_data := json.decode(map[string]interface{}, nodes_result.stdout)! - // items := nodes_data['items'] or { []interface{}{} } - // items.len - // } else { - // 0 - // } + // Get namespace count + ns_result := k.kubectl_exec(command: 'get namespaces -o json')! + mut ns_count := 0 + if ns_result.success { + ns_list := json.decode(KubectlListResponse, ns_result.stdout) or { + console.print_debug('Failed to parse namespaces JSON: ${err}') + KubectlListResponse{} + } + ns_count = ns_list.items.len + } - // // Get namespace count - // ns_result := k.kubectl_exec(command: 'get namespaces -o json')! - // ns_count := if ns_result.success { - // ns_data := json.decode(map[string]interface{}, ns_result.stdout)! - // items := ns_data['items'] or { []interface{}{} } - // items.len - // } else { - // 0 - // } + // Get running pods count + pods_result := k.kubectl_exec(command: 'get pods --all-namespaces -o json')! + mut pods_count := 0 + if pods_result.success { + pods_list := json.decode(KubectlListResponse, pods_result.stdout) or { + console.print_debug('Failed to parse pods JSON: ${err}') + KubectlListResponse{} + } + pods_count = pods_list.items.len + } - // // Get running pods count - // pods_result := k.kubectl_exec(command: 'get pods --all-namespaces -o json')! - // pods_count := if pods_result.success { - // pods_data := json.decode(map[string]interface{}, pods_result.stdout)! - // items := pods_data['items'] or { []interface{}{} } - // items.len - // } else { - // 0 - // } - - // return ClusterInfo{ - // version: 'v1.0.0' - // nodes: nodes_count - // namespaces: ns_count - // running_pods: pods_count - // api_server: k.config.api_server - // } - return ClusterInfo{} + return ClusterInfo{ + version: version_str + nodes: nodes_count + namespaces: ns_count + running_pods: pods_count + api_server: k.config.api_server + } } // Get resources (Pods, Deployments, Services, etc.) -pub fn (mut k KubeClient) get_pods(namespace string) ! { +pub fn (mut k KubeClient) get_pods(namespace string) ![]Pod { result := k.kubectl_exec(command: 'get pods -n ${namespace} -o json')! if !result.success { return error('Failed to get pods: ${result.stderr}') } - println(result.stdout) - $dbg; - // data := json.decode(map[string]interface{}, result.stdout)! - // items := data['items'] or { []interface{}{} } - // return items as []map[string]interface{} + // Parse JSON response using struct-based decoding + pod_list := json.decode(KubectlPodListResponse, result.stdout) or { + return error('Failed to parse pods JSON: ${err}') + } - panic('Not implemented') + mut pods := []Pod{} + + for item in pod_list.items { + // Extract container names + mut container_names := []string{} + for container in item.spec.containers { + container_names << container.name + } + + // Create Pod struct from kubectl response + pod := Pod{ + name: item.metadata.name + namespace: item.metadata.namespace + status: item.status.phase + node: item.spec.node_name + ip: item.status.pod_ip + containers: container_names + labels: item.metadata.labels + created_at: item.metadata.creation_timestamp + } + + pods << pod + } + + return pods } -pub fn (mut k KubeClient) get_deployments(namespace string) ! { +pub fn (mut k KubeClient) get_deployments(namespace string) ![]Deployment { result := k.kubectl_exec(command: 'get deployments -n ${namespace} -o json')! if !result.success { return error('Failed to get deployments: ${result.stderr}') } - // data := json.decode(map[string]interface{}, result.stdout)! - // items := data['items'] or { []interface{}{} } - // return items as []map[string]interface{} - panic('Not implemented') + // Parse JSON response using struct-based decoding + deployment_list := json.decode(KubectlDeploymentListResponse, result.stdout) or { + return error('Failed to parse deployments JSON: ${err}') + } + + mut deployments := []Deployment{} + + for item in deployment_list.items { + // Create Deployment struct from kubectl response + deployment := Deployment{ + name: item.metadata.name + namespace: item.metadata.namespace + replicas: item.spec.replicas + ready_replicas: item.status.ready_replicas + available_replicas: item.status.available_replicas + updated_replicas: item.status.updated_replicas + labels: item.metadata.labels + created_at: item.metadata.creation_timestamp + } + + deployments << deployment + } + + return deployments } -pub fn (mut k KubeClient) get_services(namespace string) ! { +pub fn (mut k KubeClient) get_services(namespace string) ![]Service { result := k.kubectl_exec(command: 'get services -n ${namespace} -o json')! if !result.success { return error('Failed to get services: ${result.stderr}') } - // data := json.decode(map[string]interface{}, result.stdout)! - // items := data['items'] or { []interface{}{} } - // return items as []map[string]interface{} - panic('Not implemented') + // Parse JSON response using struct-based decoding + service_list := json.decode(KubectlServiceListResponse, result.stdout) or { + return error('Failed to parse services JSON: ${err}') + } + + mut services := []Service{} + + for item in service_list.items { + // Build port strings (e.g., "80/TCP", "443/TCP") + mut port_strings := []string{} + for port in item.spec.ports { + port_strings << '${port.port}/${port.protocol}' + } + + // Get external IP from LoadBalancer status if available + mut external_ip := '' + if item.status.load_balancer.ingress.len > 0 { + external_ip = item.status.load_balancer.ingress[0].ip + } + // Also check spec.external_ips + if external_ip.len == 0 && item.spec.external_ips.len > 0 { + external_ip = item.spec.external_ips[0] + } + + // Create Service struct from kubectl response + service := Service{ + name: item.metadata.name + namespace: item.metadata.namespace + service_type: item.spec.service_type + cluster_ip: item.spec.cluster_ip + external_ip: external_ip + ports: port_strings + labels: item.metadata.labels + created_at: item.metadata.creation_timestamp + } + + services << service + } + + return services } // Apply YAML file diff --git a/lib/virt/kubernetes/kubernetes_factory_.v b/lib/virt/kubernetes/kubernetes_factory_.v index 67005a02..84e0b0b5 100644 --- a/lib/virt/kubernetes/kubernetes_factory_.v +++ b/lib/virt/kubernetes/kubernetes_factory_.v @@ -2,10 +2,7 @@ module kubernetes import incubaid.herolib.core.base import incubaid.herolib.core.playbook { PlayBook } -import incubaid.herolib.ui.console import json -import incubaid.herolib.osal.startupmanager -import time __global ( kubernetes_global map[string]&KubeClient @@ -134,176 +131,6 @@ pub fn play(mut plbook PlayBook) ! { install_action.done = true } } - mut other_actions := plbook.find(filter: 'kubernetes.')! - for mut other_action in other_actions { - if other_action.name in ['destroy', 'install', 'build'] { - mut p := other_action.params - reset := p.get_default_false('reset') - if other_action.name == 'destroy' || reset { - console.print_debug('install action kubernetes.destroy') - destroy()! - } - if other_action.name == 'install' { - console.print_debug('install action kubernetes.install') - install()! - } - } - if other_action.name in ['start', 'stop', 'restart'] { - mut p := other_action.params - name := p.get('name')! - mut kubernetes_obj := get(name: name)! - console.print_debug('action object:\n${kubernetes_obj}') - if other_action.name == 'start' { - console.print_debug('install action kubernetes.${other_action.name}') - kubernetes_obj.start()! - } - - if other_action.name == 'stop' { - console.print_debug('install action kubernetes.${other_action.name}') - kubernetes_obj.stop()! - } - if other_action.name == 'restart' { - console.print_debug('install action kubernetes.${other_action.name}') - kubernetes_obj.restart()! - } - } - other_action.done = true - } -} - -//////////////////////////////////////////////////////////////////////////////////////////////////// -//////////////////////////# LIVE CYCLE MANAGEMENT FOR INSTALLERS /////////////////////////////////// -//////////////////////////////////////////////////////////////////////////////////////////////////// - -fn startupmanager_get(cat startupmanager.StartupManagerType) !startupmanager.StartupManager { - // unknown - // screen - // zinit - // tmux - // systemd - match cat { - .screen { - console.print_debug("installer: kubernetes' startupmanager get screen") - return startupmanager.get(.screen)! - } - .zinit { - console.print_debug("installer: kubernetes' startupmanager get zinit") - return startupmanager.get(.zinit)! - } - .systemd { - console.print_debug("installer: kubernetes' startupmanager get systemd") - return startupmanager.get(.systemd)! - } - else { - console.print_debug("installer: kubernetes' startupmanager get auto") - return startupmanager.get(.auto)! - } - } -} - -// load from disk and make sure is properly intialized -pub fn (mut self KubeClient) reload() ! { - switch(self.name) - self = obj_init(self)! -} - -pub fn (mut self KubeClient) start() ! { - switch(self.name) - if self.running()! { - return - } - - console.print_header('installer: kubernetes start') - - if !installed()! { - install()! - } - - configure()! - - start_pre()! - - for zprocess in startupcmd()! { - mut sm := startupmanager_get(zprocess.startuptype)! - - console.print_debug('installer: kubernetes starting with ${zprocess.startuptype}...') - - sm.new(zprocess)! - - sm.start(zprocess.name)! - } - - start_post()! - - for _ in 0 .. 50 { - if self.running()! { - return - } - time.sleep(100 * time.millisecond) - } - return error('kubernetes did not install properly.') -} - -pub fn (mut self KubeClient) install_start(args InstallArgs) ! { - switch(self.name) - self.install(args)! - self.start()! -} - -pub fn (mut self KubeClient) stop() ! { - switch(self.name) - stop_pre()! - for zprocess in startupcmd()! { - mut sm := startupmanager_get(zprocess.startuptype)! - sm.stop(zprocess.name)! - } - stop_post()! -} - -pub fn (mut self KubeClient) restart() ! { - switch(self.name) - self.stop()! - self.start()! -} - -pub fn (mut self KubeClient) running() !bool { - switch(self.name) - - // walk over the generic processes, if not running return - for zprocess in startupcmd()! { - if zprocess.startuptype != .screen { - mut sm := startupmanager_get(zprocess.startuptype)! - r := sm.running(zprocess.name)! - if r == false { - return false - } - } - } - return running()! -} - -@[params] -pub struct InstallArgs { -pub mut: - reset bool -} - -pub fn (mut self KubeClient) install(args InstallArgs) ! { - switch(self.name) - if args.reset || (!installed()!) { - install()! - } -} - -pub fn (mut self KubeClient) build() ! { - switch(self.name) - build()! -} - -pub fn (mut self KubeClient) destroy() ! { - switch(self.name) - self.stop() or {} - destroy()! } // switch instance to be used for kubernetes diff --git a/lib/virt/kubernetes/kubernetes_model.v b/lib/virt/kubernetes/kubernetes_model.v index b1ad61c3..acb1e274 100644 --- a/lib/virt/kubernetes/kubernetes_model.v +++ b/lib/virt/kubernetes/kubernetes_model.v @@ -1,8 +1,6 @@ module kubernetes -import incubaid.herolib.data.paramsparser import incubaid.herolib.data.encoderhero -import os pub const version = '0.0.0' const singleton = false @@ -12,9 +10,9 @@ const default = true pub struct KubeClient { pub mut: name string = 'default' - kubeconfig_path string - config KubeConfig - connected bool + kubeconfig_path string // Path to kubeconfig file + config KubeConfig // Kubernetes configuration + connected bool // Connection status api_version string = 'v1' cache_enabled bool = true cache_ttl_seconds int = 300 diff --git a/lib/virt/kubernetes/kubernetes_resources_model.v b/lib/virt/kubernetes/kubernetes_resources_model.v index cd2038f9..f489b553 100644 --- a/lib/virt/kubernetes/kubernetes_resources_model.v +++ b/lib/virt/kubernetes/kubernetes_resources_model.v @@ -1,8 +1,5 @@ module kubernetes -import incubaid.herolib.data.encoderhero -import os - // K8s API Version and Kind tracking @[params] pub struct K8sMetadata { @@ -150,7 +147,7 @@ pub mut: pub struct KubeConfig { pub mut: kubeconfig_path string - context string = '' + context string namespace string = 'default' api_server string ca_cert_path string @@ -179,3 +176,189 @@ pub mut: running_pods int api_server string } + +// ============================================================================ +// Kubectl JSON Response Structs +// These structs match the JSON structure returned by kubectl commands +// ============================================================================ + +// Version response from 'kubectl version -o json' +struct KubectlVersionResponse { + server_version ServerVersionInfo @[json: serverVersion] +} + +struct ServerVersionInfo { + git_version string @[json: gitVersion] + major string + minor string +} + +// Generic list response structure +struct KubectlListResponse { + items []KubectlItemMetadata +} + +struct KubectlItemMetadata { + metadata KubectlMetadata +} + +struct KubectlMetadata { + name string +} + +// Pod list response from 'kubectl get pods -o json' +struct KubectlPodListResponse { + items []KubectlPodItem +} + +struct KubectlPodItem { + metadata KubectlPodMetadata + spec KubectlPodSpec + status KubectlPodStatus +} + +struct KubectlPodMetadata { + name string + namespace string + labels map[string]string + creation_timestamp string @[json: creationTimestamp] +} + +struct KubectlPodSpec { + node_name string @[json: nodeName] + containers []KubectlContainer +} + +struct KubectlContainer { + name string + image string +} + +struct KubectlPodStatus { + phase string + pod_ip string @[json: podIP] +} + +// Deployment list response from 'kubectl get deployments -o json' +struct KubectlDeploymentListResponse { + items []KubectlDeploymentItem +} + +struct KubectlDeploymentItem { + metadata KubectlDeploymentMetadata + spec KubectlDeploymentSpec + status KubectlDeploymentStatus +} + +struct KubectlDeploymentMetadata { + name string + namespace string + labels map[string]string + creation_timestamp string @[json: creationTimestamp] +} + +struct KubectlDeploymentSpec { + replicas int +} + +struct KubectlDeploymentStatus { + ready_replicas int @[json: readyReplicas] + available_replicas int @[json: availableReplicas] + updated_replicas int @[json: updatedReplicas] +} + +// Service list response from 'kubectl get services -o json' +struct KubectlServiceListResponse { + items []KubectlServiceItem +} + +struct KubectlServiceItem { + metadata KubectlServiceMetadata + spec KubectlServiceSpec + status KubectlServiceStatus +} + +struct KubectlServiceMetadata { + name string + namespace string + labels map[string]string + creation_timestamp string @[json: creationTimestamp] +} + +struct KubectlServiceSpec { + service_type string @[json: type] + cluster_ip string @[json: clusterIP] + external_ips []string @[json: externalIPs] + ports []KubectlServicePort +} + +struct KubectlServicePort { + port int + protocol string +} + +struct KubectlServiceStatus { + load_balancer KubectlLoadBalancerStatus @[json: loadBalancer] +} + +struct KubectlLoadBalancerStatus { + ingress []KubectlLoadBalancerIngress +} + +struct KubectlLoadBalancerIngress { + ip string +} + +// ============================================================================ +// Runtime resource structs (returned from kubectl get commands) +// ============================================================================ + +// Pod runtime information +pub struct Pod { +pub mut: + name string + namespace string + status string + node string + ip string + containers []string + labels map[string]string + created_at string +} + +// Deployment runtime information +pub struct Deployment { +pub mut: + name string + namespace string + replicas int + ready_replicas int + available_replicas int + updated_replicas int + labels map[string]string + created_at string +} + +// Service runtime information +pub struct Service { +pub mut: + name string + namespace string + service_type string + cluster_ip string + external_ip string + ports []string + labels map[string]string + created_at string +} + +// Version information from kubectl version command +pub struct VersionInfo { +pub mut: + major string + minor string + git_version string + git_commit string + build_date string + platform string +} diff --git a/lib/virt/kubernetes/kubernetes_test.v b/lib/virt/kubernetes/kubernetes_test.v index 9bc182cf..6c48b5dc 100644 --- a/lib/virt/kubernetes/kubernetes_test.v +++ b/lib/virt/kubernetes/kubernetes_test.v @@ -1,76 +1,249 @@ module kubernetes -import time -import os +// ============================================================================ +// Unit Tests for Kubernetes Client Module +// These tests verify struct creation and data handling without executing +// real kubectl commands (unit tests only, not integration tests) +// ============================================================================ fn test_model_creation() ! { - mut deployment := DeploymentSpec{ - metadata: K8sMetadata{ - name: 'test-app' - namespace: 'default' - } - replicas: 3 - selector: { - 'app': 'test-app' - } - template: PodSpec{ - metadata: K8sMetadata{ - name: 'test-app-pod' - namespace: 'default' - } - containers: [ - ContainerSpec{ - name: 'app' - image: 'nginx:latest' - ports: [ - ContainerPort{ - name: 'http' - container_port: 80 - }, - ] - }, - ] + mut client := new(name: 'test-cluster')! + assert client.name == 'test-cluster' +} + +// ============================================================================ +// Unit Tests for Data Structures and JSON Parsing +// ============================================================================ + +// Test Pod struct creation and field access +fn test_pod_struct_creation() ! { + mut pod := Pod{ + name: 'test-pod' + namespace: 'default' + status: 'Running' + node: 'node-1' + ip: '10.244.0.5' + containers: ['nginx', 'sidecar'] + labels: { + 'app': 'web' + 'env': 'prod' } + created_at: '2024-01-15T10:30:00Z' } - yaml := yaml_from_deployment(deployment)! - assert yaml.contains('apiVersion: apps/v1') - assert yaml.contains('kind: Deployment') - assert yaml.contains('test-app') + assert pod.name == 'test-pod' + assert pod.namespace == 'default' + assert pod.status == 'Running' + assert pod.node == 'node-1' + assert pod.ip == '10.244.0.5' + assert pod.containers.len == 2 + assert pod.containers[0] == 'nginx' + assert pod.containers[1] == 'sidecar' + assert pod.labels['app'] == 'web' + assert pod.labels['env'] == 'prod' + assert pod.created_at == '2024-01-15T10:30:00Z' } -fn test_yaml_validation() ! { - // Create test YAML file - test_yaml := '' - ' -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-deployment - namespace: default -spec: - replicas: 1 - selector: - matchLabels: - app: test - template: - metadata: - labels: - app: test - spec: - containers: - - name: app - image: nginx:latest -' - '' +// Test Deployment struct creation +fn test_deployment_struct_creation() ! { + mut deployment := Deployment{ + name: 'nginx-deployment' + namespace: 'default' + replicas: 3 + ready_replicas: 3 + available_replicas: 3 + updated_replicas: 3 + labels: { + 'app': 'nginx' + } + created_at: '2024-01-15T09:00:00Z' + } - test_file := '/tmp/test-deployment.yaml' - os.write_file(test_file, test_yaml)! - - result := yaml_validate(test_file)! - assert result.valid - assert result.kind == 'Deployment' - assert result.metadata.name == 'test-deployment' - - os.rm(test_file)! + assert deployment.name == 'nginx-deployment' + assert deployment.namespace == 'default' + assert deployment.replicas == 3 + assert deployment.ready_replicas == 3 + assert deployment.available_replicas == 3 + assert deployment.updated_replicas == 3 + assert deployment.labels['app'] == 'nginx' + assert deployment.created_at == '2024-01-15T09:00:00Z' +} + +// Test Service struct creation with ClusterIP +fn test_service_struct_creation() ! { + mut service := Service{ + name: 'nginx-service' + namespace: 'default' + service_type: 'ClusterIP' + cluster_ip: '10.96.100.50' + external_ip: '' + ports: ['80/TCP', '443/TCP'] + labels: { + 'app': 'nginx' + } + created_at: '2024-01-15T09:30:00Z' + } + + assert service.name == 'nginx-service' + assert service.namespace == 'default' + assert service.service_type == 'ClusterIP' + assert service.cluster_ip == '10.96.100.50' + assert service.external_ip == '' + assert service.ports.len == 2 + assert service.ports[0] == '80/TCP' + assert service.ports[1] == '443/TCP' + assert service.labels['app'] == 'nginx' + assert service.created_at == '2024-01-15T09:30:00Z' +} + +// Test Service with LoadBalancer type and external IP +fn test_service_loadbalancer_type() ! { + mut service := Service{ + name: 'web-lb-service' + namespace: 'default' + service_type: 'LoadBalancer' + cluster_ip: '10.96.100.52' + external_ip: '203.0.113.10' + ports: ['80/TCP'] + labels: { + 'app': 'web' + } + created_at: '2024-01-15T11:00:00Z' + } + + assert service.name == 'web-lb-service' + assert service.service_type == 'LoadBalancer' + assert service.cluster_ip == '10.96.100.52' + assert service.external_ip == '203.0.113.10' + assert service.ports.len == 1 + assert service.ports[0] == '80/TCP' +} + +// Test ClusterInfo struct +fn test_cluster_info_struct() ! { + mut cluster := ClusterInfo{ + api_server: 'https://test-cluster:6443' + version: 'v1.31.0' + nodes: 3 + namespaces: 5 + running_pods: 12 + } + + assert cluster.api_server == 'https://test-cluster:6443' + assert cluster.version == 'v1.31.0' + assert cluster.nodes == 3 + assert cluster.namespaces == 5 + assert cluster.running_pods == 12 +} + +// Test Pod with multiple containers +fn test_pod_with_multiple_containers() ! { + mut pod := Pod{ + name: 'multi-container-pod' + namespace: 'default' + containers: ['app', 'sidecar', 'init'] + } + + assert pod.containers.len == 3 + assert 'app' in pod.containers + assert 'sidecar' in pod.containers + assert 'init' in pod.containers +} + +// Test Deployment with partial ready state +fn test_deployment_partial_ready() ! { + mut deployment := Deployment{ + name: 'redis-deployment' + namespace: 'default' + replicas: 3 + ready_replicas: 2 + available_replicas: 2 + updated_replicas: 3 + } + + assert deployment.replicas == 3 + assert deployment.ready_replicas == 2 + assert deployment.available_replicas == 2 + // Not all replicas are ready + assert deployment.ready_replicas < deployment.replicas +} + +// Test Service with multiple ports +fn test_service_with_multiple_ports() ! { + mut service := Service{ + name: 'multi-port-service' + ports: ['80/TCP', '443/TCP', '8080/TCP'] + } + + assert service.ports.len == 3 + assert '80/TCP' in service.ports + assert '443/TCP' in service.ports + assert '8080/TCP' in service.ports +} + +// Test Pod with default/empty values +fn test_pod_default_values() ! { + mut pod := Pod{} + + assert pod.name == '' + assert pod.namespace == '' + assert pod.status == '' + assert pod.node == '' + assert pod.ip == '' + assert pod.containers.len == 0 + assert pod.labels.len == 0 + assert pod.created_at == '' +} + +// Test Deployment with default values +fn test_deployment_default_values() ! { + mut deployment := Deployment{} + + assert deployment.name == '' + assert deployment.namespace == '' + assert deployment.replicas == 0 + assert deployment.ready_replicas == 0 + assert deployment.available_replicas == 0 + assert deployment.updated_replicas == 0 + assert deployment.labels.len == 0 + assert deployment.created_at == '' +} + +// Test Service with default values +fn test_service_default_values() ! { + mut service := Service{} + + assert service.name == '' + assert service.namespace == '' + assert service.service_type == '' + assert service.cluster_ip == '' + assert service.external_ip == '' + assert service.ports.len == 0 + assert service.labels.len == 0 + assert service.created_at == '' +} + +// Test KubectlResult struct for successful command +fn test_kubectl_result_struct() ! { + mut result := KubectlResult{ + exit_code: 0 + stdout: '{"items": []}' + stderr: '' + } + + assert result.exit_code == 0 + assert result.stdout.contains('items') + assert result.stderr == '' +} + +// Test KubectlResult struct for error +fn test_kubectl_result_error() ! { + mut result := KubectlResult{ + exit_code: 1 + stdout: '' + stderr: 'Error: connection refused' + } + + assert result.exit_code == 1 + assert result.stderr.contains('Error') } diff --git a/lib/virt/kubernetes/kubernetes_yaml.v b/lib/virt/kubernetes/kubernetes_yaml.v index febf0932..b1198e8d 100644 --- a/lib/virt/kubernetes/kubernetes_yaml.v +++ b/lib/virt/kubernetes/kubernetes_yaml.v @@ -1,9 +1,6 @@ module kubernetes -import json -import incubaid.herolib.data.markdown import incubaid.herolib.core.pathlib -import os // Parse YAML file and return validation result pub fn yaml_validate(yaml_path string) !K8sValidationResult { diff --git a/lib/virt/kubernetes/templates/atemplate.yaml b/lib/virt/kubernetes/templates/atemplate.yaml deleted file mode 100644 index a4c386dd..00000000 --- a/lib/virt/kubernetes/templates/atemplate.yaml +++ /dev/null @@ -1,5 +0,0 @@ - - -name: ${cfg.configpath} - - From 82d37374d88c8761218920dbd2294c30b0688370 Mon Sep 17 00:00:00 2001 From: peternashaat Date: Thu, 30 Oct 2025 11:46:15 +0000 Subject: [PATCH 4/7] Cryptpad installer --- examples/installers/k8s/cryptpad.vsh | 19 ++ lib/installers/k8s/cryptpad/.heroscript | 10 + .../k8s/cryptpad/cryptpad_actions.v | 215 ++++++++++++ .../k8s/cryptpad/cryptpad_factory_.v | 309 ++++++++++++++++++ lib/installers/k8s/cryptpad/cryptpad_model.v | 50 +++ .../k8s/cryptpad/templates/cryptpad.yaml | 127 +++++++ .../k8s/cryptpad/templates/tfgw-cryptpad.yaml | 24 ++ 7 files changed, 754 insertions(+) create mode 100755 examples/installers/k8s/cryptpad.vsh create mode 100644 lib/installers/k8s/cryptpad/.heroscript create mode 100644 lib/installers/k8s/cryptpad/cryptpad_actions.v create mode 100644 lib/installers/k8s/cryptpad/cryptpad_factory_.v create mode 100644 lib/installers/k8s/cryptpad/cryptpad_model.v create mode 100644 lib/installers/k8s/cryptpad/templates/cryptpad.yaml create mode 100644 lib/installers/k8s/cryptpad/templates/tfgw-cryptpad.yaml diff --git a/examples/installers/k8s/cryptpad.vsh b/examples/installers/k8s/cryptpad.vsh new file mode 100755 index 00000000..d8bcc4c6 --- /dev/null +++ b/examples/installers/k8s/cryptpad.vsh @@ -0,0 +1,19 @@ +#!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run + +import incubaid.herolib.installers.k8s.cryptpad + +// This example demonstrates how to use the CryptPad installer. + +// 1. Create a new installer instance with a specific hostname. +// Replace 'mycryptpad' with your desired hostname. +mut installer := cryptpad.new(hostname: 'mycryptpadtes2222tt')! + +// 2. Install CryptPad. +// This will generate the necessary Kubernetes YAML files and apply them to your cluster. +installer.install()! + +println('CryptPad installation started.') +println('You can access it at https://${installer.hostname}.gent01.grid.tf') + +// 3. To destroy the deployment, you can run the following: +//installer.destroy()! diff --git a/lib/installers/k8s/cryptpad/.heroscript b/lib/installers/k8s/cryptpad/.heroscript new file mode 100644 index 00000000..2e74ad46 --- /dev/null +++ b/lib/installers/k8s/cryptpad/.heroscript @@ -0,0 +1,10 @@ +!!hero_code.generate_installer + name:'cryptpad' + classname:'CryptpadServer' + singleton:0 //there can only be 1 object in the globals, is called 'default' + templates:1 //are there templates for the installer + default:1 //can we create a default when the factory is used + title:'' + supported_platforms:'' //osx, ... (empty means all) + reset:0 // regenerate all, dangerous !!! + startupmanager:0 //not managed by a startup manager diff --git a/lib/installers/k8s/cryptpad/cryptpad_actions.v b/lib/installers/k8s/cryptpad/cryptpad_actions.v new file mode 100644 index 00000000..92798562 --- /dev/null +++ b/lib/installers/k8s/cryptpad/cryptpad_actions.v @@ -0,0 +1,215 @@ +module cryptpad + +import incubaid.herolib.osal.core as osal +import incubaid.herolib.ui.console +import incubaid.herolib.core.texttools +import incubaid.herolib.core +import incubaid.herolib.osal.startupmanager +import incubaid.herolib.installers.ulist +import os +import strings +import time + +fn startupcmd() ![]startupmanager.ZProcessNewArgs { + // We don't have a long-running process to manage with startupmanager for this installer, + // but we'll keep the function for consistency. + return []startupmanager.ZProcessNewArgs{} +} + +fn kubectl_installed() ! { + if !osal.cmd_exists('kubectl') { + return error('kubectl is not installed. Please install it to continue.') + } + // Check if kubectl is configured to connect to a cluster + res := osal.exec(cmd: 'kubectl cluster-info', ignore_error: true)! + if res.exit_code != 0 { + return error('kubectl is not configured to connect to a Kubernetes cluster. Please check your kubeconfig.') + } +} + +fn running() !bool { + installer := get()! + res := osal.exec( + cmd: 'kubectl get deployment cryptpad -n ${installer.namespace}' + ignore_error: true + )! + return res.exit_code == 0 +} + +fn start_pre() ! { +} + +fn start_post() ! { +} + +fn stop_pre() ! { +} + +fn stop_post() ! { +} + +//////////////////// following actions are not specific to instance of the object + +// checks if a certain version or above is installed +fn installed() !bool { + return running() +} + +// get the Upload List of the files +fn ulist_get() !ulist.UList { + return ulist.UList{} +} + +fn upload() ! { + // Not needed for this installer. +} + +fn get_master_node_ips() ![]string { + mut master_ips := []string{} + res := osal.exec( + cmd: 'kubectl get nodes -o jsonpath="{.items[*].status.addresses[?(@.type==\'InternalIP\')].address}" | tr \' \' \'\\n\' | grep \':\'' + )! + if res.exit_code != 0 { + return error('Failed to get master node IPs: ${res.output}') + } + for ip in res.output.split('\n') { + if ip.len > 0 { + master_ips << ip + } + } + return master_ips +} + +struct ConfigValues { +pub mut: + hostname string + backends string + namespace string +} + +fn install() ! { + console.print_header('Installing CryptPad...') + + // 1. Check for dependencies. + console.print_info('Checking for kubectl...') + kubectl_installed()! + console.print_info('kubectl is installed and configured.') + + // 2. Get Kubernetes master node IPs. + console.print_info('Getting Kubernetes master node IPs...') + master_ips := get_master_node_ips()! + console.print_info('Master node IPs: ${master_ips}') + + // 3. Generate YAML files from templates. + console.print_info('Generating YAML files from templates...') + installer := get()! + if installer.hostname == '' { + return error('hostname is empty') + } + + mut backends_str_builder := strings.new_builder(100) + for ip in master_ips { + backends_str_builder.writeln(' - "http://[${ip}]:80"') + } + config_values := ConfigValues{ + hostname: installer.hostname + backends: backends_str_builder.str() + namespace: installer.namespace + } + + // Write to tfgw file + temp := $tmpl('./templates/tfgw-cryptpad.yaml') + os.write_file('/tmp/tfgw-cryptpad.yaml', temp)! + + // write to cryptpad yaml file + temp2 := $tmpl('./templates/cryptpad.yaml') + os.write_file('/tmp/cryptpad.yaml', temp2)! + console.print_info('YAML files generated successfully.') + + // 4. Apply the YAML files using `kubectl`. + console.print_info('Applying Gateway YAML file to the cluster...') + res1 := osal.exec(cmd: 'kubectl apply -f /tmp/tfgw-cryptpad.yaml')! + if res1.exit_code != 0 { + return error('Failed to apply tfgw-cryptpad.yaml: ${res1.output}') + } + console.print_info('Gateway YAML file applied successfully.') + + // 5. Verify TFGW deployments + verify_tfgw_deployment(tfgw_name: 'cryptpad-main', namespace: installer.namespace, retry: 30)! + verify_tfgw_deployment(tfgw_name: 'cryptpad-sandbox', namespace: installer.namespace, retry: 30)! + + // 6. Apply Cryptpad YAML + console.print_info('Applying Cryptpad YAML file to the cluster...') + res2 := osal.exec(cmd: 'kubectl apply -f /tmp/cryptpad.yaml')! + if res2.exit_code != 0 { + return error('Failed to apply cryptpad.yaml: ${res2.output}') + } + console.print_info('Cryptpad YAML file applied successfully.') + + // 7. Verify deployment status + console.print_info('Verifying deployment status...') + mut is_running := false + for i in 0 .. 30 { + if running()! { + is_running = true + break + } + console.print_info('Waiting for CryptPad deployment to be ready... (${i + 1}/30)') + time.sleep(2 * time.second) + } + + if is_running { + console.print_header('CryptPad installation successful!') + } else { + return error('CryptPad deployment failed to start.') + } +} + +// params for verifying the generating of the FQDN using tfgw crd +@[params] +struct VerifyTfgwDeployment { +pub mut: + tfgw_name string // tfgw serivce generating the FQDN + namespace string // namespace name for cryptpad deployments/services + retry int // number of retries +} + +// Function for verifying the generating of of the FQDN using tfgw crd +fn verify_tfgw_deployment(args VerifyTfgwDeployment) ! { + console.print_info('Verifying TFGW deployment for ${args.tfgw_name}...') + mut is_fqdn_generated := false + for i in 0 .. 30 { + res := osal.exec( + cmd: 'kubectl get tfgw ${args.tfgw_name} -n ${args.namespace} -o jsonpath="{.status.fqdn}"' + ignore_error: true + )! + if res.exit_code == 0 && res.output != '' { + is_fqdn_generated = true + break + } + console.print_info('Waiting for FQDN to be generated for ${args.tfgw_name}... (${i + 1}/30)') + time.sleep(2 * time.second) + } + + if !is_fqdn_generated { + console.print_stderr('Failed to get FQDN for ${args.tfgw_name}.') + res := osal.exec( + cmd: 'kubectl describe tfgw ${args.tfgw_name} -n ${args.namespace}' + ignore_error: true + )! + console.print_stderr(res.output) + return error('TFGW deployment failed for ${args.tfgw_name}.') + } + console.print_info('TFGW deployment for ${args.tfgw_name} verified successfully.') +} + +fn destroy() ! { + console.print_header('Destroying CryptPad...') + installer := get()! + res := osal.exec(cmd: 'kubectl delete ns ${installer.namespace}', ignore_error: true)! + if res.exit_code != 0 { + console.print_stderr('Failed to delete namespace ${installer.namespace}: ${res.output}') + } else { + console.print_info('Namespace ${installer.namespace} deleted.') + } +} diff --git a/lib/installers/k8s/cryptpad/cryptpad_factory_.v b/lib/installers/k8s/cryptpad/cryptpad_factory_.v new file mode 100644 index 00000000..36f35747 --- /dev/null +++ b/lib/installers/k8s/cryptpad/cryptpad_factory_.v @@ -0,0 +1,309 @@ +module cryptpad + +import incubaid.herolib.core.base +import incubaid.herolib.core.playbook { PlayBook } +import incubaid.herolib.ui.console +import json +import incubaid.herolib.osal.startupmanager +import time + +__global ( + cryptpad_global map[string]&CryptpadServer + cryptpad_default string +) + +/////////FACTORY + +@[params] +pub struct ArgsGet { +pub mut: + name string = 'default' + fromdb bool // will load from filesystem + create bool // default will not create if not exist + hostname string + namespace string +} + +pub fn new(args ArgsGet) !&CryptpadServer { + mut obj := CryptpadServer{ + name: args.name + hostname: args.hostname + namespace: args.namespace + } + set(obj)! + return get(name: args.name)! +} + +pub fn get(args ArgsGet) !&CryptpadServer { + mut context := base.context()! + cryptpad_default = args.name + if args.fromdb || args.name !in cryptpad_global { + mut r := context.redis()! + if r.hexists('context:cryptpad', args.name)! { + data := r.hget('context:cryptpad', args.name)! + if data.len == 0 { + print_backtrace() + return error('CryptpadServer with name: ${args.name} does not exist, prob bug.') + } + mut obj := json.decode(CryptpadServer, data)! + set_in_mem(obj)! + } else { + if args.create { + new(args)! + } else { + print_backtrace() + return error("CryptpadServer with name '${args.name}' does not exist") + } + } + return get(name: args.name)! // no longer from db nor create + } + return cryptpad_global[args.name] or { + print_backtrace() + return error('could not get config for cryptpad with name:${args.name}') + } +} + +// register the config for the future +pub fn set(o CryptpadServer) ! { + mut o2 := set_in_mem(o)! + cryptpad_default = o2.name + mut context := base.context()! + mut r := context.redis()! + r.hset('context:cryptpad', o2.name, json.encode(o2))! +} + +// does the config exists? +pub fn exists(args ArgsGet) !bool { + mut context := base.context()! + mut r := context.redis()! + return r.hexists('context:cryptpad', args.name)! +} + +pub fn delete(args ArgsGet) ! { + mut context := base.context()! + mut r := context.redis()! + r.hdel('context:cryptpad', args.name)! +} + +@[params] +pub struct ArgsList { +pub mut: + fromdb bool // will load from filesystem +} + +// if fromdb set: load from filesystem, and not from mem, will also reset what is in mem +pub fn list(args ArgsList) ![]&CryptpadServer { + mut res := []&CryptpadServer{} + mut context := base.context()! + if args.fromdb { + // reset what is in mem + cryptpad_global = map[string]&CryptpadServer{} + cryptpad_default = '' + } + if args.fromdb { + mut r := context.redis()! + mut l := r.hkeys('context:cryptpad')! + + for name in l { + res << get(name: name, fromdb: true)! + } + return res + } else { + // load from memory + for _, client in cryptpad_global { + res << client + } + } + return res +} + +// only sets in mem, does not set as config +fn set_in_mem(o CryptpadServer) !CryptpadServer { + mut o2 := obj_init(o)! + cryptpad_global[o2.name] = &o2 + cryptpad_default = o2.name + return o2 +} + +pub fn play(mut plbook PlayBook) ! { + if !plbook.exists(filter: 'cryptpad.') { + return + } + mut install_actions := plbook.find(filter: 'cryptpad.configure')! + if install_actions.len > 0 { + for mut install_action in install_actions { + heroscript := install_action.heroscript() + mut obj2 := heroscript_loads(heroscript)! + set(obj2)! + install_action.done = true + } + } + mut other_actions := plbook.find(filter: 'cryptpad.')! + for mut other_action in other_actions { + if other_action.name in ['destroy', 'install', 'build'] { + mut p := other_action.params + reset := p.get_default_false('reset') + if other_action.name == 'destroy' || reset { + console.print_debug('install action cryptpad.destroy') + destroy()! + } + if other_action.name == 'install' { + console.print_debug('install action cryptpad.install') + install()! + } + } + if other_action.name in ['start', 'stop', 'restart'] { + mut p := other_action.params + name := p.get('name')! + mut cryptpad_obj := get(name: name)! + console.print_debug('action object:\n${cryptpad_obj}') + if other_action.name == 'start' { + console.print_debug('install action cryptpad.${other_action.name}') + cryptpad_obj.start()! + } + + if other_action.name == 'stop' { + console.print_debug('install action cryptpad.${other_action.name}') + cryptpad_obj.stop()! + } + if other_action.name == 'restart' { + console.print_debug('install action cryptpad.${other_action.name}') + cryptpad_obj.restart()! + } + } + other_action.done = true + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +//////////////////////////# LIVE CYCLE MANAGEMENT FOR INSTALLERS /////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////////////////// + +fn startupmanager_get(cat startupmanager.StartupManagerType) !startupmanager.StartupManager { + // unknown + // screen + // zinit + // tmux + // systemd + match cat { + .screen { + console.print_debug("installer: cryptpad' startupmanager get screen") + return startupmanager.get(.screen)! + } + .zinit { + console.print_debug("installer: cryptpad' startupmanager get zinit") + return startupmanager.get(.zinit)! + } + .systemd { + console.print_debug("installer: cryptpad' startupmanager get systemd") + return startupmanager.get(.systemd)! + } + else { + console.print_debug("installer: cryptpad' startupmanager get auto") + return startupmanager.get(.auto)! + } + } +} + +// load from disk and make sure is properly intialized +pub fn (mut self CryptpadServer) reload() ! { + self = obj_init(self)! +} + +pub fn (mut self CryptpadServer) start() ! { + if self.running()! { + return + } + + console.print_header('installer: cryptpad start') + + if !installed()! { + install()! + } + + configure()! + + start_pre()! + + for zprocess in startupcmd()! { + mut sm := startupmanager_get(zprocess.startuptype)! + + console.print_debug('installer: cryptpad starting with ${zprocess.startuptype}...') + + sm.new(zprocess)! + + sm.start(zprocess.name)! + } + + start_post()! + + for _ in 0 .. 50 { + if self.running()! { + return + } + time.sleep(100 * time.millisecond) + } + return error('cryptpad did not install properly.') +} + +@[params] +pub struct InstallArgs { +pub mut: + reset bool +} + +pub fn (mut self CryptpadServer) install_start(args InstallArgs) ! { + switch(self.name) + self.install(args)! + self.start()! +} + +pub fn (mut self CryptpadServer) stop() ! { + switch(self.name) + stop_pre()! + for zprocess in startupcmd()! { + mut sm := startupmanager_get(zprocess.startuptype)! + sm.stop(zprocess.name)! + } + stop_post()! +} + +pub fn (mut self CryptpadServer) restart() ! { + switch(self.name) + self.stop()! + self.start()! +} + +pub fn (mut self CryptpadServer) running() !bool { + switch(self.name) + + // walk over the generic processes, if not running return + for zprocess in startupcmd()! { + if zprocess.startuptype != .screen { + mut sm := startupmanager_get(zprocess.startuptype)! + r := sm.running(zprocess.name)! + if r == false { + return false + } + } + } + return running()! +} + + +pub fn (mut self CryptpadServer) install(args InstallArgs) ! { + switch(self.name) + if args.reset || (!installed()!) { + install()! + } +} + +pub fn (mut self CryptpadServer) destroy() ! { + switch(self.name) + self.stop() or {} + destroy()! +} + +// switch instance to be used for cryptpad +pub fn switch(name string) { +} diff --git a/lib/installers/k8s/cryptpad/cryptpad_model.v b/lib/installers/k8s/cryptpad/cryptpad_model.v new file mode 100644 index 00000000..dbb01954 --- /dev/null +++ b/lib/installers/k8s/cryptpad/cryptpad_model.v @@ -0,0 +1,50 @@ +module cryptpad + +import incubaid.herolib.data.paramsparser +import incubaid.herolib.data.encoderhero +import os +import incubaid.herolib.ui.console +import incubaid.herolib.core.pathlib + +pub const version = '1.0.0' +const singleton = true +const default = true + +// THIS THE THE SOURCE OF THE INFORMATION OF THIS FILE, HERE WE HAVE THE CONFIG OBJECT CONFIGURED AND MODELLED +@[heap] +pub struct CryptpadServer { +pub mut: + name string = 'default' + hostname string + namespace string = 'collab' +} + +// your checking & initialization code if needed +fn obj_init(mycfg_ CryptpadServer) !CryptpadServer { + mut mycfg := mycfg_ + if mycfg.hostname == '' { + return error('hostname cannot be empty') + } + if mycfg.namespace == '' { + mycfg.namespace = 'collab' + } + return mycfg +} + +// called before start if done +fn configure() ! { + // We will implement the configuration logic here, + // like generating the yaml files from templates. + console.print_debug('configuring cryptpad...') +} + +/////////////NORMALLY NO NEED TO TOUCH + +pub fn heroscript_dumps(obj CryptpadServer) !string { + return encoderhero.encode[CryptpadServer](obj)! +} + +pub fn heroscript_loads(heroscript string) !CryptpadServer { + mut obj := encoderhero.decode[CryptpadServer](heroscript)! + return obj +} diff --git a/lib/installers/k8s/cryptpad/templates/cryptpad.yaml b/lib/installers/k8s/cryptpad/templates/cryptpad.yaml new file mode 100644 index 00000000..4c099792 --- /dev/null +++ b/lib/installers/k8s/cryptpad/templates/cryptpad.yaml @@ -0,0 +1,127 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: @{config_values.namespace} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cryptpad-config + namespace: @{config_values.namespace} +data: + config.js: | + module.exports = { + httpUnsafeOrigin: 'https://@{config_values.hostname}.gent01.grid.tf', + httpSafeOrigin: 'https://@{config_values.hostname}sb.gent01.grid.tf', + httpAddress: '0.0.0.0', + httpPort: 80, + + websocketPort: 3003, + websocketPath: '/cryptpad_websocket', + + blockPath: './block', + blobPath: './blob', + dataPath: './data', + filePath: './datastore', + logToStdout: true, + logLevel: 'info', + }; +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cryptpad + namespace: @{config_values.namespace} +spec: + replicas: 1 + selector: + matchLabels: { app: cryptpad } + template: + metadata: + labels: { app: cryptpad } + spec: + initContainers: + - name: fix-perms + image: busybox:1.36 + command: ["/bin/sh","-lc"] + args: + - > + chown -R 4001:4001 + /cryptpad/blob /cryptpad/block /cryptpad/data /cryptpad/datastore /cryptpad/customize || true + volumeMounts: + - { name: blob, mountPath: /cryptpad/blob } + - { name: block, mountPath: /cryptpad/block } + - { name: data, mountPath: /cryptpad/data } + - { name: files, mountPath: /cryptpad/datastore } + - { name: customize, mountPath: /cryptpad/customize } + containers: + - name: cryptpad + image: cryptpad/cryptpad:latest + ports: + - { name: http, containerPort: 80 } + - { name: ws, containerPort: 3003 } + env: + - { name: CPAD_CONF, value: "/cryptpad/config/config.js" } + - { name: CPAD_MAIN_DOMAIN, value: "https://@{config_values.hostname}.gent01.grid.tf" } + - { name: CPAD_SANDBOX_DOMAIN, value: "https://@{config_values.hostname}sb.gent01.grid.tf" } + - { name: CPAD_INSTALL_ONLYOFFICE, value: "no" } + readinessProbe: + httpGet: { path: /, port: 80 } + initialDelaySeconds: 20 + periodSeconds: 10 + volumeMounts: + - { name: cfg, mountPath: /cryptpad/config/config.js, subPath: config.js, readOnly: true } + - { name: blob, mountPath: /cryptpad/blob } + - { name: block, mountPath: /cryptpad/block } + - { name: data, mountPath: /cryptpad/data } + - { name: files, mountPath: /cryptpad/datastore } + - { name: customize, mountPath: /cryptpad/customize } + volumes: + - name: cfg + configMap: + name: cryptpad-config + items: [{ key: config.js, path: config.js }] + - { name: blob, emptyDir: {} } + - { name: block, emptyDir: {} } + - { name: data, emptyDir: {} } + - { name: files, emptyDir: {} } + - { name: customize, emptyDir: {} } +--- +apiVersion: v1 +kind: Service +metadata: + name: cryptpad + namespace: @{config_values.namespace} +spec: + selector: { app: cryptpad } + ports: + - { name: http, port: 80, targetPort: 80 } + - { name: ws, port: 3003, targetPort: 3003 } +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: cryptpad + namespace: @{config_values.namespace} + annotations: + kubernetes.io.ingress.class: traefik +spec: + rules: + - host: @{config_values.hostname}.gent01.grid.tf + http: + paths: + - path: / + pathType: Prefix + backend: { service: { name: cryptpad, port: { number: 80 } } } + - path: /cryptpad_websocket + pathType: Prefix + backend: { service: { name: cryptpad, port: { number: 3003 } } } + - host: @{config_values.hostname}sb.gent01.grid.tf + http: + paths: + - path: / + pathType: Prefix + backend: { service: { name: cryptpad, port: { number: 80 } } } + - path: /cryptpad_websocket + pathType: Prefix + backend: { service: { name: cryptpad, port: { number: 3003 } } } \ No newline at end of file diff --git a/lib/installers/k8s/cryptpad/templates/tfgw-cryptpad.yaml b/lib/installers/k8s/cryptpad/templates/tfgw-cryptpad.yaml new file mode 100644 index 00000000..b861d84a --- /dev/null +++ b/lib/installers/k8s/cryptpad/templates/tfgw-cryptpad.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: @{config_values.namespace} +--- +apiVersion: ingress.grid.tf/v1 +kind: TFGW +metadata: + name: cryptpad-main + namespace: @{config_values.namespace} +spec: + hostname: "@{config_values.hostname}" + backends: +@{config_values.backends} +--- +apiVersion: ingress.grid.tf/v1 +kind: TFGW +metadata: + name: cryptpad-sandbox + namespace: @{config_values.namespace} +spec: + hostname: "@{config_values.hostname}sb" # Sandbox domain will be always with prefix hostname+sb + backends: +@{config_values.backends} \ No newline at end of file From 81adc60eea020a01edb2943f4f53b8148a47af4a Mon Sep 17 00:00:00 2001 From: peternashaat Date: Thu, 30 Oct 2025 13:20:12 +0000 Subject: [PATCH 5/7] feat(cryptpad): Use constants for deployment retry logic Refactor the installer to use global constants for the maximum number of retries and the check interval when verifying deployments. This change removes hardcoded values from the FQDN and deployment status checks, improving maintainability and centralizing configuration. --- .../k8s/cryptpad/cryptpad_actions.v | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/installers/k8s/cryptpad/cryptpad_actions.v b/lib/installers/k8s/cryptpad/cryptpad_actions.v index 92798562..040d4c88 100644 --- a/lib/installers/k8s/cryptpad/cryptpad_actions.v +++ b/lib/installers/k8s/cryptpad/cryptpad_actions.v @@ -10,6 +10,9 @@ import os import strings import time +const max_deployment_retries = 30 +const deployment_check_interval_seconds = 2 + fn startupcmd() ![]startupmanager.ZProcessNewArgs { // We don't have a long-running process to manage with startupmanager for this installer, // but we'll keep the function for consistency. @@ -135,8 +138,8 @@ fn install() ! { console.print_info('Gateway YAML file applied successfully.') // 5. Verify TFGW deployments - verify_tfgw_deployment(tfgw_name: 'cryptpad-main', namespace: installer.namespace, retry: 30)! - verify_tfgw_deployment(tfgw_name: 'cryptpad-sandbox', namespace: installer.namespace, retry: 30)! + verify_tfgw_deployment(tfgw_name: 'cryptpad-main', namespace: installer.namespace)! + verify_tfgw_deployment(tfgw_name: 'cryptpad-sandbox', namespace: installer.namespace)! // 6. Apply Cryptpad YAML console.print_info('Applying Cryptpad YAML file to the cluster...') @@ -149,13 +152,13 @@ fn install() ! { // 7. Verify deployment status console.print_info('Verifying deployment status...') mut is_running := false - for i in 0 .. 30 { + for i in 0 .. max_deployment_retries { if running()! { is_running = true break } - console.print_info('Waiting for CryptPad deployment to be ready... (${i + 1}/30)') - time.sleep(2 * time.second) + console.print_info('Waiting for CryptPad deployment to be ready... (${i + 1}/${max_deployment_retries})') + time.sleep(deployment_check_interval_seconds * time.second) } if is_running { @@ -171,14 +174,13 @@ struct VerifyTfgwDeployment { pub mut: tfgw_name string // tfgw serivce generating the FQDN namespace string // namespace name for cryptpad deployments/services - retry int // number of retries } // Function for verifying the generating of of the FQDN using tfgw crd fn verify_tfgw_deployment(args VerifyTfgwDeployment) ! { console.print_info('Verifying TFGW deployment for ${args.tfgw_name}...') mut is_fqdn_generated := false - for i in 0 .. 30 { + for i in 0 .. max_deployment_retries { res := osal.exec( cmd: 'kubectl get tfgw ${args.tfgw_name} -n ${args.namespace} -o jsonpath="{.status.fqdn}"' ignore_error: true @@ -187,8 +189,8 @@ fn verify_tfgw_deployment(args VerifyTfgwDeployment) ! { is_fqdn_generated = true break } - console.print_info('Waiting for FQDN to be generated for ${args.tfgw_name}... (${i + 1}/30)') - time.sleep(2 * time.second) + console.print_info('Waiting for FQDN to be generated for ${args.tfgw_name}... (${i + 1}/${max_deployment_retries})') + time.sleep(deployment_check_interval_seconds * time.second) } if !is_fqdn_generated { From 80108d4b367c311b5b3926b50dd3b4729dd7d53f Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Thu, 30 Oct 2025 17:58:03 +0300 Subject: [PATCH 6/7] refactor: Refactor Kubernetes client and CryptPad installer - Replace kubectl exec calls with Kubernetes client methods - Improve error handling and logging in Kubernetes client - Enhance node information retrieval and parsing - Add comprehensive unit tests for Kubernetes client and Node structs - Refine YAML validation to allow custom resource definitions - Update CryptPad installer to use the refactored Kubernetes client --- examples/installers/k8s/.gitignore | 1 + examples/installers/k8s/cryptpad.vsh | 4 +- lib/clients/zinit/zinit_factory_.v | 1 - lib/clients/zinit/zinit_model.v | 2 - .../k8s/cryptpad/cryptpad_actions.v | 126 +++-- lib/installers/k8s/cryptpad/cryptpad_model.v | 3 - lib/virt/kubernetes/kubernetes_client.v | 180 ++++++- .../kubernetes/kubernetes_resources_model.v | 65 +++ lib/virt/kubernetes/kubernetes_test.v | 490 ++++++++++++++++++ lib/virt/kubernetes/kubernetes_yaml.v | 19 +- 10 files changed, 817 insertions(+), 74 deletions(-) create mode 100644 examples/installers/k8s/.gitignore diff --git a/examples/installers/k8s/.gitignore b/examples/installers/k8s/.gitignore new file mode 100644 index 00000000..f032778d --- /dev/null +++ b/examples/installers/k8s/.gitignore @@ -0,0 +1 @@ +cryptpad diff --git a/examples/installers/k8s/cryptpad.vsh b/examples/installers/k8s/cryptpad.vsh index d8bcc4c6..95649b25 100755 --- a/examples/installers/k8s/cryptpad.vsh +++ b/examples/installers/k8s/cryptpad.vsh @@ -6,7 +6,7 @@ import incubaid.herolib.installers.k8s.cryptpad // 1. Create a new installer instance with a specific hostname. // Replace 'mycryptpad' with your desired hostname. -mut installer := cryptpad.new(hostname: 'mycryptpadtes2222tt')! +mut installer := cryptpad.new(hostname: 'omda')! // 2. Install CryptPad. // This will generate the necessary Kubernetes YAML files and apply them to your cluster. @@ -16,4 +16,4 @@ println('CryptPad installation started.') println('You can access it at https://${installer.hostname}.gent01.grid.tf') // 3. To destroy the deployment, you can run the following: -//installer.destroy()! +// installer.destroy()! diff --git a/lib/clients/zinit/zinit_factory_.v b/lib/clients/zinit/zinit_factory_.v index e2e60d66..89e1b565 100644 --- a/lib/clients/zinit/zinit_factory_.v +++ b/lib/clients/zinit/zinit_factory_.v @@ -2,7 +2,6 @@ module zinit import incubaid.herolib.core.base import incubaid.herolib.core.playbook { PlayBook } -import incubaid.herolib.ui.console import json __global ( diff --git a/lib/clients/zinit/zinit_model.v b/lib/clients/zinit/zinit_model.v index ab336a87..fb5dd95e 100644 --- a/lib/clients/zinit/zinit_model.v +++ b/lib/clients/zinit/zinit_model.v @@ -1,8 +1,6 @@ module zinit import incubaid.herolib.data.encoderhero -import incubaid.herolib.schemas.jsonrpc -import os pub const version = '0.0.0' const singleton = true diff --git a/lib/installers/k8s/cryptpad/cryptpad_actions.v b/lib/installers/k8s/cryptpad/cryptpad_actions.v index 92798562..aa93c766 100644 --- a/lib/installers/k8s/cryptpad/cryptpad_actions.v +++ b/lib/installers/k8s/cryptpad/cryptpad_actions.v @@ -2,10 +2,9 @@ module cryptpad import incubaid.herolib.osal.core as osal import incubaid.herolib.ui.console -import incubaid.herolib.core.texttools -import incubaid.herolib.core import incubaid.herolib.osal.startupmanager import incubaid.herolib.installers.ulist +import incubaid.herolib.virt.kubernetes import os import strings import time @@ -17,23 +16,36 @@ fn startupcmd() ![]startupmanager.ZProcessNewArgs { } fn kubectl_installed() ! { + // Check if kubectl command exists if !osal.cmd_exists('kubectl') { return error('kubectl is not installed. Please install it to continue.') } + // Check if kubectl is configured to connect to a cluster - res := osal.exec(cmd: 'kubectl cluster-info', ignore_error: true)! - if res.exit_code != 0 { + mut k8s := kubernetes.get()! + if !k8s.test_connection()! { return error('kubectl is not configured to connect to a Kubernetes cluster. Please check your kubeconfig.') } } fn running() !bool { installer := get()! - res := osal.exec( - cmd: 'kubectl get deployment cryptpad -n ${installer.namespace}' - ignore_error: true - )! - return res.exit_code == 0 + mut k8s := kubernetes.get()! + + // Try to get the cryptpad deployment + deployments := k8s.get_deployments(installer.namespace) or { + // If we can't get deployments, it's not running + return false + } + + // Check if cryptpad deployment exists + for deployment in deployments { + if deployment.name == 'cryptpad' { + return true + } + } + + return false } fn start_pre() ! { @@ -66,17 +78,21 @@ fn upload() ! { fn get_master_node_ips() ![]string { mut master_ips := []string{} - res := osal.exec( - cmd: 'kubectl get nodes -o jsonpath="{.items[*].status.addresses[?(@.type==\'InternalIP\')].address}" | tr \' \' \'\\n\' | grep \':\'' - )! - if res.exit_code != 0 { - return error('Failed to get master node IPs: ${res.output}') - } - for ip in res.output.split('\n') { - if ip.len > 0 { - master_ips << ip + mut k8s := kubernetes.get()! + + // Get all nodes using the kubernetes client + nodes := k8s.get_nodes()! + + // Extract IPv6 internal IPs from all nodes (dual-stack support) + for node in nodes { + // Check all internal IPs (not just the first one) for IPv6 addresses + for ip in node.internal_ips { + if ip.len > 0 && ip.contains(':') { + master_ips << ip + } } } + return master_ips } @@ -90,6 +106,16 @@ pub mut: fn install() ! { console.print_header('Installing CryptPad...') + // Get installer config to access namespace + installer := get()! + if installer.hostname == '' { + return error('hostname is empty') + } + + // Configure kubernetes client with the correct namespace + mut k8s := kubernetes.get()! + k8s.config.namespace = installer.namespace + // 1. Check for dependencies. console.print_info('Checking for kubectl...') kubectl_installed()! @@ -102,10 +128,6 @@ fn install() ! { // 3. Generate YAML files from templates. console.print_info('Generating YAML files from templates...') - installer := get()! - if installer.hostname == '' { - return error('hostname is empty') - } mut backends_str_builder := strings.new_builder(100) for ip in master_ips { @@ -126,11 +148,11 @@ fn install() ! { os.write_file('/tmp/cryptpad.yaml', temp2)! console.print_info('YAML files generated successfully.') - // 4. Apply the YAML files using `kubectl`. + // 4. Apply the YAML files using kubernetes client console.print_info('Applying Gateway YAML file to the cluster...') - res1 := osal.exec(cmd: 'kubectl apply -f /tmp/tfgw-cryptpad.yaml')! - if res1.exit_code != 0 { - return error('Failed to apply tfgw-cryptpad.yaml: ${res1.output}') + res1 := k8s.apply_yaml('/tmp/tfgw-cryptpad.yaml')! + if !res1.success { + return error('Failed to apply tfgw-cryptpad.yaml: ${res1.stderr}') } console.print_info('Gateway YAML file applied successfully.') @@ -140,9 +162,9 @@ fn install() ! { // 6. Apply Cryptpad YAML console.print_info('Applying Cryptpad YAML file to the cluster...') - res2 := osal.exec(cmd: 'kubectl apply -f /tmp/cryptpad.yaml')! - if res2.exit_code != 0 { - return error('Failed to apply cryptpad.yaml: ${res2.output}') + res2 := k8s.apply_yaml('/tmp/cryptpad.yaml')! + if !res2.success { + return error('Failed to apply cryptpad.yaml: ${res2.stderr}') } console.print_info('Cryptpad YAML file applied successfully.') @@ -177,27 +199,36 @@ pub mut: // Function for verifying the generating of of the FQDN using tfgw crd fn verify_tfgw_deployment(args VerifyTfgwDeployment) ! { console.print_info('Verifying TFGW deployment for ${args.tfgw_name}...') + mut k8s := kubernetes.get()! mut is_fqdn_generated := false - for i in 0 .. 30 { - res := osal.exec( - cmd: 'kubectl get tfgw ${args.tfgw_name} -n ${args.namespace} -o jsonpath="{.status.fqdn}"' - ignore_error: true - )! - if res.exit_code == 0 && res.output != '' { + + for i in 0 .. args.retry { + // Use kubectl_exec for custom resource (TFGW) with jsonpath + result := k8s.kubectl_exec( + command: 'get tfgw ${args.tfgw_name} -n ${args.namespace} -o jsonpath="{.status.fqdn}"' + ) or { + console.print_info('Waiting for FQDN to be generated for ${args.tfgw_name}... (${i + 1}/${args.retry})') + time.sleep(2 * time.second) + continue + } + + if result.success && result.stdout != '' { is_fqdn_generated = true break } - console.print_info('Waiting for FQDN to be generated for ${args.tfgw_name}... (${i + 1}/30)') + console.print_info('Waiting for FQDN to be generated for ${args.tfgw_name}... (${i + 1}/${args.retry})') time.sleep(2 * time.second) } if !is_fqdn_generated { console.print_stderr('Failed to get FQDN for ${args.tfgw_name}.') - res := osal.exec( - cmd: 'kubectl describe tfgw ${args.tfgw_name} -n ${args.namespace}' - ignore_error: true - )! - console.print_stderr(res.output) + // Use describe_resource to get detailed information about the TFGW resource + result := k8s.describe_resource( + resource: 'tfgw' + resource_name: args.tfgw_name + namespace: args.namespace + ) or { return error('TFGW deployment failed for ${args.tfgw_name}.') } + console.print_stderr(result.stdout) return error('TFGW deployment failed for ${args.tfgw_name}.') } console.print_info('TFGW deployment for ${args.tfgw_name} verified successfully.') @@ -206,9 +237,16 @@ fn verify_tfgw_deployment(args VerifyTfgwDeployment) ! { fn destroy() ! { console.print_header('Destroying CryptPad...') installer := get()! - res := osal.exec(cmd: 'kubectl delete ns ${installer.namespace}', ignore_error: true)! - if res.exit_code != 0 { - console.print_stderr('Failed to delete namespace ${installer.namespace}: ${res.output}') + mut k8s := kubernetes.get()! + + // Delete the namespace using kubernetes client + result := k8s.delete_resource('namespace', installer.namespace, '') or { + console.print_stderr('Failed to delete namespace ${installer.namespace}: ${err}') + return + } + + if !result.success { + console.print_stderr('Failed to delete namespace ${installer.namespace}: ${result.stderr}') } else { console.print_info('Namespace ${installer.namespace} deleted.') } diff --git a/lib/installers/k8s/cryptpad/cryptpad_model.v b/lib/installers/k8s/cryptpad/cryptpad_model.v index dbb01954..6ea8176d 100644 --- a/lib/installers/k8s/cryptpad/cryptpad_model.v +++ b/lib/installers/k8s/cryptpad/cryptpad_model.v @@ -1,10 +1,7 @@ module cryptpad -import incubaid.herolib.data.paramsparser import incubaid.herolib.data.encoderhero -import os import incubaid.herolib.ui.console -import incubaid.herolib.core.pathlib pub const version = '1.0.0' const singleton = true diff --git a/lib/virt/kubernetes/kubernetes_client.v b/lib/virt/kubernetes/kubernetes_client.v index cf727cdc..25dc890d 100644 --- a/lib/virt/kubernetes/kubernetes_client.v +++ b/lib/virt/kubernetes/kubernetes_client.v @@ -12,6 +12,15 @@ pub mut: retry int } +// Args for describing a resource +@[params] +pub struct DescribeResourceArgs { +pub mut: + resource string // Resource type: pod, node, service, deployment, tfgw, etc. + resource_name string // Name of the specific resource instance + namespace string // Namespace (empty string for cluster-scoped resources) +} + pub struct KubectlResult { pub mut: exit_code int @@ -40,18 +49,52 @@ pub fn (mut k KubeClient) kubectl_exec(args KubectlExecArgs) !KubectlResult { console.print_debug('executing: ${cmd}') - job := osal.exec( - cmd: cmd - timeout: args.timeout - retry: args.retry - raise_error: false - )! + // Check if this is a command that might produce large output + is_large_output := args.command.contains('get nodes') || args.command.contains('get pods') + || args.command.contains('get deployments') || args.command.contains('get services') - return KubectlResult{ - exit_code: job.exit_code - stdout: job.output - stderr: job.error - success: job.exit_code == 0 + if is_large_output { + // Use exec_fast for large outputs (avoids 8KB buffer limit in osal.exec) + // exec_fast uses os.execute which doesn't have the pipe buffer limitation + result_output := osal.exec_fast( + cmd: cmd + ignore_error: true + ) or { return error('Failed to execute kubectl command: ${err}') } + + // Check if command succeeded by looking for error messages + if result_output.contains('Error from server') || result_output.contains('error:') + || result_output.contains('Unable to connect') { + return KubectlResult{ + exit_code: 1 + stdout: result_output + stderr: result_output + success: false + } + } + + return KubectlResult{ + exit_code: 0 + stdout: result_output + stderr: '' + success: result_output.len > 0 + } + } else { + // Use regular exec for normal commands (supports timeout and proper error handling) + // Note: stdout must be true to prevent process from hanging when output buffer fills + job := osal.exec( + cmd: cmd + timeout: args.timeout + retry: args.retry + raise_error: false + stdout: true + )! + + return KubectlResult{ + exit_code: job.exit_code + stdout: job.output + stderr: job.error + success: job.exit_code == 0 + } } } @@ -244,6 +287,100 @@ pub fn (mut k KubeClient) get_services(namespace string) ![]Service { return services } +// Get nodes from cluster +pub fn (mut k KubeClient) get_nodes() ![]Node { + result := k.kubectl_exec(command: 'get nodes -o json')! + if !result.success { + return error('Failed to get nodes: ${result.stderr}') + } + + // Parse JSON response using struct-based decoding + node_list := json.decode(KubectlNodeListResponse, result.stdout) or { + // Log error details for debugging + console.print_stderr('Failed to parse nodes JSON response') + console.print_stderr('Error: ${err}') + console.print_stderr('Response length: ${result.stdout.len} bytes') + if result.stdout.len > 0 { + console.print_stderr('First 200 chars: ${result.stdout[..if result.stdout.len < 200 { + result.stdout.len + } else { + 200 + }]}') + } + return error('Failed to parse nodes JSON: ${err}') + } + + mut nodes := []Node{} + + for item in node_list.items { + // Extract IP addresses (handle dual-stack: multiple IPs of same type) + mut internal_ips := []string{} + mut external_ips := []string{} + mut hostname := '' + + for addr in item.status.addresses { + match addr.address_type { + 'InternalIP' { + internal_ips << addr.address + } + 'ExternalIP' { + external_ips << addr.address + } + 'Hostname' { + hostname = addr.address + } + else {} + } + } + + // For backward compatibility, use first internal/external IP + internal_ip := if internal_ips.len > 0 { internal_ips[0] } else { '' } + external_ip := if external_ips.len > 0 { external_ips[0] } else { '' } + + // Determine node status from conditions + mut node_status := 'Unknown' + for condition in item.status.conditions { + if condition.condition_type == 'Ready' { + node_status = if condition.status == 'True' { 'Ready' } else { 'NotReady' } + break + } + } + + // Extract roles from labels + mut roles := []string{} + for label_key, _ in item.metadata.labels { + if label_key.starts_with('node-role.kubernetes.io/') { + role := label_key.all_after('node-role.kubernetes.io/') + if role.len > 0 { + roles << role + } + } + } + + // Create Node struct from kubectl response + node := Node{ + name: item.metadata.name + internal_ip: internal_ip + external_ip: external_ip + internal_ips: internal_ips + external_ips: external_ips + hostname: hostname + status: node_status + roles: roles + kubelet_version: item.status.node_info.kubelet_version + os_image: item.status.node_info.os_image + kernel_version: item.status.node_info.kernel_version + container_runtime: item.status.node_info.container_runtime_version + labels: item.metadata.labels + created_at: item.metadata.creation_timestamp + } + + nodes << node + } + + return nodes +} + // Apply YAML file pub fn (mut k KubeClient) apply_yaml(yaml_path string) !KubectlResult { // Validate before applying @@ -252,7 +389,10 @@ pub fn (mut k KubeClient) apply_yaml(yaml_path string) !KubectlResult { return error('YAML validation failed: ${validation.errors.join(', ')}') } + console.print_debug('Applying YAML file: ${yaml_path}') result := k.kubectl_exec(command: 'apply -f ${yaml_path}')! + console.print_debug('Apply completed with exit code: ${result.exit_code}') + if result.success { console.print_green('Applied: ${validation.kind}/${validation.metadata.name}') } @@ -265,13 +405,19 @@ pub fn (mut k KubeClient) delete_resource(kind string, name string, namespace st return result } -// Describe resource -pub fn (mut k KubeClient) describe_resource(kind string, name string, namespace string) !string { - result := k.kubectl_exec(command: 'describe ${kind} ${name} -n ${namespace}')! - if !result.success { - return error('Failed to describe resource: ${result.stderr}') +// Describe resource - provides detailed information about a specific resource +pub fn (mut k KubeClient) describe_resource(args DescribeResourceArgs) !KubectlResult { + // Build the describe command + mut cmd := 'describe ${args.resource} ${args.resource_name}' + + // Only add namespace flag if namespace is not empty (for namespaced resources) + if args.namespace.len > 0 { + cmd += ' -n ${args.namespace}' } - return result.stdout + + // Execute the command + result := k.kubectl_exec(command: cmd)! + return result } // Port forward diff --git a/lib/virt/kubernetes/kubernetes_resources_model.v b/lib/virt/kubernetes/kubernetes_resources_model.v index f489b553..d74cf769 100644 --- a/lib/virt/kubernetes/kubernetes_resources_model.v +++ b/lib/virt/kubernetes/kubernetes_resources_model.v @@ -309,6 +309,52 @@ struct KubectlLoadBalancerIngress { ip string } +// Node list response from 'kubectl get nodes -o json' +struct KubectlNodeListResponse { + items []KubectlNodeItem +} + +struct KubectlNodeItem { + metadata KubectlNodeMetadata + spec KubectlNodeSpec + status KubectlNodeStatus +} + +struct KubectlNodeMetadata { + name string + labels map[string]string + creation_timestamp string @[json: creationTimestamp] +} + +struct KubectlNodeSpec { + pod_cidr string @[json: podCIDR] +} + +struct KubectlNodeStatus { + addresses []KubectlNodeAddress + conditions []KubectlNodeCondition + node_info KubectlNodeSystemInfo @[json: nodeInfo] +} + +struct KubectlNodeAddress { + address string @[json: address] + address_type string @[json: type] +} + +struct KubectlNodeCondition { + condition_type string @[json: type] + status string +} + +struct KubectlNodeSystemInfo { + architecture string + kernel_version string @[json: kernelVersion] + os_image string @[json: osImage] + operating_system string @[json: operatingSystem] + kubelet_version string @[json: kubeletVersion] + container_runtime_version string @[json: containerRuntimeVersion] +} + // ============================================================================ // Runtime resource structs (returned from kubectl get commands) // ============================================================================ @@ -352,6 +398,25 @@ pub mut: created_at string } +// Node runtime information +pub struct Node { +pub mut: + name string + internal_ip string // Primary internal IP (first in list) + external_ip string // Primary external IP (first in list) + internal_ips []string // All internal IPs (for dual-stack support) + external_ips []string // All external IPs (for dual-stack support) + hostname string + status string // Ready, NotReady, Unknown + roles []string + kubelet_version string + os_image string + kernel_version string + container_runtime string + labels map[string]string + created_at string +} + // Version information from kubectl version command pub struct VersionInfo { pub mut: diff --git a/lib/virt/kubernetes/kubernetes_test.v b/lib/virt/kubernetes/kubernetes_test.v index 6c48b5dc..920e32fa 100644 --- a/lib/virt/kubernetes/kubernetes_test.v +++ b/lib/virt/kubernetes/kubernetes_test.v @@ -1,5 +1,7 @@ module kubernetes +import json + // ============================================================================ // Unit Tests for Kubernetes Client Module // These tests verify struct creation and data handling without executing @@ -247,3 +249,491 @@ fn test_kubectl_result_error() ! { assert result.exit_code == 1 assert result.stderr.contains('Error') } + +// ============================================================================ +// Unit Tests for Node Struct and get_nodes() Method +// ============================================================================ + +// Test Node struct creation with all fields +fn test_node_struct_creation() ! { + mut node := Node{ + name: 'worker-node-1' + internal_ip: '192.168.1.10' + external_ip: '203.0.113.10' + hostname: 'worker-node-1.example.com' + status: 'Ready' + roles: ['worker'] + kubelet_version: 'v1.31.0' + os_image: 'Ubuntu 22.04.3 LTS' + kernel_version: '5.15.0-91-generic' + container_runtime: 'containerd://1.7.2' + labels: { + 'kubernetes.io/hostname': 'worker-node-1' + 'node-role.kubernetes.io/worker': '' + } + created_at: '2024-01-15T08:00:00Z' + } + + assert node.name == 'worker-node-1' + assert node.internal_ip == '192.168.1.10' + assert node.external_ip == '203.0.113.10' + assert node.hostname == 'worker-node-1.example.com' + assert node.status == 'Ready' + assert node.roles.len == 1 + assert node.roles[0] == 'worker' + assert node.kubelet_version == 'v1.31.0' + assert node.os_image == 'Ubuntu 22.04.3 LTS' + assert node.kernel_version == '5.15.0-91-generic' + assert node.container_runtime == 'containerd://1.7.2' + assert node.labels['kubernetes.io/hostname'] == 'worker-node-1' + assert node.created_at == '2024-01-15T08:00:00Z' +} + +// Test Node struct with master role +fn test_node_struct_master_role() ! { + mut node := Node{ + name: 'master-node-1' + status: 'Ready' + roles: ['control-plane', 'master'] + } + + assert node.name == 'master-node-1' + assert node.status == 'Ready' + assert node.roles.len == 2 + assert 'control-plane' in node.roles + assert 'master' in node.roles +} + +// Test Node struct with NotReady status +fn test_node_struct_not_ready() ! { + mut node := Node{ + name: 'worker-node-2' + status: 'NotReady' + } + + assert node.name == 'worker-node-2' + assert node.status == 'NotReady' +} + +// Test Node struct with IPv6 internal IP +fn test_node_struct_ipv6() ! { + mut node := Node{ + name: 'worker-node-3' + internal_ip: '2001:db8::1' + status: 'Ready' + } + + assert node.name == 'worker-node-3' + assert node.internal_ip == '2001:db8::1' + assert node.internal_ip.contains(':') + assert node.status == 'Ready' +} + +// Test Node struct with default values +fn test_node_default_values() ! { + mut node := Node{} + + assert node.name == '' + assert node.internal_ip == '' + assert node.external_ip == '' + assert node.hostname == '' + assert node.status == '' + assert node.roles.len == 0 + assert node.kubelet_version == '' + assert node.os_image == '' + assert node.kernel_version == '' + assert node.container_runtime == '' + assert node.labels.len == 0 + assert node.created_at == '' +} + +// Test Node struct with multiple roles +fn test_node_multiple_roles() ! { + mut node := Node{ + name: 'control-plane-1' + roles: ['control-plane', 'master', 'etcd'] + } + + assert node.roles.len == 3 + assert 'control-plane' in node.roles + assert 'master' in node.roles + assert 'etcd' in node.roles +} + +// ============================================================================ +// Unit Tests for DescribeResourceArgs Struct +// ============================================================================ + +// Test DescribeResourceArgs struct for namespaced resource +fn test_describe_resource_args_namespaced() ! { + mut args := DescribeResourceArgs{ + resource: 'pod' + resource_name: 'nginx-pod' + namespace: 'default' + } + + assert args.resource == 'pod' + assert args.resource_name == 'nginx-pod' + assert args.namespace == 'default' +} + +// Test DescribeResourceArgs struct for cluster-scoped resource +fn test_describe_resource_args_cluster_scoped() ! { + mut args := DescribeResourceArgs{ + resource: 'node' + resource_name: 'worker-node-1' + namespace: '' + } + + assert args.resource == 'node' + assert args.resource_name == 'worker-node-1' + assert args.namespace == '' +} + +// Test DescribeResourceArgs struct for custom resource (TFGW) +fn test_describe_resource_args_custom_resource() ! { + mut args := DescribeResourceArgs{ + resource: 'tfgw' + resource_name: 'cryptpad-main' + namespace: 'cryptpad' + } + + assert args.resource == 'tfgw' + assert args.resource_name == 'cryptpad-main' + assert args.namespace == 'cryptpad' +} + +// ============================================================================ +// JSON Parsing Tests for Kubectl Responses +// ============================================================================ + +// Test parsing kubectl node list JSON response +fn test_parse_kubectl_node_list_json() ! { + // Sample kubectl get nodes -o json response + json_response := '{ + "items": [ + { + "metadata": { + "name": "k3s-master", + "labels": { + "kubernetes.io/hostname": "k3s-master", + "node-role.kubernetes.io/control-plane": "", + "node-role.kubernetes.io/master": "" + }, + "creationTimestamp": "2024-01-15T08:00:00Z" + }, + "spec": { + "podCIDR": "10.42.0.0/24" + }, + "status": { + "addresses": [ + { + "type": "InternalIP", + "address": "192.168.1.100" + }, + { + "type": "Hostname", + "address": "k3s-master" + } + ], + "conditions": [ + { + "type": "Ready", + "status": "True" + } + ], + "nodeInfo": { + "architecture": "arm64", + "kernelVersion": "5.15.0-91-generic", + "osImage": "Ubuntu 22.04.3 LTS", + "operatingSystem": "linux", + "kubeletVersion": "v1.31.0+k3s1", + "containerRuntimeVersion": "containerd://1.7.11-k3s2" + } + } + } + ] +}' + + // Parse the JSON + node_list := json.decode(KubectlNodeListResponse, json_response)! + + // Verify parsing + assert node_list.items.len == 1 + assert node_list.items[0].metadata.name == 'k3s-master' + assert node_list.items[0].metadata.labels['kubernetes.io/hostname'] == 'k3s-master' + assert node_list.items[0].metadata.labels['node-role.kubernetes.io/control-plane'] == '' + assert node_list.items[0].spec.pod_cidr == '10.42.0.0/24' + assert node_list.items[0].status.addresses.len == 2 + assert node_list.items[0].status.addresses[0].address_type == 'InternalIP' + assert node_list.items[0].status.addresses[0].address == '192.168.1.100' + assert node_list.items[0].status.addresses[1].address_type == 'Hostname' + assert node_list.items[0].status.addresses[1].address == 'k3s-master' + assert node_list.items[0].status.conditions.len == 1 + assert node_list.items[0].status.conditions[0].condition_type == 'Ready' + assert node_list.items[0].status.conditions[0].status == 'True' + assert node_list.items[0].status.node_info.kubelet_version == 'v1.31.0+k3s1' + assert node_list.items[0].status.node_info.os_image == 'Ubuntu 22.04.3 LTS' +} + +// Test parsing kubectl node list with IPv6 addresses +fn test_parse_kubectl_node_list_ipv6() ! { + json_response := '{ + "items": [ + { + "metadata": { + "name": "worker-node-1", + "labels": { + "node-role.kubernetes.io/worker": "" + }, + "creationTimestamp": "2024-01-15T09:00:00Z" + }, + "spec": { + "podCIDR": "10.42.1.0/24" + }, + "status": { + "addresses": [ + { + "type": "InternalIP", + "address": "2001:db8::1" + }, + { + "type": "ExternalIP", + "address": "2001:db8:1::1" + }, + { + "type": "Hostname", + "address": "worker-node-1" + } + ], + "conditions": [ + { + "type": "Ready", + "status": "True" + } + ], + "nodeInfo": { + "architecture": "amd64", + "kernelVersion": "6.5.0-14-generic", + "osImage": "Ubuntu 23.10", + "operatingSystem": "linux", + "kubeletVersion": "v1.31.0", + "containerRuntimeVersion": "containerd://1.7.2" + } + } + } + ] +}' + + node_list := json.decode(KubectlNodeListResponse, json_response)! + + assert node_list.items.len == 1 + assert node_list.items[0].metadata.name == 'worker-node-1' + assert node_list.items[0].status.addresses.len == 3 + + // Verify IPv6 addresses + mut internal_ip := '' + mut external_ip := '' + for addr in node_list.items[0].status.addresses { + if addr.address_type == 'InternalIP' { + internal_ip = addr.address + } + if addr.address_type == 'ExternalIP' { + external_ip = addr.address + } + } + + assert internal_ip == '2001:db8::1' + assert internal_ip.contains(':') + assert external_ip == '2001:db8:1::1' + assert external_ip.contains(':') +} + +// Test parsing node with NotReady status +fn test_parse_kubectl_node_not_ready() ! { + json_response := '{ + "items": [ + { + "metadata": { + "name": "worker-node-2", + "labels": {}, + "creationTimestamp": "2024-01-15T10:00:00Z" + }, + "spec": { + "podCIDR": "" + }, + "status": { + "addresses": [ + { + "type": "InternalIP", + "address": "192.168.1.102" + } + ], + "conditions": [ + { + "type": "Ready", + "status": "False" + } + ], + "nodeInfo": { + "architecture": "amd64", + "kernelVersion": "5.15.0-91-generic", + "osImage": "Ubuntu 22.04.3 LTS", + "operatingSystem": "linux", + "kubeletVersion": "v1.31.0", + "containerRuntimeVersion": "containerd://1.7.2" + } + } + } + ] +}' + + node_list := json.decode(KubectlNodeListResponse, json_response)! + + assert node_list.items.len == 1 + assert node_list.items[0].metadata.name == 'worker-node-2' + assert node_list.items[0].status.conditions[0].condition_type == 'Ready' + assert node_list.items[0].status.conditions[0].status == 'False' +} + +// Test role extraction from node labels +fn test_node_role_extraction() ! { + json_response := '{ + "items": [ + { + "metadata": { + "name": "control-plane-1", + "labels": { + "node-role.kubernetes.io/control-plane": "", + "node-role.kubernetes.io/master": "", + "node-role.kubernetes.io/etcd": "", + "kubernetes.io/hostname": "control-plane-1" + }, + "creationTimestamp": "2024-01-15T08:00:00Z" + }, + "spec": { + "podCIDR": "10.42.0.0/24" + }, + "status": { + "addresses": [ + { + "type": "InternalIP", + "address": "192.168.1.100" + } + ], + "conditions": [ + { + "type": "Ready", + "status": "True" + } + ], + "nodeInfo": { + "architecture": "arm64", + "kernelVersion": "5.15.0-91-generic", + "osImage": "Ubuntu 22.04.3 LTS", + "operatingSystem": "linux", + "kubeletVersion": "v1.31.0", + "containerRuntimeVersion": "containerd://1.7.2" + } + } + } + ] +}' + + node_list := json.decode(KubectlNodeListResponse, json_response)! + + assert node_list.items.len == 1 + + // Extract roles from labels + mut roles := []string{} + for label_key, _ in node_list.items[0].metadata.labels { + if label_key.starts_with('node-role.kubernetes.io/') { + role := label_key.all_after('node-role.kubernetes.io/') + if role.len > 0 { + roles << role + } + } + } + + // Verify roles were extracted + assert roles.len == 3 + assert 'control-plane' in roles + assert 'master' in roles + assert 'etcd' in roles +} + +// Test empty node list +fn test_parse_empty_node_list() ! { + json_response := '{ + "items": [] +}' + + node_list := json.decode(KubectlNodeListResponse, json_response)! + assert node_list.items.len == 0 +} + +// Test dual-stack node (multiple InternalIP addresses) +fn test_parse_dual_stack_node() ! { + json_response := '{ + "items": [ + { + "metadata": { + "name": "dual-stack-node", + "labels": { + "node-role.kubernetes.io/control-plane": "true" + }, + "creationTimestamp": "2025-10-29T12:40:47Z" + }, + "spec": { + "podCIDR": "10.42.0.0/24" + }, + "status": { + "addresses": [ + { + "type": "InternalIP", + "address": "10.20.3.2" + }, + { + "type": "InternalIP", + "address": "477:a3a5:7595:d3da:ff0f:ece1:204e:6691" + }, + { + "type": "Hostname", + "address": "dual-stack-node" + } + ], + "conditions": [ + { + "type": "Ready", + "status": "True" + } + ], + "nodeInfo": { + "architecture": "amd64", + "kernelVersion": "5.15.0-91-generic", + "osImage": "Ubuntu 22.04.3 LTS", + "operatingSystem": "linux", + "kubeletVersion": "v1.31.0+k3s1", + "containerRuntimeVersion": "containerd://1.7.11-k3s2" + } + } + } + ] +}' + + node_list := json.decode(KubectlNodeListResponse, json_response)! + + assert node_list.items.len == 1 + assert node_list.items[0].metadata.name == 'dual-stack-node' + + // Verify we have 2 InternalIP addresses + assert node_list.items[0].status.addresses.len == 3 + + mut internal_ip_count := 0 + for addr in node_list.items[0].status.addresses { + if addr.address_type == 'InternalIP' { + internal_ip_count++ + } + } + assert internal_ip_count == 2 +} diff --git a/lib/virt/kubernetes/kubernetes_yaml.v b/lib/virt/kubernetes/kubernetes_yaml.v index b1198e8d..7e307a24 100644 --- a/lib/virt/kubernetes/kubernetes_yaml.v +++ b/lib/virt/kubernetes/kubernetes_yaml.v @@ -46,11 +46,20 @@ pub fn yaml_validate(yaml_path string) !K8sValidationResult { errors << 'Missing metadata.name field' } - // Validate kind values - valid_kinds := ['Pod', 'Deployment', 'Service', 'ConfigMap', 'Secret', 'StatefulSet', 'DaemonSet', - 'Job', 'CronJob', 'Ingress', 'PersistentVolume', 'PersistentVolumeClaim'] - if kind !in valid_kinds { - errors << 'Invalid kind: ${kind}. Valid kinds: ${valid_kinds.join(', ')}' + // Validate kind values for standard Kubernetes resources + // Allow custom resources (CRDs) which typically have non-standard apiVersions + standard_kinds := ['Pod', 'Deployment', 'Service', 'ConfigMap', 'Secret', 'StatefulSet', + 'DaemonSet', 'Job', 'CronJob', 'Ingress', 'PersistentVolume', 'PersistentVolumeClaim', + 'Namespace', 'ServiceAccount', 'Role', 'RoleBinding', 'ClusterRole', 'ClusterRoleBinding'] + + // Check if it's a standard Kubernetes resource or a custom resource + is_standard_api := api_version.starts_with('v1') || api_version.starts_with('apps/') + || api_version.starts_with('batch/') || api_version.starts_with('networking.k8s.io/') + || api_version.starts_with('rbac.authorization.k8s.io/') + + // Only validate kind for standard Kubernetes resources + if is_standard_api && kind !in standard_kinds { + errors << 'Invalid kind: ${kind}. Valid kinds for standard resources: ${standard_kinds.join(', ')}' } return K8sValidationResult{ From 44c87930749cb71e095716a93f89396d8d4d1a31 Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Sun, 2 Nov 2025 13:34:44 +0200 Subject: [PATCH 7/7] refactor: Update cryptpad installer code - Use installer.kube_client for Kubernetes operations - Remove redundant startupmanager calls - Simplify `delete_resource` command - Add default values for installer name and hostname - Refactor `get` function to use new arguments correctly - Remove commented out example code and unused imports - Change the factory file to load the default instance name - Update the README file of the installer Co-authored-by: peternahaaat --- examples/installers/k8s/cryptpad.vsh | 14 +- lib/apps/biz/cryptpad/.heroscript | 12 - lib/apps/biz/cryptpad/cryptpad_actions.v | 187 ----------- lib/apps/biz/cryptpad/cryptpad_factory_.v | 312 ------------------ lib/apps/biz/cryptpad/cryptpad_model.v | 52 --- lib/apps/biz/cryptpad/readme.md | 44 --- lib/apps/biz/cryptpad/templates/cryptpad.yaml | 127 ------- .../biz/cryptpad/templates/tfgw-cryptpad.yaml | 28 -- lib/installers/k8s/cryptpad/.heroscript | 2 +- lib/installers/k8s/cryptpad/README.md | 72 ++++ .../k8s/cryptpad/cryptpad_actions.v | 157 +++------ .../k8s/cryptpad/cryptpad_factory_.v | 150 +-------- lib/installers/k8s/cryptpad/cryptpad_model.v | 95 ++++-- lib/virt/kubernetes/kubernetes_client.v | 58 +--- 14 files changed, 229 insertions(+), 1081 deletions(-) delete mode 100644 lib/apps/biz/cryptpad/.heroscript delete mode 100644 lib/apps/biz/cryptpad/cryptpad_actions.v delete mode 100644 lib/apps/biz/cryptpad/cryptpad_factory_.v delete mode 100644 lib/apps/biz/cryptpad/cryptpad_model.v delete mode 100644 lib/apps/biz/cryptpad/readme.md delete mode 100644 lib/apps/biz/cryptpad/templates/cryptpad.yaml delete mode 100644 lib/apps/biz/cryptpad/templates/tfgw-cryptpad.yaml create mode 100644 lib/installers/k8s/cryptpad/README.md diff --git a/examples/installers/k8s/cryptpad.vsh b/examples/installers/k8s/cryptpad.vsh index 95649b25..b081bf0d 100755 --- a/examples/installers/k8s/cryptpad.vsh +++ b/examples/installers/k8s/cryptpad.vsh @@ -6,14 +6,18 @@ import incubaid.herolib.installers.k8s.cryptpad // 1. Create a new installer instance with a specific hostname. // Replace 'mycryptpad' with your desired hostname. -mut installer := cryptpad.new(hostname: 'omda')! +mut installer := cryptpad.get( + name: 'kristof' + create: true +)! // 2. Install CryptPad. // This will generate the necessary Kubernetes YAML files and apply them to your cluster. -installer.install()! +// installer.install()! +// cryptpad.delete()! -println('CryptPad installation started.') -println('You can access it at https://${installer.hostname}.gent01.grid.tf') +// println('CryptPad installation started.') +// println('You can access it at https://${installer.hostname}.gent01.grid.tf') // 3. To destroy the deployment, you can run the following: -// installer.destroy()! +installer.destroy()! diff --git a/lib/apps/biz/cryptpad/.heroscript b/lib/apps/biz/cryptpad/.heroscript deleted file mode 100644 index b3269b96..00000000 --- a/lib/apps/biz/cryptpad/.heroscript +++ /dev/null @@ -1,12 +0,0 @@ - -!!hero_code.generate_installer - name:'' - classname:'CryptPad' - singleton:0 - templates:1 - default:1 - title:'' - supported_platforms:'' - startupmanager:1 - hasconfig:1 - build:1 \ No newline at end of file diff --git a/lib/apps/biz/cryptpad/cryptpad_actions.v b/lib/apps/biz/cryptpad/cryptpad_actions.v deleted file mode 100644 index ed3bb12d..00000000 --- a/lib/apps/biz/cryptpad/cryptpad_actions.v +++ /dev/null @@ -1,187 +0,0 @@ -module cryptpad - -import incubaid.herolib.osal.core as osal -import incubaid.herolib.ui.console -import incubaid.herolib.core.texttools -import incubaid.herolib.core.pathlib -import incubaid.herolib.osal.systemd -import incubaid.herolib.osal.startupmanager -import incubaid.herolib.installers.ulist -import incubaid.herolib.installers.lang.golang -import incubaid.herolib.installers.lang.rust -import incubaid.herolib.installers.lang.python -import os - -fn startupcmd() ![]startupmanager.ZProcessNewArgs { - mut installer := get()! - mut res := []startupmanager.ZProcessNewArgs{} - // THIS IS EXAMPLE CODEAND NEEDS TO BE CHANGED - // res << startupmanager.ZProcessNewArgs{ - // name: 'cryptpad' - // cmd: 'cryptpad server' - // env: { - // 'HOME': '/root' - // } - // } - - return res -} - -fn running() !bool { - mut installer := get()! - // THIS IS EXAMPLE CODEAND NEEDS TO BE CHANGED - // this checks health of cryptpad - // curl http://localhost:3333/api/v1/s --oauth2-bearer 1234 works - // url:='http://127.0.0.1:${cfg.port}/api/v1' - // mut conn := httpconnection.new(name: 'cryptpad', url: url)! - - // if cfg.secret.len > 0 { - // conn.default_header.add(.authorization, 'Bearer ${cfg.secret}') - // } - // conn.default_header.add(.content_type, 'application/json') - // console.print_debug("curl -X 'GET' '${url}'/tags --oauth2-bearer ${cfg.secret}") - // r := conn.get_json_dict(prefix: 'tags', debug: false) or {return false} - // println(r) - // if true{panic("ssss")} - // tags := r['Tags'] or { return false } - // console.print_debug(tags) - // console.print_debug('cryptpad is answering.') - return false -} - -fn start_pre() ! { -} - -fn start_post() ! { -} - -fn stop_pre() ! { -} - -fn stop_post() ! { -} - -//////////////////// following actions are not specific to instance of the object - -// checks if a certain version or above is installed -fn installed() !bool { - // THIS IS EXAMPLE CODEAND NEEDS TO BE CHANGED - // res := os.execute('${osal.profile_path_source_and()!} cryptpad version') - // if res.exit_code != 0 { - // return false - // } - // r := res.output.split_into_lines().filter(it.trim_space().len > 0) - // if r.len != 1 { - // return error("couldn't parse cryptpad version.\n${res.output}") - // } - // if texttools.version(version) == texttools.version(r[0]) { - // return true - // } - return false -} - -// get the Upload List of the files -fn ulist_get() !ulist.UList { - // optionally build a UList which is all paths which are result of building, is then used e.g. in upload - return ulist.UList{} -} - -// uploads to S3 server if configured -fn upload() ! { - // installers.upload( - // cmdname: 'cryptpad' - // source: '${gitpath}/target/x86_64-unknown-linux-musl/release/cryptpad' - // )! -} - -fn install() ! { - console.print_header('install cryptpad') - // THIS IS EXAMPLE CODEAND NEEDS TO BE CHANGED - // mut url := '' - // if core.is_linux_arm()! { - // url = 'https://github.com/cryptpad-dev/cryptpad/releases/download/v${version}/cryptpad_${version}_linux_arm64.tar.gz' - // } else if core.is_linux_intel()! { - // url = 'https://github.com/cryptpad-dev/cryptpad/releases/download/v${version}/cryptpad_${version}_linux_amd64.tar.gz' - // } else if core.is_osx_arm()! { - // url = 'https://github.com/cryptpad-dev/cryptpad/releases/download/v${version}/cryptpad_${version}_darwin_arm64.tar.gz' - // } else if osal.is_osx_intel()! { - // url = 'https://github.com/cryptpad-dev/cryptpad/releases/download/v${version}/cryptpad_${version}_darwin_amd64.tar.gz' - // } else { - // return error('unsported platform') - // } - - // mut dest := osal.download( - // url: url - // minsize_kb: 9000 - // expand_dir: '/tmp/cryptpad' - // )! - - // //dest.moveup_single_subdir()! - - // mut binpath := dest.file_get('cryptpad')! - // osal.cmd_add( - // cmdname: 'cryptpad' - // source: binpath.path - // )! -} - -fn build() ! { - // url := 'https://github.com/threefoldtech/cryptpad' - - // make sure we install base on the node - // if core.platform() != .ubuntu { - // return error('only support ubuntu for now') - // } - // golang.install()! - - // console.print_header('build cryptpad') - - // gitpath := gittools.get_repo(coderoot: '/tmp/builder', url: url, reset: true, pull: true)! - - // cmd := ' - // cd ${gitpath} - // source ~/.cargo/env - // exit 1 #todo - // ' - // osal.execute_stdout(cmd)! - // - // //now copy to the default bin path - // mut binpath := dest.file_get('...')! - // adds it to path - // osal.cmd_add( - // cmdname: 'griddriver2' - // source: binpath.path - // )! -} - -fn destroy() ! { - // mut systemdfactory := systemd.new()! - // systemdfactory.destroy("zinit")! - - // osal.process_kill_recursive(name:'zinit')! - // osal.cmd_delete('zinit')! - - // osal.package_remove(' - // podman - // conmon - // buildah - // skopeo - // runc - // ')! - - // //will remove all paths where go/bin is found - // osal.profile_path_add_remove(paths2delete:"go/bin")! - - // osal.rm(" - // podman - // conmon - // buildah - // skopeo - // runc - // /var/lib/containers - // /var/lib/podman - // /var/lib/buildah - // /tmp/podman - // /tmp/conmon - // ")! -} diff --git a/lib/apps/biz/cryptpad/cryptpad_factory_.v b/lib/apps/biz/cryptpad/cryptpad_factory_.v deleted file mode 100644 index 231041de..00000000 --- a/lib/apps/biz/cryptpad/cryptpad_factory_.v +++ /dev/null @@ -1,312 +0,0 @@ -module cryptpad - -import incubaid.herolib.core.base -import incubaid.herolib.core.playbook { PlayBook } -import incubaid.herolib.ui.console -import json -import incubaid.herolib.osal.startupmanager -import time - -__global ( - cryptpad_global map[string]&CryptPad - cryptpad_default string -) - -/////////FACTORY - -@[params] -pub struct ArgsGet { -pub mut: - name string = 'default' - fromdb bool // will load from filesystem - create bool // default will not create if not exist -} - -pub fn new(args ArgsGet) !&CryptPad { - mut obj := CryptPad{ - name: args.name - } - set(obj)! - return get(name: args.name)! -} - -pub fn get(args ArgsGet) !&CryptPad { - mut context := base.context()! - cryptpad_default = args.name - if args.fromdb || args.name !in cryptpad_global { - mut r := context.redis()! - if r.hexists('context:cryptpad', args.name)! { - data := r.hget('context:cryptpad', args.name)! - if data.len == 0 { - print_backtrace() - return error('CryptPad with name: ${args.name} does not exist, prob bug.') - } - mut obj := json.decode(CryptPad, data)! - set_in_mem(obj)! - } else { - if args.create { - new(args)! - } else { - print_backtrace() - return error("CryptPad with name '${args.name}' does not exist") - } - } - return get(name: args.name)! // no longer from db nor create - } - return cryptpad_global[args.name] or { - print_backtrace() - return error('could not get config for cryptpad with name:${args.name}') - } -} - -// register the config for the future -pub fn set(o CryptPad) ! { - mut o2 := set_in_mem(o)! - cryptpad_default = o2.name - mut context := base.context()! - mut r := context.redis()! - r.hset('context:cryptpad', o2.name, json.encode(o2))! -} - -// does the config exists? -pub fn exists(args ArgsGet) !bool { - mut context := base.context()! - mut r := context.redis()! - return r.hexists('context:cryptpad', args.name)! -} - -pub fn delete(args ArgsGet) ! { - mut context := base.context()! - mut r := context.redis()! - r.hdel('context:cryptpad', args.name)! -} - -@[params] -pub struct ArgsList { -pub mut: - fromdb bool // will load from filesystem -} - -// if fromdb set: load from filesystem, and not from mem, will also reset what is in mem -pub fn list(args ArgsList) ![]&CryptPad { - mut res := []&CryptPad{} - mut context := base.context()! - if args.fromdb { - // reset what is in mem - cryptpad_global = map[string]&CryptPad{} - cryptpad_default = '' - } - if args.fromdb { - mut r := context.redis()! - mut l := r.hkeys('context:cryptpad')! - - for name in l { - res << get(name: name, fromdb: true)! - } - return res - } else { - // load from memory - for _, client in cryptpad_global { - res << client - } - } - return res -} - -// only sets in mem, does not set as config -fn set_in_mem(o CryptPad) !CryptPad { - mut o2 := obj_init(o)! - cryptpad_global[o2.name] = &o2 - cryptpad_default = o2.name - return o2 -} - -pub fn play(mut plbook PlayBook) ! { - if !plbook.exists(filter: 'cryptpad.') { - return - } - mut install_actions := plbook.find(filter: 'cryptpad.configure')! - if install_actions.len > 0 { - for mut install_action in install_actions { - heroscript := install_action.heroscript() - mut obj2 := heroscript_loads(heroscript)! - set(obj2)! - install_action.done = true - } - } - mut other_actions := plbook.find(filter: 'cryptpad.')! - for mut other_action in other_actions { - if other_action.name in ['destroy', 'install', 'build'] { - mut p := other_action.params - reset := p.get_default_false('reset') - if other_action.name == 'destroy' || reset { - console.print_debug('install action cryptpad.destroy') - destroy()! - } - if other_action.name == 'install' { - console.print_debug('install action cryptpad.install') - install()! - } - } - if other_action.name in ['start', 'stop', 'restart'] { - mut p := other_action.params - name := p.get('name')! - mut cryptpad_obj := get(name: name)! - console.print_debug('action object:\n${cryptpad_obj}') - if other_action.name == 'start' { - console.print_debug('install action cryptpad.${other_action.name}') - cryptpad_obj.start()! - } - - if other_action.name == 'stop' { - console.print_debug('install action cryptpad.${other_action.name}') - cryptpad_obj.stop()! - } - if other_action.name == 'restart' { - console.print_debug('install action cryptpad.${other_action.name}') - cryptpad_obj.restart()! - } - } - other_action.done = true - } -} - -//////////////////////////////////////////////////////////////////////////////////////////////////// -//////////////////////////# LIVE CYCLE MANAGEMENT FOR INSTALLERS /////////////////////////////////// -//////////////////////////////////////////////////////////////////////////////////////////////////// - -fn startupmanager_get(cat startupmanager.StartupManagerType) !startupmanager.StartupManager { - // unknown - // screen - // zinit - // tmux - // systemd - match cat { - .screen { - console.print_debug("installer: cryptpad' startupmanager get screen") - return startupmanager.get(.screen)! - } - .zinit { - console.print_debug("installer: cryptpad' startupmanager get zinit") - return startupmanager.get(.zinit)! - } - .systemd { - console.print_debug("installer: cryptpad' startupmanager get systemd") - return startupmanager.get(.systemd)! - } - else { - console.print_debug("installer: cryptpad' startupmanager get auto") - return startupmanager.get(.auto)! - } - } -} - -// load from disk and make sure is properly intialized -pub fn (mut self CryptPad) reload() ! { - switch(self.name) - self = obj_init(self)! -} - -pub fn (mut self CryptPad) start() ! { - switch(self.name) - if self.running()! { - return - } - - console.print_header('installer: cryptpad start') - - if !installed()! { - install()! - } - - configure()! - - start_pre()! - - for zprocess in startupcmd()! { - mut sm := startupmanager_get(zprocess.startuptype)! - - console.print_debug('installer: cryptpad starting with ${zprocess.startuptype}...') - - sm.new(zprocess)! - - sm.start(zprocess.name)! - } - - start_post()! - - for _ in 0 .. 50 { - if self.running()! { - return - } - time.sleep(100 * time.millisecond) - } - return error('cryptpad did not install properly.') -} - -pub fn (mut self CryptPad) install_start(args InstallArgs) ! { - switch(self.name) - self.install(args)! - self.start()! -} - -pub fn (mut self CryptPad) stop() ! { - switch(self.name) - stop_pre()! - for zprocess in startupcmd()! { - mut sm := startupmanager_get(zprocess.startuptype)! - sm.stop(zprocess.name)! - } - stop_post()! -} - -pub fn (mut self CryptPad) restart() ! { - switch(self.name) - self.stop()! - self.start()! -} - -pub fn (mut self CryptPad) running() !bool { - switch(self.name) - - // walk over the generic processes, if not running return - for zprocess in startupcmd()! { - if zprocess.startuptype != .screen { - mut sm := startupmanager_get(zprocess.startuptype)! - r := sm.running(zprocess.name)! - if r == false { - return false - } - } - } - return running()! -} - -@[params] -pub struct InstallArgs { -pub mut: - reset bool -} - -pub fn (mut self CryptPad) install(args InstallArgs) ! { - switch(self.name) - if args.reset || (!installed()!) { - install()! - } -} - -pub fn (mut self CryptPad) build() ! { - switch(self.name) - build()! -} - -pub fn (mut self CryptPad) destroy() ! { - switch(self.name) - self.stop() or {} - destroy()! -} - -// switch instance to be used for cryptpad -pub fn switch(name string) { - cryptpad_default = name -} diff --git a/lib/apps/biz/cryptpad/cryptpad_model.v b/lib/apps/biz/cryptpad/cryptpad_model.v deleted file mode 100644 index abd50e3e..00000000 --- a/lib/apps/biz/cryptpad/cryptpad_model.v +++ /dev/null @@ -1,52 +0,0 @@ -module cryptpad - -import incubaid.herolib.data.paramsparser -import incubaid.herolib.data.encoderhero -import os - -pub const version = '0.0.0' -const singleton = false -const default = true - -// THIS THE THE SOURCE OF THE INFORMATION OF THIS FILE, HERE WE HAVE THE CONFIG OBJECT CONFIGURED AND MODELLED -@[heap] -pub struct CryptPad { -pub mut: - name string = 'default' - domain string - domain_sandbox string - configpath string //can be left empty, will be set to default path (is kubernetes config file) - cryptpad_configpath string //can be left empty, will be set to default path - masters []string //list of master servers for kubernetes - domainname string //can be 1 name e.g. mycryptpad or it can be a fully qualified domain name e.g. mycryptpad.mycompany.com -} - -// your checking & initialization code if needed -fn obj_init(mycfg_ CryptPad) !CryptPad { - mut mycfg := mycfg_ - if mycfg.domain == '' { - return error('CryptPad client "${mycfg.name}" missing domain') - } - if mycfg.configpath == '' { - mycfg.configpath = '${os.home_dir()}/.apps/cryptpad/${mycfg.name}/config.yaml' - } - //call kubernetes client to get master nodes and put them in - mycfg.masters = []string{} //TODO get from kubernetes - return mycfg -} - -// called before start if done -fn configure() ! { - mut installer := get()! - mut mycode := $tmpl('templates/main.yaml') - mut path := pathlib.get_file(path: cfg.configpath, create: true)! - path.write(mycode)! - console.print_debug(mycode) -} - -/////////////NORMALLY NO NEED TO TOUCH - -pub fn heroscript_loads(heroscript string) !CryptPad { - mut obj := encoderhero.decode[CryptPad](heroscript)! - return obj -} diff --git a/lib/apps/biz/cryptpad/readme.md b/lib/apps/biz/cryptpad/readme.md deleted file mode 100644 index 3e9a4862..00000000 --- a/lib/apps/biz/cryptpad/readme.md +++ /dev/null @@ -1,44 +0,0 @@ -# cryptpad - - - -To get started - -```v - - -import incubaid.herolib.installers.something.cryptpad as cryptpad_installer - -heroscript:=" -!!cryptpad.configure name:'test' - password: '1234' - port: 7701 - -!!cryptpad.start name:'test' reset:1 -" - -cryptpad_installer.play(heroscript=heroscript)! - -//or we can call the default and do a start with reset -//mut installer:= cryptpad_installer.get()! -//installer.start(reset:true)! - - - - -``` - -## example heroscript - - -```hero -!!cryptpad.configure - homedir: '/home/user/cryptpad' - username: 'admin' - password: 'secretpassword' - title: 'Some Title' - host: 'localhost' - port: 8888 - -``` - diff --git a/lib/apps/biz/cryptpad/templates/cryptpad.yaml b/lib/apps/biz/cryptpad/templates/cryptpad.yaml deleted file mode 100644 index 1160378f..00000000 --- a/lib/apps/biz/cryptpad/templates/cryptpad.yaml +++ /dev/null @@ -1,127 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: collab ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: cryptpad-config - namespace: collab -data: - config.js: | - module.exports = { - httpUnsafeOrigin: 'https://cryptpadp2.gent01.grid.tf', - httpSafeOrigin: 'https://cryptpads2.gent01.grid.tf', - httpAddress: '0.0.0.0', - httpPort: 3000, - - websocketPort: 3003, - websocketPath: '/cryptpad_websocket', - - blockPath: './block', - blobPath: './blob', - dataPath: './data', - filePath: './datastore', - logToStdout: true, - logLevel: 'info', - }; ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: cryptpad - namespace: collab -spec: - replicas: 1 - selector: - matchLabels: { app: cryptpad } - template: - metadata: - labels: { app: cryptpad } - spec: - initContainers: - - name: fix-perms - image: busybox:1.36 - command: ["/bin/sh","-lc"] - args: - - > - chown -R 4001:4001 - /cryptpad/blob /cryptpad/block /cryptpad/data /cryptpad/datastore /cryptpad/customize || true - volumeMounts: - - { name: blob, mountPath: /cryptpad/blob } - - { name: block, mountPath: /cryptpad/block } - - { name: data, mountPath: /cryptpad/data } - - { name: files, mountPath: /cryptpad/datastore } - - { name: customize, mountPath: /cryptpad/customize } - containers: - - name: cryptpad - image: cryptpad/cryptpad:latest - ports: - - { name: http, containerPort: 3000 } - - { name: ws, containerPort: 3003 } - env: - - { name: CPAD_CONF, value: "/cryptpad/config/config.js" } - - { name: CPAD_MAIN_DOMAIN, value: "https://cryptpadp2.gent01.grid.tf" } - - { name: CPAD_SANDBOX_DOMAIN, value: "https://cryptpads2.gent01.grid.tf" } - - { name: CPAD_INSTALL_ONLYOFFICE, value: "no" } - readinessProbe: - httpGet: { path: /, port: 3000 } - initialDelaySeconds: 20 - periodSeconds: 10 - volumeMounts: - - { name: cfg, mountPath: /cryptpad/config/config.js, subPath: config.js, readOnly: true } - - { name: blob, mountPath: /cryptpad/blob } - - { name: block, mountPath: /cryptpad/block } - - { name: data, mountPath: /cryptpad/data } - - { name: files, mountPath: /cryptpad/datastore } - - { name: customize, mountPath: /cryptpad/customize } - volumes: - - name: cfg - configMap: - name: cryptpad-config - items: [{ key: config.js, path: config.js }] - - { name: blob, emptyDir: {} } - - { name: block, emptyDir: {} } - - { name: data, emptyDir: {} } - - { name: files, emptyDir: {} } - - { name: customize, emptyDir: {} } ---- -apiVersion: v1 -kind: Service -metadata: - name: cryptpad - namespace: collab -spec: - selector: { app: cryptpad } - ports: - - { name: http, port: 3000, targetPort: 3000 } - - { name: ws, port: 3003, targetPort: 3003 } ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: cryptpad - namespace: collab - annotations: - kubernetes.io/ingress.class: traefik -spec: - rules: - - host: cryptpadp2.gent01.grid.tf - http: - paths: - - path: / - pathType: Prefix - backend: { service: { name: cryptpad, port: { number: 3000 } } } - - path: /cryptpad_websocket - pathType: Prefix - backend: { service: { name: cryptpad, port: { number: 3003 } } } - - host: cryptpads2.gent01.grid.tf - http: - paths: - - path: / - pathType: Prefix - backend: { service: { name: cryptpad, port: { number: 3000 } } } - - path: /cryptpad_websocket - pathType: Prefix - backend: { service: { name: cryptpad, port: { number: 3003 } } } diff --git a/lib/apps/biz/cryptpad/templates/tfgw-cryptpad.yaml b/lib/apps/biz/cryptpad/templates/tfgw-cryptpad.yaml deleted file mode 100644 index 9ed46f6f..00000000 --- a/lib/apps/biz/cryptpad/templates/tfgw-cryptpad.yaml +++ /dev/null @@ -1,28 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: collab ---- -apiVersion: ingress.grid.tf/v1 -kind: TFGW -metadata: - name: cryptpad-main - namespace: collab -spec: - hostname: "cryptpadp2" - backends: - - "http://[49a:bfc3:59bf:872c:ff0f:d25f:751c:5106]:80" - - "http://[53c:609e:6488:9ddd:ff0f:2ffc:ac2a:94a6]:80" - - "http://[58e:ca1d:ce3d:355c:ff0f:a065:a40a:a08c]:80" ---- -apiVersion: ingress.grid.tf/v1 -kind: TFGW -metadata: - name: cryptpad-sandbox - namespace: collab -spec: - hostname: "cryptpads2" - backends: - - "http://[49a:bfc3:59bf:872c:ff0f:d25f:751c:5106]:80" - - "http://[53c:609e:6488:9ddd:ff0f:2ffc:ac2a:94a6]:80" - - "http://[58e:ca1d:ce3d:355c:ff0f:a065:a40a:a08c]:80" diff --git a/lib/installers/k8s/cryptpad/.heroscript b/lib/installers/k8s/cryptpad/.heroscript index 2e74ad46..5956e3a6 100644 --- a/lib/installers/k8s/cryptpad/.heroscript +++ b/lib/installers/k8s/cryptpad/.heroscript @@ -3,7 +3,7 @@ classname:'CryptpadServer' singleton:0 //there can only be 1 object in the globals, is called 'default' templates:1 //are there templates for the installer - default:1 //can we create a default when the factory is used + default:0 //can we create a default when the factory is used title:'' supported_platforms:'' //osx, ... (empty means all) reset:0 // regenerate all, dangerous !!! diff --git a/lib/installers/k8s/cryptpad/README.md b/lib/installers/k8s/cryptpad/README.md new file mode 100644 index 00000000..e90a1baa --- /dev/null +++ b/lib/installers/k8s/cryptpad/README.md @@ -0,0 +1,72 @@ +# CryptPad Kubernetes Installer + +A Kubernetes installer for CryptPad with TFGrid Gateway integration. + +## Quick Start + +```v +import incubaid.herolib.installers.k8s.cryptpad + +// Create and install CryptPad +mut installer := cryptpad.get( + name: 'mycryptpad' + create: true +)! + +installer.install()! +``` + +to change the hostname and the namespace, you can override the default values: + +```v +mut installer := cryptpad.get( + name: 'mycryptpad' + create: true +)! + +installer.hostname = 'customhostname' +installer.namespace = 'customnamespace' +installer.install()! +``` + +## Usage + +### Create an Instance + +```v +mut installer := cryptpad.get( + name: 'mycryptpad' // Unique name for this instance + create: true // Create if doesn't exist +)! +``` + +The instance name will be used as: + +- Kubernetes namespace name +- Hostname prefix (e.g., `mycryptpad.gent01.grid.tf`) + +### Install + +```v +installer.install()! +``` + +This will: + +1. Generate Kubernetes YAML files for CryptPad and TFGrid Gateway +2. Apply them to your k3s cluster +3. Wait for deployment to be ready + +### Destroy + +```v +installer.destroy()! +``` + +Removes all CryptPad resources from the cluster. + +## Requirements + +- kubectl installed and configured +- k3s cluster running +- Redis server running (for configuration storage) diff --git a/lib/installers/k8s/cryptpad/cryptpad_actions.v b/lib/installers/k8s/cryptpad/cryptpad_actions.v index 81171a26..605123b8 100644 --- a/lib/installers/k8s/cryptpad/cryptpad_actions.v +++ b/lib/installers/k8s/cryptpad/cryptpad_actions.v @@ -2,38 +2,18 @@ module cryptpad import incubaid.herolib.osal.core as osal import incubaid.herolib.ui.console -import incubaid.herolib.osal.startupmanager import incubaid.herolib.installers.ulist -import incubaid.herolib.virt.kubernetes -import os -import strings import time const max_deployment_retries = 30 const deployment_check_interval_seconds = 2 -fn startupcmd() ![]startupmanager.ZProcessNewArgs { - // We don't have a long-running process to manage with startupmanager for this installer, - // but we'll keep the function for consistency. - return []startupmanager.ZProcessNewArgs{} -} +//////////////////// following actions are not specific to instance of the object -fn kubectl_installed() ! { - // Check if kubectl command exists - if !osal.cmd_exists('kubectl') { - return error('kubectl is not installed. Please install it to continue.') - } - - // Check if kubectl is configured to connect to a cluster - mut k8s := kubernetes.get()! - if !k8s.test_connection()! { - return error('kubectl is not configured to connect to a Kubernetes cluster. Please check your kubeconfig.') - } -} - -fn running() !bool { +// checks if a certain version or above is installed +fn installed() !bool { installer := get()! - mut k8s := kubernetes.get()! + mut k8s := installer.kube_client // Try to get the cryptpad deployment deployments := k8s.get_deployments(installer.namespace) or { @@ -51,59 +31,18 @@ fn running() !bool { return false } -fn start_pre() ! { -} - -fn start_post() ! { -} - -fn stop_pre() ! { -} - -fn stop_post() ! { -} - -//////////////////// following actions are not specific to instance of the object - -// checks if a certain version or above is installed -fn installed() !bool { - return running() -} - // get the Upload List of the files fn ulist_get() !ulist.UList { + // optionally build a UList which is all paths which are result of building, is then used e.g. in upload return ulist.UList{} } +// uploads to S3 server if configured fn upload() ! { - // Not needed for this installer. -} - -fn get_master_node_ips() ![]string { - mut master_ips := []string{} - mut k8s := kubernetes.get()! - - // Get all nodes using the kubernetes client - nodes := k8s.get_nodes()! - - // Extract IPv6 internal IPs from all nodes (dual-stack support) - for node in nodes { - // Check all internal IPs (not just the first one) for IPv6 addresses - for ip in node.internal_ips { - if ip.len > 0 && ip.contains(':') { - master_ips << ip - } - } - } - - return master_ips -} - -struct ConfigValues { -pub mut: - hostname string - backends string - namespace string + // installers.upload( + // cmdname: 'cryptpad' + // source: '${gitpath}/target/x86_64-unknown-linux-musl/release/cryptpad' + // )! } fn install() ! { @@ -111,46 +50,14 @@ fn install() ! { // Get installer config to access namespace installer := get()! - if installer.hostname == '' { - return error('hostname is empty') - } - - // Configure kubernetes client with the correct namespace - mut k8s := kubernetes.get()! - k8s.config.namespace = installer.namespace + mut k8s := installer.kube_client + configure()! // 1. Check for dependencies. console.print_info('Checking for kubectl...') kubectl_installed()! console.print_info('kubectl is installed and configured.') - // 2. Get Kubernetes master node IPs. - console.print_info('Getting Kubernetes master node IPs...') - master_ips := get_master_node_ips()! - console.print_info('Master node IPs: ${master_ips}') - - // 3. Generate YAML files from templates. - console.print_info('Generating YAML files from templates...') - - mut backends_str_builder := strings.new_builder(100) - for ip in master_ips { - backends_str_builder.writeln(' - "http://[${ip}]:80"') - } - config_values := ConfigValues{ - hostname: installer.hostname - backends: backends_str_builder.str() - namespace: installer.namespace - } - - // Write to tfgw file - temp := $tmpl('./templates/tfgw-cryptpad.yaml') - os.write_file('/tmp/tfgw-cryptpad.yaml', temp)! - - // write to cryptpad yaml file - temp2 := $tmpl('./templates/cryptpad.yaml') - os.write_file('/tmp/cryptpad.yaml', temp2)! - console.print_info('YAML files generated successfully.') - // 4. Apply the YAML files using kubernetes client console.print_info('Applying Gateway YAML file to the cluster...') res1 := k8s.apply_yaml('/tmp/tfgw-cryptpad.yaml')! @@ -175,7 +82,7 @@ fn install() ! { console.print_info('Verifying deployment status...') mut is_running := false for i in 0 .. max_deployment_retries { - if running()! { + if installed()! { is_running = true break } @@ -199,10 +106,11 @@ pub mut: retry int = 30 } -// Function for verifying the generating of of the FQDN using tfgw crd +// Function for verifying the generating of of the FQDN using tfgw crd fn verify_tfgw_deployment(args VerifyTfgwDeployment) ! { console.print_info('Verifying TFGW deployment for ${args.tfgw_name}...') - mut k8s := kubernetes.get()! + installer := get()! + mut k8s := installer.kube_client mut is_fqdn_generated := false for i in 0 .. args.retry { @@ -240,17 +148,42 @@ fn verify_tfgw_deployment(args VerifyTfgwDeployment) ! { fn destroy() ! { console.print_header('Destroying CryptPad...') installer := get()! - mut k8s := kubernetes.get()! + mut k8s := installer.kube_client + + console.print_debug('Attempting to delete namespace: ${installer.namespace}') // Delete the namespace using kubernetes client result := k8s.delete_resource('namespace', installer.namespace, '') or { console.print_stderr('Failed to delete namespace ${installer.namespace}: ${err}') - return + return error('Failed to delete namespace ${installer.namespace}: ${err}') } + console.print_debug('Delete command completed. Exit code: ${result.exit_code}, Success: ${result.success}') + if !result.success { - console.print_stderr('Failed to delete namespace ${installer.namespace}: ${result.stderr}') + // Namespace not found is OK - it means it's already deleted + if result.stderr.contains('NotFound') { + console.print_info('Namespace ${installer.namespace} does not exist (already deleted).') + } else { + console.print_stderr('Failed to delete namespace ${installer.namespace}: ${result.stderr}') + return error('Failed to delete namespace ${installer.namespace}: ${result.stderr}') + } } else { - console.print_info('Namespace ${installer.namespace} deleted.') + console.print_info('Namespace ${installer.namespace} deleted successfully.') + } +} + +fn kubectl_installed() ! { + // Check if kubectl command exists + if !osal.cmd_exists('kubectl') { + return error('kubectl is not installed. Please install it to continue.') + } + + // Check if kubectl is configured to connect to a cluster + installer := get()! + mut k8s := installer.kube_client + + if !k8s.test_connection()! { + return error('kubectl is not configured to connect to a Kubernetes cluster. Please check your kubeconfig.') } } diff --git a/lib/installers/k8s/cryptpad/cryptpad_factory_.v b/lib/installers/k8s/cryptpad/cryptpad_factory_.v index 36f35747..e448cb1e 100644 --- a/lib/installers/k8s/cryptpad/cryptpad_factory_.v +++ b/lib/installers/k8s/cryptpad/cryptpad_factory_.v @@ -4,8 +4,6 @@ import incubaid.herolib.core.base import incubaid.herolib.core.playbook { PlayBook } import incubaid.herolib.ui.console import json -import incubaid.herolib.osal.startupmanager -import time __global ( cryptpad_global map[string]&CryptpadServer @@ -17,26 +15,25 @@ __global ( @[params] pub struct ArgsGet { pub mut: - name string = 'default' + name string = 'cryptpad' fromdb bool // will load from filesystem create bool // default will not create if not exist - hostname string - namespace string } pub fn new(args ArgsGet) !&CryptpadServer { mut obj := CryptpadServer{ name: args.name - hostname: args.hostname - namespace: args.namespace } set(obj)! return get(name: args.name)! } -pub fn get(args ArgsGet) !&CryptpadServer { +pub fn get(args_ ArgsGet) !&CryptpadServer { + mut args := args_ mut context := base.context()! - cryptpad_default = args.name + if args.name == 'cryptpad' && cryptpad_default != '' { + args.name = cryptpad_default + } if args.fromdb || args.name !in cryptpad_global { mut r := context.redis()! if r.hexists('context:cryptpad', args.name)! { @@ -55,12 +52,17 @@ pub fn get(args ArgsGet) !&CryptpadServer { return error("CryptpadServer with name '${args.name}' does not exist") } } - return get(name: args.name)! // no longer from db nor create + return get( + name: args.name + fromdb: args.fromdb + create: args.create + )! // no longer from db nor create } - return cryptpad_global[args.name] or { + result := cryptpad_global[args.name] or { print_backtrace() return error('could not get config for cryptpad with name:${args.name}') } + return result } // register the config for the future @@ -69,7 +71,8 @@ pub fn set(o CryptpadServer) ! { cryptpad_default = o2.name mut context := base.context()! mut r := context.redis()! - r.hset('context:cryptpad', o2.name, json.encode(o2))! + encoded := json.encode(o2) + r.hset('context:cryptpad', o2.name, encoded)! } // does the config exists? @@ -152,25 +155,6 @@ pub fn play(mut plbook PlayBook) ! { install()! } } - if other_action.name in ['start', 'stop', 'restart'] { - mut p := other_action.params - name := p.get('name')! - mut cryptpad_obj := get(name: name)! - console.print_debug('action object:\n${cryptpad_obj}') - if other_action.name == 'start' { - console.print_debug('install action cryptpad.${other_action.name}') - cryptpad_obj.start()! - } - - if other_action.name == 'stop' { - console.print_debug('install action cryptpad.${other_action.name}') - cryptpad_obj.stop()! - } - if other_action.name == 'restart' { - console.print_debug('install action cryptpad.${other_action.name}') - cryptpad_obj.restart()! - } - } other_action.done = true } } @@ -179,118 +163,18 @@ pub fn play(mut plbook PlayBook) ! { //////////////////////////# LIVE CYCLE MANAGEMENT FOR INSTALLERS /////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////// -fn startupmanager_get(cat startupmanager.StartupManagerType) !startupmanager.StartupManager { - // unknown - // screen - // zinit - // tmux - // systemd - match cat { - .screen { - console.print_debug("installer: cryptpad' startupmanager get screen") - return startupmanager.get(.screen)! - } - .zinit { - console.print_debug("installer: cryptpad' startupmanager get zinit") - return startupmanager.get(.zinit)! - } - .systemd { - console.print_debug("installer: cryptpad' startupmanager get systemd") - return startupmanager.get(.systemd)! - } - else { - console.print_debug("installer: cryptpad' startupmanager get auto") - return startupmanager.get(.auto)! - } - } -} - // load from disk and make sure is properly intialized pub fn (mut self CryptpadServer) reload() ! { + switch(self.name) self = obj_init(self)! } -pub fn (mut self CryptpadServer) start() ! { - if self.running()! { - return - } - - console.print_header('installer: cryptpad start') - - if !installed()! { - install()! - } - - configure()! - - start_pre()! - - for zprocess in startupcmd()! { - mut sm := startupmanager_get(zprocess.startuptype)! - - console.print_debug('installer: cryptpad starting with ${zprocess.startuptype}...') - - sm.new(zprocess)! - - sm.start(zprocess.name)! - } - - start_post()! - - for _ in 0 .. 50 { - if self.running()! { - return - } - time.sleep(100 * time.millisecond) - } - return error('cryptpad did not install properly.') -} - @[params] pub struct InstallArgs { pub mut: reset bool } -pub fn (mut self CryptpadServer) install_start(args InstallArgs) ! { - switch(self.name) - self.install(args)! - self.start()! -} - -pub fn (mut self CryptpadServer) stop() ! { - switch(self.name) - stop_pre()! - for zprocess in startupcmd()! { - mut sm := startupmanager_get(zprocess.startuptype)! - sm.stop(zprocess.name)! - } - stop_post()! -} - -pub fn (mut self CryptpadServer) restart() ! { - switch(self.name) - self.stop()! - self.start()! -} - -pub fn (mut self CryptpadServer) running() !bool { - switch(self.name) - - // walk over the generic processes, if not running return - for zprocess in startupcmd()! { - if zprocess.startuptype != .screen { - mut sm := startupmanager_get(zprocess.startuptype)! - r := sm.running(zprocess.name)! - if r == false { - return false - } - } - } - return running()! -} - - pub fn (mut self CryptpadServer) install(args InstallArgs) ! { switch(self.name) if args.reset || (!installed()!) { @@ -300,10 +184,10 @@ pub fn (mut self CryptpadServer) install(args InstallArgs) ! { pub fn (mut self CryptpadServer) destroy() ! { switch(self.name) - self.stop() or {} destroy()! } // switch instance to be used for cryptpad pub fn switch(name string) { + cryptpad_default = name } diff --git a/lib/installers/k8s/cryptpad/cryptpad_model.v b/lib/installers/k8s/cryptpad/cryptpad_model.v index 6ea8176d..e612b2c3 100644 --- a/lib/installers/k8s/cryptpad/cryptpad_model.v +++ b/lib/installers/k8s/cryptpad/cryptpad_model.v @@ -1,46 +1,103 @@ module cryptpad -import incubaid.herolib.data.encoderhero import incubaid.herolib.ui.console +import incubaid.herolib.data.encoderhero +import incubaid.herolib.virt.kubernetes +import incubaid.herolib.core.pathlib +import strings -pub const version = '1.0.0' -const singleton = true -const default = true +pub const version = '0.0.0' +const singleton = false +const default = false + +struct ConfigValues { +pub mut: + hostname string // The CryptPad hostname + backends string // The backends for the TFGW + namespace string // The namespace for the CryptPad deployment +} -// THIS THE THE SOURCE OF THE INFORMATION OF THIS FILE, HERE WE HAVE THE CONFIG OBJECT CONFIGURED AND MODELLED @[heap] pub struct CryptpadServer { pub mut: - name string = 'default' - hostname string - namespace string = 'collab' + name string = 'cryptpad' + hostname string + namespace string + cryptpad_path string = '/tmp/cryptpad.yaml' + tfgw_cryptpad_path string = '/tmp/tfgw-cryptpad.yaml' + kube_client kubernetes.KubeClient @[skip] } // your checking & initialization code if needed fn obj_init(mycfg_ CryptpadServer) !CryptpadServer { mut mycfg := mycfg_ - if mycfg.hostname == '' { - return error('hostname cannot be empty') - } + if mycfg.namespace == '' { - mycfg.namespace = 'collab' + mycfg.namespace = mycfg.name } + + if mycfg.hostname == '' { + mycfg.hostname = mycfg.name + } + + mycfg.kube_client = kubernetes.get(create: true)! + mycfg.kube_client.config.namespace = mycfg.namespace return mycfg } // called before start if done fn configure() ! { - // We will implement the configuration logic here, - // like generating the yaml files from templates. - console.print_debug('configuring cryptpad...') + mut installer := get()! + + master_ips := get_master_node_ips()! + console.print_info('Master node IPs: ${master_ips}') + + mut backends_str_builder := strings.new_builder(100) + for ip in master_ips { + backends_str_builder.writeln(' - "http://[${ip}]:80"') + } + + config_values := ConfigValues{ + hostname: installer.hostname + backends: backends_str_builder.str() + namespace: installer.namespace + } + + console.print_info('Generating YAML files from templates...') + temp := $tmpl('./templates/tfgw-cryptpad.yaml') + mut temp_path := pathlib.get_file(path: installer.tfgw_cryptpad_path, create: true)! + temp_path.write(temp)! + + temp2 := $tmpl('./templates/cryptpad.yaml') + mut temp_path2 := pathlib.get_file(path: installer.cryptpad_path, create: true)! + temp_path2.write(temp2)! + + console.print_info('YAML files generated successfully.') +} + +// Get Kubernetes master node IPs +fn get_master_node_ips() ![]string { + mut master_ips := []string{} + installer := get()! + + // Get all nodes using the kubernetes client + mut k8s := installer.kube_client + nodes := k8s.get_nodes()! + + // Extract IPv6 internal IPs from all nodes (dual-stack support) + for node in nodes { + // Check all internal IPs (not just the first one) for IPv6 addresses + for ip in node.internal_ips { + if ip.len > 0 && ip.contains(':') { + master_ips << ip + } + } + } + return master_ips } /////////////NORMALLY NO NEED TO TOUCH -pub fn heroscript_dumps(obj CryptpadServer) !string { - return encoderhero.encode[CryptpadServer](obj)! -} - pub fn heroscript_loads(heroscript string) !CryptpadServer { mut obj := encoderhero.decode[CryptpadServer](heroscript)! return obj diff --git a/lib/virt/kubernetes/kubernetes_client.v b/lib/virt/kubernetes/kubernetes_client.v index 25dc890d..1aabcd5a 100644 --- a/lib/virt/kubernetes/kubernetes_client.v +++ b/lib/virt/kubernetes/kubernetes_client.v @@ -3,6 +3,7 @@ module kubernetes import incubaid.herolib.osal.core as osal import incubaid.herolib.ui.console import json +import os @[params] pub struct KubectlExecArgs { @@ -46,55 +47,13 @@ pub fn (mut k KubeClient) kubectl_exec(args KubectlExecArgs) !KubectlResult { } cmd += ' ${args.command}' + result := os.execute(cmd) - console.print_debug('executing: ${cmd}') - - // Check if this is a command that might produce large output - is_large_output := args.command.contains('get nodes') || args.command.contains('get pods') - || args.command.contains('get deployments') || args.command.contains('get services') - - if is_large_output { - // Use exec_fast for large outputs (avoids 8KB buffer limit in osal.exec) - // exec_fast uses os.execute which doesn't have the pipe buffer limitation - result_output := osal.exec_fast( - cmd: cmd - ignore_error: true - ) or { return error('Failed to execute kubectl command: ${err}') } - - // Check if command succeeded by looking for error messages - if result_output.contains('Error from server') || result_output.contains('error:') - || result_output.contains('Unable to connect') { - return KubectlResult{ - exit_code: 1 - stdout: result_output - stderr: result_output - success: false - } - } - - return KubectlResult{ - exit_code: 0 - stdout: result_output - stderr: '' - success: result_output.len > 0 - } - } else { - // Use regular exec for normal commands (supports timeout and proper error handling) - // Note: stdout must be true to prevent process from hanging when output buffer fills - job := osal.exec( - cmd: cmd - timeout: args.timeout - retry: args.retry - raise_error: false - stdout: true - )! - - return KubectlResult{ - exit_code: job.exit_code - stdout: job.output - stderr: job.error - success: job.exit_code == 0 - } + return KubectlResult{ + exit_code: result.exit_code + stdout: result.output + stderr: result.output // os.execute combines stdout and stderr + success: result.exit_code == 0 } } @@ -401,7 +360,8 @@ pub fn (mut k KubeClient) apply_yaml(yaml_path string) !KubectlResult { // Delete resource pub fn (mut k KubeClient) delete_resource(kind string, name string, namespace string) !KubectlResult { - result := k.kubectl_exec(command: 'delete ${kind} ${name} -n ${namespace}')! + mut cmd := 'delete ${kind} ${name}' + result := k.kubectl_exec(command: cmd)! return result }