Merge branch 'development' into development_heroprompt

* development:
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  ...

# Conflicts:
#	lib/threefold/grid4/datamodel/model_slice_compute.v
#	lib/threefold/grid4/datamodel/model_slice_storage.v
This commit is contained in:
2025-08-25 06:01:35 +02:00
97 changed files with 4790 additions and 19829 deletions

6
.zed/keymap.json Normal file
View File

@@ -0,0 +1,6 @@
{
"context": "Workspace",
"bindings": {
"cmd-r": ["task::Spawn", { "task_name": "ET", "reveal_target": "center" }]
}
}

47
.zed/tasks.json Normal file
View File

@@ -0,0 +1,47 @@
[
{
"label": "ET",
"command": "for i in {1..5}; do echo \"Hello $i/5\"; sleep 1; done",
//"args": [],
// Env overrides for the command, will be appended to the terminal's environment from the settings.
"env": { "foo": "bar" },
// Current working directory to spawn the command into, defaults to current project root.
//"cwd": "/path/to/working/directory",
// Whether to use a new terminal tab or reuse the existing one to spawn the process, defaults to `false`.
"use_new_terminal": true,
// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish, defaults to `false`.
"allow_concurrent_runs": false,
// What to do with the terminal pane and tab, after the command was started:
// * `always` — always show the task's pane, and focus the corresponding tab in it (default)
// * `no_focus` — always show the task's pane, add the task's tab in it, but don't focus it
// * `never` — do not alter focus, but still add/reuse the task's tab in its pane
"reveal": "always",
// What to do with the terminal pane and tab, after the command has finished:
// * `never` — Do nothing when the command finishes (default)
// * `always` — always hide the terminal tab, hide the pane also if it was the last tab in it
// * `on_success` — hide the terminal tab on task success only, otherwise behaves similar to `always`
"hide": "never",
// Which shell to use when running a task inside the terminal.
// May take 3 values:
// 1. (default) Use the system's default terminal configuration in /etc/passwd
// "shell": "system"
// 2. A program:
// "shell": {
// "program": "sh"
// }
// 3. A program with arguments:
// "shell": {
// "with_arguments": {
// "program": "/bin/bash",
// "args": ["--login"]
// }
// }
"shell": "system",
// Whether to show the task line in the output of the spawned task, defaults to `true`.
"show_summary": true,
// Whether to show the command line in the output of the spawned task, defaults to `true`.
// "show_output": true,
// Represents the tags for inline runnable indicators, or spawning multiple tasks at once.
"tags": ["DODO"]
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,225 +0,0 @@
# tus Resumable Upload Protocol (Condensed for Coding Agents)
## Core Protocol
All Clients and Servers MUST implement the core protocol for resumable uploads.
### Resuming an Upload
1. **Determine Offset (HEAD Request):**
* **Request:**
```
HEAD /files/{upload_id} HTTP/1.1
Host: tus.example.org
Tus-Resumable: 1.0.0
```
* **Response:**
```
HTTP/1.1 200 OK
Upload-Offset: {current_offset}
Tus-Resumable: 1.0.0
```
* Server MUST include `Upload-Offset`.
* Server MUST include `Upload-Length` if known.
* Server SHOULD return `200 OK` or `204 No Content`.
* Server MUST prevent caching: `Cache-Control: no-store`.
2. **Resume Upload (PATCH Request):**
* **Request:**
```
PATCH /files/{upload_id} HTTP/1.1
Host: tus.example.org
Content-Type: application/offset+octet-stream
Content-Length: {chunk_size}
Upload-Offset: {current_offset}
Tus-Resumable: 1.0.0
[binary data chunk]
```
* **Response:**
```
HTTP/1.1 204 No Content
Tus-Resumable: 1.0.0
Upload-Offset: {new_offset}
```
* `Content-Type` MUST be `application/offset+octet-stream`.
* `Upload-Offset` in request MUST match server's current offset (else `409 Conflict`).
* Server MUST acknowledge with `204 No Content` and `Upload-Offset` (new offset).
* Server SHOULD return `404 Not Found` for non-existent resources.
### Common Headers
* **`Upload-Offset`**: Non-negative integer. Byte offset within resource.
* **`Upload-Length`**: Non-negative integer. Total size of upload in bytes.
* **`Tus-Version`**: Comma-separated list of supported protocol versions (Server response).
* **`Tus-Resumable`**: Protocol version used (e.g., `1.0.0`). MUST be in every request/response (except `OPTIONS`). If client version unsupported, server responds `412 Precondition Failed` with `Tus-Version`.
* **`Tus-Extension`**: Comma-separated list of supported extensions (Server response). Omitted if none.
* **`Tus-Max-Size`**: Non-negative integer. Max allowed upload size in bytes (Server response).
* **`X-HTTP-Method-Override`**: String. Client MAY use to override HTTP method (e.g., for `PATCH`/`DELETE` limitations).
### Server Configuration (OPTIONS Request)
* **Request:**
```
OPTIONS /files HTTP/1.1
Host: tus.example.org
```
* **Response:**
```
HTTP/1.1 204 No Content
Tus-Resumable: 1.0.0
Tus-Version: 1.0.0,0.2.2,0.2.1
Tus-Max-Size: 1073741824
Tus-Extension: creation,expiration
```
* Response MUST contain `Tus-Version`. MAY include `Tus-Extension` and `Tus-Max-Size`.
* Client SHOULD NOT include `Tus-Resumable` in request.
## Protocol Extensions
Clients SHOULD use `OPTIONS` request and `Tus-Extension` header for feature detection.
### Creation (`creation` extension)
Create a new upload resource. Server MUST add `creation` to `Tus-Extension`.
* **Request (POST):**
```
POST /files HTTP/1.1
Host: tus.example.org
Content-Length: 0
Upload-Length: {total_size} OR Upload-Defer-Length: 1
Tus-Resumable: 1.0.0
Upload-Metadata: filename {base64_filename},is_confidential
```
* MUST include `Upload-Length` or `Upload-Defer-Length: 1`.
* If `Upload-Defer-Length: 1`, client MUST set `Upload-Length` in subsequent `PATCH`.
* `Upload-Length: 0` creates an immediately complete empty file.
* Client MAY supply `Upload-Metadata` (key-value pairs, value Base64 encoded).
* If `Upload-Length` exceeds `Tus-Max-Size`, server responds `413 Request Entity Too Large`.
* **Response:**
```
HTTP/1.1 201 Created
Location: {upload_url}
Tus-Resumable: 1.0.0
```
* Server MUST respond `201 Created` and set `Location` header to new resource URL.
* New resource has implicit offset `0`.
#### Headers
* **`Upload-Defer-Length`**: `1`. Indicates upload size is unknown. Server adds `creation-defer-length` to `Tus-Extension` if supported.
* **`Upload-Metadata`**: Comma-separated `key value` pairs. Key: no spaces/commas, ASCII. Value: Base64 encoded.
### Creation With Upload (`creation-with-upload` extension)
Include initial upload data in the `POST` request. Server MUST add `creation-with-upload` to `Tus-Extension`. Depends on `creation` extension.
* **Request (POST):**
```
POST /files HTTP/1.1
Host: tus.example.org
Content-Length: {initial_chunk_size}
Upload-Length: {total_size}
Tus-Resumable: 1.0.0
Content-Type: application/offset+octet-stream
Expect: 100-continue
[initial binary data chunk]
```
* Similar rules as `PATCH` apply for content.
* Client SHOULD include `Expect: 100-continue`.
* **Response:**
```
HTTP/1.1 201 Created
Location: {upload_url}
Tus-Resumable: 1.0.0
Upload-Offset: {accepted_offset}
```
* Server MUST include `Upload-Offset` with accepted bytes.
### Expiration (`expiration` extension)
Server MAY remove unfinished uploads. Server MUST add `expiration` to `Tus-Extension`.
* **Response (PATCH/POST):**
```
HTTP/1.1 204 No Content
Upload-Expires: Wed, 25 Jun 2014 16:00:00 GMT
Tus-Resumable: 1.0.0
Upload-Offset: {new_offset}
```
* **`Upload-Expires`**: Datetime in RFC 9110 format. Indicates when upload expires. Client SHOULD use to check validity. Server SHOULD respond `404 Not Found` or `410 Gone` for expired uploads.
### Checksum (`checksum` extension)
Verify data integrity of `PATCH` requests. Server MUST add `checksum` to `Tus-Extension`. Server MUST support `sha1`.
* **Request (PATCH):**
```
PATCH /files/{upload_id} HTTP/1.1
Content-Length: {chunk_size}
Upload-Offset: {current_offset}
Tus-Resumable: 1.0.0
Upload-Checksum: {algorithm} {base64_checksum}
[binary data chunk]
```
* **Response:**
* `204 No Content`: Checksums match.
* `400 Bad Request`: Algorithm not supported.
* `460 Checksum Mismatch`: Checksums mismatch.
* In `400`/`460` cases, chunk MUST be discarded, upload/offset NOT updated.
* **`Tus-Checksum-Algorithm`**: Comma-separated list of supported algorithms (Server response to `OPTIONS`).
* **`Upload-Checksum`**: `{algorithm} {Base64_encoded_checksum}`.
### Termination (`termination` extension)
Client can terminate uploads. Server MUST add `termination` to `Tus-Extension`.
* **Request (DELETE):**
```
DELETE /files/{upload_id} HTTP/1.1
Host: tus.example.org
Content-Length: 0
Tus-Resumable: 1.0.0
```
* **Response:**
```
HTTP/1.1 204 No Content
Tus-Resumable: 1.0.0
```
* Server SHOULD free resources, MUST respond `204 No Content`.
* Future requests to URL SHOULD return `404 Not Found` or `410 Gone`.
### Concatenation (`concatenation` extension)
Concatenate multiple partial uploads into a single final upload. Server MUST add `concatenation` to `Tus-Extension`.
* **Partial Upload Creation (POST):**
```
POST /files HTTP/1.1
Upload-Concat: partial
Upload-Length: {partial_size}
Tus-Resumable: 1.0.0
```
* `Upload-Concat: partial` header.
* Server SHOULD NOT process partial uploads until concatenated.
* **Final Upload Creation (POST):**
```
POST /files HTTP/1.1
Upload-Concat: final;{url_partial1} {url_partial2} ...
Tus-Resumable: 1.0.0
```
* `Upload-Concat: final;{space-separated_partial_urls}`.
* Client MUST NOT include `Upload-Length`.
* Final upload length is sum of partials.
* Server MAY delete partials after concatenation.
* Server MUST respond `403 Forbidden` to `PATCH` requests against final upload.
* **`concatenation-unfinished`**: Server adds to `Tus-Extension` if it supports concatenation while partial uploads are in progress.
* **HEAD Request for Final Upload:**
* Response SHOULD NOT contain `Upload-Offset` unless concatenation finished.
* After success, `Upload-Offset` and `Upload-Length` MUST be equal.
* Response MUST include `Upload-Concat` header.
* **HEAD Request for Partial Upload:**
* Response MUST contain `Upload-Offset`.

View File

@@ -1,667 +0,0 @@
# TUS (1.0.0) — Server-Side Specs (Concise)
## Always
* All requests/responses **except** `OPTIONS` MUST include: `Tus-Resumable: 1.0.0`.
If unsupported → `412 Precondition Failed` + `Tus-Version`.
* Canonical server features via `OPTIONS /files`:
* `Tus-Version: 1.0.0`
* `Tus-Extension: creation,creation-with-upload,termination,checksum,concatenation,concatenation-unfinished` (as supported)
* `Tus-Max-Size: <int>` (if hard limit)
* `Tus-Checksum-Algorithm: sha1[,md5,crc32...]` (if checksum ext.)
## Core
* **Create:** `POST /files` with `Upload-Length: <int>` OR `Upload-Defer-Length: 1`. Optional `Upload-Metadata`.
* `201 Created` + `Location: /files/{id}`, echo `Tus-Resumable`.
* *Creation-With-Upload:* If body present → `Content-Type: application/offset+octet-stream`, accept bytes, respond with `Upload-Offset`.
* **Status:** `HEAD /files/{id}`
* Always return `Upload-Offset` for partial uploads, include `Upload-Length` if known; if deferred, return `Upload-Defer-Length: 1`. `Cache-Control: no-store`.
* **Upload:** `PATCH /files/{id}`
* `Content-Type: application/offset+octet-stream` and `Upload-Offset` (must match server).
* On success → `204 No Content` + new `Upload-Offset`.
* Mismatch → `409 Conflict`. Bad type → `415 Unsupported Media Type`.
* **Terminate:** `DELETE /files/{id}` (if supported) → `204 No Content`. Subsequent requests → `404/410`.
## Checksum (optional but implemented here)
* Client MAY send: `Upload-Checksum: <algo> <base64digest>` per `PATCH`.
* Server MUST verify request bodys checksum of the exact received bytes.
* If algo unsupported → `400 Bad Request`.
* If mismatch → **discard the chunk** (no offset change) and respond `460 Checksum Mismatch`.
* If OK → `204 No Content` + new `Upload-Offset`.
* `OPTIONS` MUST include `Tus-Checksum-Algorithm` (comma-separated algos).
## Concatenation (optional but implemented here)
* **Partial uploads:** `POST /files` with `Upload-Concat: partial` and `Upload-Length`. (MUST have length; may use creation-with-upload/patch thereafter.)
* **Final upload:** `POST /files` with
`Upload-Concat: final; /files/{a} /files/{b} ...`
* MUST NOT include `Upload-Length`.
* Final uploads **cannot** be `PATCH`ed (`403`).
* Server SHOULD assemble final (in order).
* If `concatenation-unfinished` supported, final may be created before partials completed; server completes once all partials are done.
* **HEAD semantics:**
* For *partial*: MUST include `Upload-Offset`.
* For *final* before concatenation: SHOULD NOT include `Upload-Offset`. `Upload-Length` MAY be present if computable (= sum of partials lengths when known).
* After finalization: `Upload-Offset == Upload-Length`.
---
# TUS FastAPI Server (disk-only, crash-safe, checksum + concatenation)
**Features**
* All persistent state on disk:
```
TUS_ROOT/
{upload_id}/
info.json # canonical metadata & status
data.part # exists while uploading or while building final
data # final file after atomic rename
```
* Crash recovery: `HEAD` offset = size of `data.part` or `data`.
* `.part` during upload; `os.replace()` (atomic) to `data` on completion.
* Streaming I/O; `fsync` on file + parent directory.
* Checksum: supports `sha1` (can easily add md5/crc32).
* Concatenation: server builds final when partials complete; supports `concatenation-unfinished`.
> Run with: `uv pip install fastapi uvicorn` then `uvicorn tus_server:app --host 0.0.0.0 --port 8080` (or `python tus_server.py`).
> Set `TUS_ROOT` env to choose storage root.
```python
# tus_server.py
from fastapi import FastAPI, Request, Response, HTTPException
from typing import Optional, Dict, Any, List
import os, json, uuid, base64, asyncio, errno, hashlib
# -----------------------------
# Config
# -----------------------------
TUS_VERSION = "1.0.0"
# Advertise extensions implemented below:
TUS_EXTENSIONS = ",".join([
"creation",
"creation-with-upload",
"termination",
"checksum",
"concatenation",
"concatenation-unfinished",
])
# Supported checksum algorithms (keys = header token)
CHECKSUM_ALGOS = ["sha1"] # add "md5" if desired
TUS_ROOT = os.environ.get("TUS_ROOT", "/tmp/tus")
MAX_SIZE = 1 << 40 # 1 TiB default
os.makedirs(TUS_ROOT, exist_ok=True)
app = FastAPI()
# Per-process locks to prevent concurrent mutations on same upload_id
_locks: Dict[str, asyncio.Lock] = {}
def _lock_for(upload_id: str) -> asyncio.Lock:
if upload_id not in _locks:
_locks[upload_id] = asyncio.Lock()
return _locks[upload_id]
# -----------------------------
# Path helpers
# -----------------------------
def upload_dir(upload_id: str) -> str:
return os.path.join(TUS_ROOT, upload_id)
def info_path(upload_id: str) -> str:
return os.path.join(upload_dir(upload_id), "info.json")
def part_path(upload_id: str) -> str:
return os.path.join(upload_dir(upload_id), "data.part")
def final_path(upload_id: str) -> str:
return os.path.join(upload_dir(upload_id), "data")
# -----------------------------
# FS utils (crash-safe)
# -----------------------------
def _fsync_dir(path: str) -> None:
fd = os.open(path, os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
def _write_json_atomic(path: str, obj: Dict[str, Any]) -> None:
tmp = f"{path}.tmp"
data = json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
with open(tmp, "w", encoding="utf-8") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
_fsync_dir(os.path.dirname(path))
def _read_json(path: str) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _size(path: str) -> int:
try:
return os.path.getsize(path)
except FileNotFoundError:
return 0
def _exists(path: str) -> bool:
return os.path.exists(path)
# -----------------------------
# TUS helpers
# -----------------------------
def _ensure_tus_version(req: Request):
if req.method == "OPTIONS":
return
v = req.headers.get("Tus-Resumable")
if v is None:
raise HTTPException(status_code=412, detail="Missing Tus-Resumable")
if v != TUS_VERSION:
raise HTTPException(status_code=412, detail="Unsupported Tus-Resumable",
headers={"Tus-Version": TUS_VERSION})
def _parse_metadata(raw: Optional[str]) -> str:
# Raw passthrough; validate/consume in your app if needed.
return raw or ""
def _new_upload_info(upload_id: str,
kind: str, # "single" | "partial" | "final"
length: Optional[int],
defer_length: bool,
metadata: str,
parts: Optional[List[str]] = None) -> Dict[str, Any]:
return {
"upload_id": upload_id,
"kind": kind, # "single" (default), "partial", or "final"
"length": length, # int or None if deferred/unknown
"defer_length": bool(defer_length),
"metadata": metadata, # raw Upload-Metadata header
"completed": False,
"parts": parts or [], # for final: list of upload_ids (not URLs)
}
def _load_info_or_404(upload_id: str) -> Dict[str, Any]:
p = info_path(upload_id)
if not _exists(p):
raise HTTPException(404, "Upload not found")
try:
return _read_json(p)
except Exception as e:
raise HTTPException(500, f"Corrupt metadata: {e}")
def _set_info(upload_id: str, info: Dict[str, Any]) -> None:
_write_json_atomic(info_path(upload_id), info)
def _ensure_dir(path: str):
os.makedirs(path, exist_ok=False)
def _atomic_finalize_file(upload_id: str):
"""Rename data.part → data and mark completed."""
upath = upload_dir(upload_id)
p = part_path(upload_id)
f = final_path(upload_id)
if _exists(p):
with open(p, "rb+") as fp:
fp.flush()
os.fsync(fp.fileno())
os.replace(p, f)
_fsync_dir(upath)
info = _load_info_or_404(upload_id)
info["completed"] = True
_set_info(upload_id, info)
def _current_offsets(upload_id: str):
f, p = final_path(upload_id), part_path(upload_id)
if _exists(f):
return True, False, _size(f)
if _exists(p):
return False, True, _size(p)
return False, False, 0
def _parse_concat_header(h: Optional[str]) -> Optional[Dict[str, Any]]:
if not h:
return None
h = h.strip()
if h == "partial":
return {"type": "partial", "parts": []}
if h.startswith("final;"):
# format: final;/files/a /files/b
rest = h[len("final;"):].strip()
urls = [s for s in rest.split(" ") if s]
return {"type": "final", "parts": urls}
return None
def _extract_upload_id_from_url(url: str) -> str:
# Accept relative /files/{id} (common) — robust split:
segs = [s for s in url.split("/") if s]
return segs[-1] if segs else url
def _sum_lengths_or_none(ids: List[str]) -> Optional[int]:
total = 0
for pid in ids:
info = _load_info_or_404(pid)
if info.get("length") is None:
return None
total += int(info["length"])
return total
async def _stream_with_checksum_and_append(file_obj, request: Request, algo: Optional[str]) -> int:
"""Stream request body to file, verifying checksum if header present.
Returns bytes written. On checksum mismatch, truncate to original size and raise HTTPException(460)."""
start_pos = file_obj.tell()
# Choose hash
hasher = None
provided_digest = None
if algo:
if algo not in CHECKSUM_ALGOS:
raise HTTPException(400, "Unsupported checksum algorithm")
if algo == "sha1":
hasher = hashlib.sha1()
# elif algo == "md5": hasher = hashlib.md5()
# elif algo == "crc32": ... (custom)
# Read expected checksum
if hasher:
uh = request.headers.get("Upload-Checksum")
if not uh:
# spec: checksum header optional; if algo passed to this fn we must have parsed it already
pass
else:
try:
name, b64 = uh.split(" ", 1)
if name != algo:
raise ValueError()
provided_digest = base64.b64decode(b64.encode("ascii"))
except Exception:
raise HTTPException(400, "Invalid Upload-Checksum")
written = 0
async for chunk in request.stream():
if not chunk:
continue
file_obj.write(chunk)
if hasher:
hasher.update(chunk)
written += len(chunk)
# Verify checksum if present
if hasher and provided_digest is not None:
digest = hasher.digest()
if digest != provided_digest:
# rollback appended bytes
file_obj.truncate(start_pos)
file_obj.flush()
os.fsync(file_obj.fileno())
raise HTTPException(status_code=460, detail="Checksum Mismatch")
file_obj.flush()
os.fsync(file_obj.fileno())
return written
def _try_finalize_final(upload_id: str):
"""If this is a final upload and all partials are completed, build final data and finalize atomically."""
info = _load_info_or_404(upload_id)
if info.get("kind") != "final" or info.get("completed"):
return
part_ids = info.get("parts", [])
# Check all partials completed and have data
for pid in part_ids:
pinf = _load_info_or_404(pid)
if not pinf.get("completed"):
return # still not ready
if not _exists(final_path(pid)):
# tolerate leftover .part (e.g., if completed used .part->data). If data missing, can't finalize.
return
# Build final .part by concatenating parts' data in order, then atomically rename
up = upload_dir(upload_id)
os.makedirs(up, exist_ok=True)
ppath = part_path(upload_id)
# Reset/overwrite .part
with open(ppath, "wb") as out:
for pid in part_ids:
with open(final_path(pid), "rb") as src:
for chunk in iter(lambda: src.read(1024 * 1024), b""):
out.write(chunk)
out.flush()
os.fsync(out.fileno())
# If server can compute length now, set it
length = _sum_lengths_or_none(part_ids)
info["length"] = length if length is not None else info.get("length")
_set_info(upload_id, info)
_atomic_finalize_file(upload_id)
# -----------------------------
# Routes
# -----------------------------
@app.options("/files")
async def tus_options():
headers = {
"Tus-Version": TUS_VERSION,
"Tus-Extension": TUS_EXTENSIONS,
"Tus-Max-Size": str(MAX_SIZE),
"Tus-Checksum-Algorithm": ",".join(CHECKSUM_ALGOS),
}
return Response(status_code=204, headers=headers)
@app.post("/files")
async def tus_create(request: Request):
_ensure_tus_version(request)
metadata = _parse_metadata(request.headers.get("Upload-Metadata"))
concat = _parse_concat_header(request.headers.get("Upload-Concat"))
# Validate creation modes
hdr_len = request.headers.get("Upload-Length")
hdr_defer = request.headers.get("Upload-Defer-Length")
if concat and concat["type"] == "partial":
# Partial MUST have Upload-Length (spec)
if hdr_len is None:
raise HTTPException(400, "Partial uploads require Upload-Length")
if hdr_defer is not None:
raise HTTPException(400, "Partial uploads cannot defer length")
elif concat and concat["type"] == "final":
# Final MUST NOT include Upload-Length
if hdr_len is not None or hdr_defer is not None:
raise HTTPException(400, "Final uploads must not include Upload-Length or Upload-Defer-Length")
else:
# Normal single upload: require length or defer
if hdr_len is None and hdr_defer != "1":
raise HTTPException(400, "Must provide Upload-Length or Upload-Defer-Length: 1")
# Parse length
length: Optional[int] = None
defer = False
if hdr_len is not None:
try:
length = int(hdr_len)
if length < 0: raise ValueError()
except ValueError:
raise HTTPException(400, "Invalid Upload-Length")
if length > MAX_SIZE:
raise HTTPException(413, "Upload too large")
elif not concat or concat["type"] != "final":
# final has no length at creation
defer = (hdr_defer == "1")
upload_id = str(uuid.uuid4())
udir = upload_dir(upload_id)
_ensure_dir(udir)
if concat and concat["type"] == "final":
# Resolve part ids from URLs
part_ids = [_extract_upload_id_from_url(u) for u in concat["parts"]]
# Compute length if possible
sum_len = _sum_lengths_or_none(part_ids)
info = _new_upload_info(upload_id, "final", sum_len, False, metadata, part_ids)
_set_info(upload_id, info)
# Prepare empty .part (will be filled when partials complete)
with open(part_path(upload_id), "wb") as f:
f.flush(); os.fsync(f.fileno())
_fsync_dir(udir)
# If all partials already complete, finalize immediately
_try_finalize_final(upload_id)
return Response(status_code=201,
headers={"Location": f"/files/{upload_id}",
"Tus-Resumable": TUS_VERSION})
# Create partial or single
kind = "partial" if (concat and concat["type"] == "partial") else "single"
info = _new_upload_info(upload_id, kind, length, defer, metadata)
_set_info(upload_id, info)
# Create empty .part
with open(part_path(upload_id), "wb") as f:
f.flush(); os.fsync(f.fileno())
_fsync_dir(udir)
# Creation-With-Upload (optional body)
upload_offset = 0
has_body = request.headers.get("Content-Length") or request.headers.get("Transfer-Encoding")
if has_body:
ctype = request.headers.get("Content-Type", "")
if ctype != "application/offset+octet-stream":
raise HTTPException(415, "Content-Type must be application/offset+octet-stream for creation-with-upload")
# Checksum header optional; if present, parse algo token
uh = request.headers.get("Upload-Checksum")
algo = None
if uh:
try:
algo = uh.split(" ", 1)[0]
except Exception:
raise HTTPException(400, "Invalid Upload-Checksum")
async with _lock_for(upload_id):
with open(part_path(upload_id), "ab+") as f:
f.seek(0, os.SEEK_END)
upload_offset = await _stream_with_checksum_and_append(f, request, algo)
# If length known and we hit it, finalize
inf = _load_info_or_404(upload_id)
if inf["length"] is not None and upload_offset == int(inf["length"]):
_atomic_finalize_file(upload_id)
# If this is a partial that belongs to some final, a watcher could finalize final; here we rely on
# client to create final explicitly (spec). Finalization of final is handled by _try_finalize_final
# when final resource is created (or rechecked on subsequent HEAD/PATCH).
headers = {"Location": f"/files/{upload_id}", "Tus-Resumable": TUS_VERSION}
if upload_offset:
headers["Upload-Offset"] = str(upload_offset)
return Response(status_code=201, headers=headers)
@app.head("/files/{upload_id}")
async def tus_head(upload_id: str, request: Request):
_ensure_tus_version(request)
info = _load_info_or_404(upload_id)
is_final = info.get("kind") == "final"
headers = {
"Tus-Resumable": TUS_VERSION,
"Cache-Control": "no-store",
}
if info.get("metadata"):
headers["Upload-Metadata"] = info["metadata"]
if info.get("length") is not None:
headers["Upload-Length"] = str(int(info["length"]))
elif info.get("defer_length"):
headers["Upload-Defer-Length"] = "1"
exists_final, exists_part, offset = False, False, 0
if is_final and not info.get("completed"):
# BEFORE concatenation completes: SHOULD NOT include Upload-Offset
# Try to see if we can finalize now (e.g., partials completed after crash)
_try_finalize_final(upload_id)
info = _load_info_or_404(upload_id)
if info.get("completed"):
# fallthrough to completed case
pass
else:
# For in-progress final, no Upload-Offset; include Upload-Length if computable (already handled above)
return Response(status_code=200, headers=headers)
# For partials or completed finals
f = final_path(upload_id)
p = part_path(upload_id)
if _exists(f):
exists_final, offset = True, _size(f)
elif _exists(p):
exists_part, offset = True, _size(p)
else:
# if info exists but no data, consider gone
raise HTTPException(410, "Upload gone")
headers["Upload-Offset"] = str(offset)
return Response(status_code=200, headers=headers)
@app.patch("/files/{upload_id}")
async def tus_patch(upload_id: str, request: Request):
_ensure_tus_version(request)
info = _load_info_or_404(upload_id)
if info.get("kind") == "final":
raise HTTPException(403, "Final uploads cannot be patched")
ctype = request.headers.get("Content-Type", "")
if ctype != "application/offset+octet-stream":
raise HTTPException(415, "Content-Type must be application/offset+octet-stream")
# Client offset must match server
try:
client_offset = int(request.headers.get("Upload-Offset", "-1"))
if client_offset < 0: raise ValueError()
except ValueError:
raise HTTPException(400, "Invalid or missing Upload-Offset")
# If length deferred, client may now set Upload-Length (once)
if info.get("length") is None and info.get("defer_length"):
if "Upload-Length" in request.headers:
try:
new_len = int(request.headers["Upload-Length"])
if new_len < 0:
raise ValueError()
except ValueError:
raise HTTPException(400, "Invalid Upload-Length")
if new_len > MAX_SIZE:
raise HTTPException(413, "Upload too large")
info["length"] = new_len
info["defer_length"] = False
_set_info(upload_id, info)
# Determine current server offset
f = final_path(upload_id)
p = part_path(upload_id)
if _exists(f):
raise HTTPException(403, "Upload already finalized")
if not _exists(p):
raise HTTPException(404, "Upload not found")
server_offset = _size(p)
if client_offset != server_offset:
return Response(status_code=409)
# Optional checksum
uh = request.headers.get("Upload-Checksum")
algo = None
if uh:
try:
algo = uh.split(" ", 1)[0]
except Exception:
raise HTTPException(400, "Invalid Upload-Checksum")
# Append data (with rollback on checksum mismatch)
async with _lock_for(upload_id):
with open(p, "ab+") as fobj:
fobj.seek(0, os.SEEK_END)
written = await _stream_with_checksum_and_append(fobj, request, algo)
new_offset = server_offset + written
# If length known and reached exactly, finalize
info = _load_info_or_404(upload_id) # reload
if info.get("length") is not None and new_offset == int(info["length"]):
_atomic_finalize_file(upload_id)
# If this is a partial, a corresponding final may exist and be now completable
# We don't maintain reverse index; finalization is triggered when HEAD on final is called.
# (Optional: scan for finals to proactively finalize.)
return Response(status_code=204, headers={"Tus-Resumable": TUS_VERSION, "Upload-Offset": str(new_offset)})
@app.delete("/files/{upload_id}")
async def tus_delete(upload_id: str, request: Request):
_ensure_tus_version(request)
async with _lock_for(upload_id):
udir = upload_dir(upload_id)
for p in (part_path(upload_id), final_path(upload_id), info_path(upload_id)):
try:
os.remove(p)
except FileNotFoundError:
pass
try:
os.rmdir(udir)
except OSError:
pass
return Response(status_code=204, headers={"Tus-Resumable": TUS_VERSION})
```
---
## Quick Client Examples (manual)
```bash
# OPTIONS
curl -i -X OPTIONS http://localhost:8080/files
# 1) Single upload (known length)
curl -i -X POST http://localhost:8080/files \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Length: 11" \
-H "Upload-Metadata: filename Zm9vLnR4dA=="
# → Location: /files/<ID>
# Upload with checksum (sha1 of "hello ")
printf "hello " | curl -i -X PATCH http://localhost:8080/files/<ID> \
-H "Tus-Resumable: 1.0.0" \
-H "Content-Type: application/offset+octet-stream" \
-H "Upload-Offset: 0" \
-H "Upload-Checksum: sha1 L6v8xR3Lw4N2n9kQox3wL7G0m/I=" \
--data-binary @-
# (Replace digest with correct base64 for your chunk)
# 2) Concatenation
# Create partial A (5 bytes)
curl -i -X POST http://localhost:8080/files \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Length: 5" \
-H "Upload-Concat: partial"
# → Location: /files/<A>
printf "hello" | curl -i -X PATCH http://localhost:8080/files/<A> \
-H "Tus-Resumable: 1.0.0" \
-H "Content-Type: application/offset+octet-stream" \
-H "Upload-Offset: 0" \
--data-binary @-
# Create partial B (6 bytes)
curl -i -X POST http://localhost:8080/files \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Length: 6" \
-H "Upload-Concat: partial"
# → Location: /files/<B>
printf " world" | curl -i -X PATCH http://localhost:8080/files/<B> \
-H "Tus-Resumable: 1.0.0" \
-H "Content-Type: application/offset+octet-stream" \
-H "Upload-Offset: 0" \
--data-binary @-
# Create final (may be before or after partials complete)
curl -i -X POST http://localhost:8080/files \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Concat: final; /files/<A> /files/<B>"
# HEAD on final will eventually show Upload-Offset once finalized
curl -i -X HEAD http://localhost:8080/files/<FINAL> -H "Tus-Resumable: 1.0.0"
```
---
## Implementation Notes (agent hints)
* **Durability:** every data write `fsync(file)`; after `os.replace` of `*.part → data` or `info.json.tmp → info.json`, also `fsync(parent)`.
* **Checksum:** verify against **this requests** body only; on mismatch, **truncate back** to previous size and return `460`.
* **Concatenation:** final upload is never `PATCH`ed. Server builds `final.data.part` by concatenating each partials **final file** in order, then atomically renames and marks completed. Its triggered lazily in `HEAD` of final (and right after creation).
* **Crash Recovery:** offset = `size(data.part)` or `size(data)`; `info.json` is canonical for `kind`, `length`, `defer_length`, `completed`, `parts`.
* **Multi-process deployments:** replace `asyncio.Lock` with file locks (`fcntl.flock`) per `upload_id` to synchronize across workers.

View File

@@ -1,229 +0,0 @@
```bash
unpm install @uppy/react
```
## Components
Pre-composed, plug-and-play components:
<Dashboard /> renders @uppy/dashboard
<DashboardModal /> renders @uppy/dashboard as a modal
<DragDrop /> renders @uppy/drag-drop
<ProgressBar /> renders @uppy/progress-bar
<StatusBar /> renders @uppy/status-bar
more info see https://uppy.io/docs/react
we use tus server for the upload support
npm install @uppy/tus
e.g.
import Uppy from '@uppy/core';
import Dashboard from '@uppy/dashboard';
import Tus from '@uppy/tus';
import '@uppy/core/dist/style.min.css';
import '@uppy/dashboard/dist/style.min.css';
new Uppy()
.use(Dashboard, { inline: true, target: 'body' })
========================
CODE SNIPPETS
========================
TITLE: React Dashboard Modal Example with TUS
DESCRIPTION: Demonstrates how to use the DashboardModal component from @uppy/react with the Tus plugin for resumable uploads.
LANGUAGE: jsx
CODE:
```
/** @jsx React */
import React from 'react'
import Uppy from '@uppy/core'
import { DashboardModal } from '@uppy/react'
import Tus from '@uppy/tus'
const uppy = new Uppy({ debug: true, autoProceed: false })
.use(Tus, { endpoint: 'https://tusd.tusdemo.net/files/' })
class Example extends React.Component {
state = { open: false }
render() {
const { open } = this.state
return (
<DashboardModal
uppy={uppy}
open={open}
onRequestClose={this.handleClose}
/>
)
}
// ..snip..
}
```
----------------------------------------
TITLE: Installation using npm for @uppy/react
DESCRIPTION: Provides the command to install the @uppy/react package using npm.
LANGUAGE: bash
CODE:
```
$ npm install @uppy/react @uppy/core @uppy/dashboard @uppy/tus
```
----------------------------------------
TITLE: Uppy Dashboard and Tus Integration Example (HTML & JavaScript)
DESCRIPTION: This snippet demonstrates how to initialize Uppy with the Dashboard and Tus plugins, configure them, and handle upload success events.
LANGUAGE: html
CODE:
```
<html>
<head>
<link rel="stylesheet" href="https://releases.transloadit.com/uppy/v4.18.0/uppy.min.css" />
</head>
<body>
<div class="DashboardContainer"></div>
<button class="UppyModalOpenerBtn">Upload</button>
<div class="uploaded-files">
<h5>Uploaded files:</h5>
<ol></ol>
</div>
</body>
<script type="module">
import { Uppy, Dashboard, Tus } from 'https://releases.transloadit.com/uppy/v4.18.0/uppy.min.mjs'
var uppy = new Uppy({
debug: true,
autoProceed: false,
})
.use(Dashboard, {
browserBackButtonClose: false,
height: 470,
inline: false,
replaceTargetContent: true,
showProgressDetails: true,
target: '.DashboardContainer',
trigger: '.UppyModalOpenerBtn',
})
.use(Tus, { endpoint: 'https://tusd.tusdemo.net/files/' })
.on('upload-success', function (file, response) {
var url = response.uploadURL
var fileName = file.name
document.querySelector('.uploaded-files ol').innerHTML +=
'<li><a href="' + url + '" target="_blank">' + fileName + '</a></li>'
})
</script>
</html>
```
----------------------------------------
TITLE: Initialize Uppy with Tus Plugin (JavaScript)
DESCRIPTION: Demonstrates how to initialize Uppy and configure the Tus plugin for resumable uploads.
LANGUAGE: js
CODE:
```
import Uppy from '@uppy/core'
import Tus from '@uppy/tus'
const uppy = new Uppy()
uppy.use(Tus, {
endpoint: 'https://tusd.tusdemo.net/files/', // use your tus endpoint here
resume: true,
retryDelays: [0, 1000, 3000, 5000],
})
```
----------------------------------------
TITLE: Uppy Core Initialization and Plugin Usage (JavaScript)
DESCRIPTION: This example demonstrates how to initialize Uppy with core functionality and integrate the Tus plugin. It also shows how to listen for upload completion events.
LANGUAGE: javascript
CODE:
```
import Uppy from '@uppy/core'
import Dashboard from '@uppy/dashboard'
import Tus from '@uppy/tus'
const uppy = new Uppy()
.use(Dashboard, { trigger: '#select-files' })
.use(Tus, { endpoint: 'https://tusd.tusdemo.net/files/' })
.on('complete', (result) => {
console.log('Upload result:', result)
})
```
----------------------------------------
TITLE: Uppy XHRUpload Configuration (JavaScript)
DESCRIPTION: This snippet shows the basic JavaScript configuration for Uppy, initializing it with the XHRUpload plugin to send files to a specified endpoint.
LANGUAGE: javascript
CODE:
```
import Uppy from '@uppy/core';
import XHRUpload from '@uppy/xhr-upload';
const uppy = new Uppy({
debug: true,
autoProceed: false,
restrictions: {
maxFileSize: 100000000,
maxNumberOfFiles: 10,
allowedFileTypes: ['image/*', 'video/*']
}
});
uppy.use(XHRUpload, {
endpoint: 'YOUR_UPLOAD_ENDPOINT_URL',
fieldName: 'files[]',
method: 'post'
});
uppy.on('complete', (result) => {
console.log('Upload complete:', result);
});
uppy.on('error', (error) => {
console.error('Upload error:', error);
});
```
----------------------------------------
TITLE: Install Uppy Core Packages for TUS
DESCRIPTION: Installs the core Uppy package along with the Dashboard and Tus plugins using npm.
LANGUAGE: bash
CODE:
```
npm install @uppy/core @uppy/dashboard @uppy/tus @uppy/xhr-upload
```
========================
QUESTIONS AND ANSWERS
========================
TOPIC: Uppy React Components
Q: What is the purpose of the @uppy/react package?
A: The @uppy/react package provides React component wrappers for Uppy's officially maintained UI plugins. It allows developers to easily integrate Uppy's file uploading capabilities into their React applications.
----------------------------------------
TOPIC: Uppy React Components
Q: How can @uppy/react be installed in a project?
A: The @uppy/react package can be installed using npm with the command '$ npm install @uppy/react'.
----------------------------------------
TOPIC: Uppy React Components
Q: Where can I find more detailed documentation for the @uppy/react plugin?
A: More detailed documentation for the @uppy/react plugin is available on the Uppy website at https://uppy.io/docs/react.

View File

@@ -15,7 +15,7 @@ pub struct ListArgs {
pub mut:
regex []string // A slice of regular expressions to filter files.
recursive bool = true // Whether to list files recursively (default true).
ignoredefault bool = true // Whether to ignore files starting with . and _ (default true).
ignore_default bool = true // Whether to ignore files starting with . and _ (default true).
include_links bool // Whether to include symbolic links in the list.
dirs_only bool // Whether to include only directories in the list.
files_only bool // Whether to include only files in the list.
@@ -77,7 +77,7 @@ for path_obj in top_level_items.paths {
#### 3. Including or Excluding Hidden Files
The `ignoredefault` parameter controls whether files and directories starting with `.` or `_` are ignored.
The `ignore_default` parameter controls whether files and directories starting with `.` or `_` are ignored.
```v
import freeflowuniverse.herolib.core.pathlib
@@ -86,7 +86,7 @@ mut dir := pathlib.get('/some/directory')!
// List all files and directories, including hidden ones
mut all_items := dir.list(
ignoredefault: false
ignore_default: false
)!
for path_obj in all_items.paths {

View File

@@ -24,13 +24,15 @@ Executes a shell command with extensive configuration.
* `work_folder` (string): Working directory.
* `environment` (map[string]string): Environment variables.
* `stdout` (bool, default: true): Show command output.
* `stdout_log` (bool, default: true): Log stdout to internal buffer.
* `raise_error` (bool, default: true): Raise V error on failure.
* `ignore_error` (bool): Do not raise error, just report.
* `debug` (bool): Enable debug output.
* `shell` (bool): Execute in interactive shell.
* `interactive` (bool, default: true): Run in interactive mode.
* `async` (bool): Run command asynchronously.
* `runtime` (`RunTime` enum): Specify runtime (`.bash`, `.python`, etc.).
* **Returns**: `Job` struct (contains `status`, `output`, `error`, `exit_code`, `start`, `end`).
* **Returns**: `Job` struct (contains `status`, `output`, `error`, `exit_code`, `start`, `end`, `process`, `runnr`).
* **Error Handling**: Returns `JobError` with `error_type` (`.exec`, `.timeout`, `.args`).
### `osal.execute_silent(cmd string) !string`
@@ -49,7 +51,24 @@ Executes a command and prints output to stdout.
* **Returns**: `string` (command output).
### `osal.execute_interactive(cmd string) !`
### `osal.execute_ok(cmd string) bool`
Executes a command and returns `true` if the command exits with a zero status, `false` otherwise.
* **Parameters**: `cmd` (string): The command string.
* **Returns**: `bool`.
Executes a command in an interactive shell.
### `osal.exec_fast(cmd: CommandFast) !string`
Executes a command quickly, with options for profile sourcing and environment variables.
* **Parameters**:
* `cmd` (`CommandFast` struct):
* `cmd` (string): The command string.
* `ignore_error` (bool): Do not raise error on non-zero exit code.
* `work_folder` (string): Working directory.
* `environment` (map[string]string): Environment variables.
* `ignore_error_codes` ([]int): List of exit codes to ignore.
* `debug` (bool): Enable debug output.
* `includeprofile` (bool): Source the user's profile before execution.
* `notempty` (bool): Return an error if the output is empty.
* **Returns**: `string` (command output).
* **Parameters**: `cmd` (string): The command string.
### `osal.cmd_exists(cmd string) bool`
@@ -78,6 +97,18 @@ Checks if a process with a given PID exists.
### `osal.processinfo_with_children(pid int) !ProcessMap`
Returns a process and all its child processes.
## 1.1. Done Context Management (`done.v`)
Functions for managing a "done" context or state using Redis.
* **`osal.done_set(key string, val string) !`**: Sets a key-value pair in the "done" context.
* **`osal.done_get(key string) ?string`**: Retrieves a value from the "done" context by key.
* **`osal.done_delete(key string) !`**: Deletes a key from the "done" context.
* **`osal.done_get_str(key string) string`**: Retrieves a string value from the "done" context by key (panics on error).
* **`osal.done_get_int(key string) int`**: Retrieves an integer value from the "done" context by key (panics on error).
* **`osal.done_exists(key string) bool`**: Checks if a key exists in the "done" context.
* **`osal.done_print() !`**: Prints all key-value pairs in the "done" context to debug output.
* **`osal.done_reset() !`**: Resets (deletes all keys from) the "done" context.
* **Parameters**: `pid` (int): Parent Process ID.
* **Returns**: `ProcessMap`.
@@ -93,6 +124,10 @@ Kills a process and all its children by name or PID.
* `name` (string): Process name.
* `pid` (int): Process ID.
### `osal.process_exists_byname(name string) !bool`
Checks if a process with a given name exists.
* **Parameters**: `name` (string): Process name (substring match).
* **Returns**: `bool`.
### `osal.whoami() !string`
Returns the current username.
* **Returns**: `string`.
@@ -102,6 +137,14 @@ Returns the current username.
### `osal.ping(args: PingArgs) !PingResult`
Checks host reachability.
* **Parameters**:
### `osal.ipaddr_pub_get_check() !string`
Retrieves the public IP address and verifies it is bound to a local interface.
* **Returns**: `string`.
### `osal.is_ip_on_local_interface(ip string) !bool`
Checks if a given IP address is bound to a local network interface.
* **Parameters**: `ip` (string): IP address to check.
* **Returns**: `bool`.
* `args` (`PingArgs` struct):
* `address` (string, required): IP address or hostname.
* `count` (u8, default: 1): Number of pings.
@@ -156,7 +199,17 @@ Deletes and then recreates a directory.
Removes files or directories.
* **Parameters**: `todelete` (string): Comma or newline separated list of paths (supports `~` for home directory).
### `osal.env_get_all() map[string]string`
Returns all existing environment variables as a map.
* **Returns**: `map[string]string`.
## 4. Environment Variables
## 4.1. Package Management (`package.v`)
Functions for managing system packages.
* **`osal.package_refresh() !`**: Updates the package list for the detected platform.
* **`osal.package_install(name_ string) !`**: Installs one or more packages.
* **`osal.package_remove(name_ string) !`**: Removes one or more packages.
### `osal.env_set(args: EnvSet)`
Sets an environment variable.
@@ -229,6 +282,10 @@ Returns the `~/hero` directory path.
Returns `/usr/local` for Linux or `~/hero` for macOS.
* **Returns**: `string`.
### `osal.cmd_exists_profile(cmd string) bool`
Checks if a command exists in the system's PATH, considering the user's profile.
* **Parameters**: `cmd` (string): The command name.
* **Returns**: `bool`.
### `osal.profile_path_source() !string`
Returns a source statement for the preferred profile file (e.g., `. /home/user/.zprofile`).
* **Returns**: `string`.
@@ -260,6 +317,37 @@ Lists all possible profile file paths in the OS.
* **Returns**: `[]string`.
### `osal.profile_paths_preferred() ![]string`
## 5.1. SSH Key Management (`ssh_key.v`)
Functions and structs for managing SSH keys.
### `struct SSHKey`
Represents an SSH key pair.
* **Fields**: `name` (string), `directory` (string).
* **Methods**:
* `public_key_path() !pathlib.Path`: Returns the path to the public key.
* `private_key_path() !pathlib.Path`: Returns the path to the private key.
* `public_key() !string`: Returns the content of the public key.
* `private_key() !string`: Returns the content of the private key.
### `struct SSHConfig`
Configuration for SSH key operations.
* **Fields**: `directory` (string, default: `~/.ssh`).
### `osal.get_ssh_key(key_name string, config SSHConfig) ?SSHKey`
Retrieves a specific SSH key by name.
* **Parameters**: `key_name` (string), `config` (`SSHConfig` struct).
* **Returns**: `?SSHKey` (optional SSHKey struct).
### `osal.list_ssh_keys(config SSHConfig) ![]SSHKey`
Lists all SSH keys in the specified directory.
* **Parameters**: `config` (`SSHConfig` struct).
* **Returns**: `[]SSHKey`.
### `osal.new_ssh_key(key_name string, config SSHConfig) !SSHKey`
Creates a new SSH key pair.
* **Parameters**: `key_name` (string), `config` (`SSHConfig` struct).
* **Returns**: `SSHKey`.
Lists preferred profile file paths based on the operating system.
* **Returns**: `[]string`.

View File

@@ -0,0 +1,42 @@
## instructions for code generation
> when I generate code, the following instructions can never be overruled they are the basics
- do not try to fix files which end with _.v because these are generated files
## instruction for vlang scripts
when I generate vlang scripts I will always use .vsh extension and use following as first line:
```
#!/usr/bin/env -S v -n -w -cg -gc none -cc tcc -d use_openssl -enable-globals run
```
- a .vsh is a v shell script and can be executed as is, no need to use v ...
- in .vsh file there is no need for a main() function
- these scripts can be used for examples or instruction scripts e.g. an installs script
## executing vlang scripts
As AI agent I should also execute .v or .vsh scripts with vrun
```bash
vrun ~/code/github/freeflowuniverse/herolib/examples/biztools/bizmodel.vsh
```
## executing test scripts
instruct user to test as follows (vtest is an alias which gets installed when herolib gets installed), can be done for a dir and for a file
```bash
vtest ~/code/github/freeflowuniverse/herolib/lib/osal/package_test.v
```
- use ~ so it works over all machines
- don't use 'v test', we have vtest as alternative
## module imports
- in v all files in a folder are part of the same module, no need to import then, this is important difference in v

View File

@@ -1,29 +1,27 @@
# OSAL Core Module - Key Capabilities (freeflowuniverse.herolib.osal.core)
```v
//example how to get started
import freeflowuniverse.herolib.osal.core as osal
osal.exec(cmd:"ls /")!
job := osal.exec(cmd: 'ls /')!
```
this document has info about the most core functions, more detailed info can be found in `aiprompts/herolib_advanced/osal.md` if needed.
This document describes the core functionalities of the Operating System Abstraction Layer (OSAL) module, designed for platform-independent system operations in V.
## Key Functions
### 1. Process Execution
## 1. Process Execution
* **`osal.exec(cmd: Command) !Job`**: Execute a shell command.
* **Key Parameters**: `cmd` (string), `timeout` (int), `retry` (int), `work_folder` (string), `environment` (map[string]string), `stdout` (bool), `raise_error` (bool).
* **Returns**: `Job` (status, output, error, exit code).
* **`osal.execute_silent(cmd string) !string`**: Execute silently, return output.
* **`osal.execute_debug(cmd string) !string`**: Execute with debug output, return output.
* **`osal.execute_stdout(cmd string) !string`**: Execute and print output to stdout, return output.
* **`osal.execute_interactive(cmd string) !`**: Execute in an interactive shell.
* **`osal.cmd_exists(cmd string) bool`**: Check if a command exists.
* **`osal.process_kill_recursive(args: ProcessKillArgs) !`**: Kill a process and its children.
### 2. Network Utilities
## 2. Network Utilities
* **`osal.ping(args: PingArgs) !PingResult`**: Check host reachability.
* **Key Parameters**: `address` (string).
@@ -32,32 +30,52 @@ this document has info about the most core functions, more detailed info can be
* **Key Parameters**: `address` (string), `port` (int).
* **`osal.ipaddr_pub_get() !string`**: Get public IP address.
### 3. File System Operations
## 3. File System Operations
* **`osal.file_write(path string, text string) !`**: Write text to a file.
* **`osal.file_read(path string) !string`**: Read content from a file.
* **`osal.dir_ensure(path string) !`**: Ensure a directory exists.
* **`osal.rm(todelete string) !`**: Remove files/directories.
### 4. Environment Variables
## 4. Environment Variables
* **`osal.env_set(args: EnvSet)`**: Set an environment variable.
* **Key Parameters**: `key` (string), `value` (string).
* **`osal.env_unset(key string)`**: Unset a specific environment variable.
* **`osal.env_unset_all()`**: Unset all environment variables.
* **`osal.env_set_all(args: EnvSetAll)`**: Set multiple environment variables.
* **Key Parameters**: `env` (map[string]string), `clear_before_set` (bool), `overwrite_if_exists` (bool).
* **`osal.env_get(key string) !string`**: Get an environment variable's value.
* **`osal.env_exists(key string) !bool`**: Check if an environment variable exists.
* **`osal.env_get_default(key string, def string) string`**: Get an environment variable or a default value.
* **`osal.load_env_file(file_path string) !`**: Load variables from a file.
### 5. Command & Profile Management
## 5. Command & Profile Management
* **`osal.cmd_add(args: CmdAddArgs) !`**: Add a binary to system paths and update profiles.
* **Key Parameters**: `source` (string, required), `cmdname` (string).
* **`osal.profile_path_add_remove(args: ProfilePathAddRemoveArgs) !`**: Add/remove paths from profiles.
* **Key Parameters**: `paths2add` (string), `paths2delete` (string).
### 6. System Information
## 6. System Information & Utilities
* **`osal.processmap_get() !ProcessMap`**: Get a map of all running processes.
* **`osal.processinfo_get(pid int) !ProcessInfo`**: Get detailed information for a specific process.
* **`osal.processinfo_get_byname(name string) ![]ProcessInfo`**: Get info for processes matching a name.
* **`osal.process_exists(pid int) bool`**: Check if a process exists by PID.
* **`osal.processinfo_with_children(pid int) !ProcessMap`**: Get a process and its children.
* **`osal.processinfo_children(pid int) !ProcessMap`**: Get children of a process.
* **`osal.process_kill_recursive(args: ProcessKillArgs) !`**: Kill a process and its children.
* **Key Parameters**: `name` (string), `pid` (int).
* **`osal.whoami() !string`**: Return the current username.
* **`osal.platform() !PlatformType`**: Identify the operating system.
* **`osal.cputype() !CPUType`**: Identify the CPU architecture.
* **`osal.hostname() !string`**: Get system hostname.
---
* **`osal.sleep(duration int)`**: Pause execution for a specified duration.
* **`osal.download(args: DownloadArgs) !pathlib.Path`**: Download a file from a URL.
* `pathlib.Path` is from `freeflowuniverse.herolib.core.pathlib`
* **Key Parameters**: `url` (string), `dest` (string), `timeout` (int), `retry` (int).
* **`osal.user_exists(username string) bool`**: Check if a user exists.
* **`osal.user_id_get(username string) !int`**: Get user ID.
* **`osal.user_add(args: UserArgs) !int`**: Add a user.
* **Key Parameters**: `name` (string).

View File

@@ -19,6 +19,9 @@ import freeflowuniverse.herolib.core.pathlib
```
### Creating Path Objects
This will figure out if the path is a dir, file and if it exists.
```v
// Create a Path object for a file
mut file_path := pathlib.get("path/to/file.txt")
@@ -27,6 +30,8 @@ mut file_path := pathlib.get("path/to/file.txt")
mut dir_path := pathlib.get("path/to/directory")
```
if you know in advance if you expect a dir or file its better to use `pathlib.get_dir(path:...,create:true)` or `pathlib.get_file(path:...,create:true)`.
### Basic Path Operations
```v
// Get absolute path

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run
import freeflowuniverse.herolib.osal.sshagent
import freeflowuniverse.herolib.builder
import freeflowuniverse.herolib.ui.console
console.print_header('SSH Agent Management Example')
// Create SSH agent with single instance guarantee
mut agent := sshagent.new_single()!
println('SSH Agent initialized and ensured single instance')
// Show diagnostics
diag := agent.diagnostics()
console.print_header('SSH Agent Diagnostics:')
for key, value in diag {
console.print_item('${key}: ${value}')
}
// Show current agent status
println(agent)
// Example: Generate a test key if no keys exist
if agent.keys.len == 0 {
console.print_header('No keys found, generating example key...')
mut key := agent.generate('example_key', '')!
console.print_debug('Generated key: ${key}')
// Load the generated key
key.load()!
console.print_debug('Key loaded into agent')
}
// Example: Push key to remote node (uncomment and modify for actual use)
/*
console.print_header('Testing remote node key deployment...')
mut b := builder.new()!
// Create connection to remote node
mut node := b.node_new(
ipaddr: 'root@192.168.1.100:22' // Replace with actual remote host
name: 'test_node'
)!
if agent.keys.len > 0 {
key_name := agent.keys[0].name
console.print_debug('Pushing key "${key_name}" to remote node...')
// Push the key
agent.push_key_to_node(mut node, key_name)!
// Verify access
if agent.verify_key_access(mut node, key_name)! {
console.print_debug('✓ SSH key access verified')
} else {
console.print_debug('✗ SSH key access verification failed')
}
// Optional: Remove key from remote (for testing)
// agent.remove_key_from_node(mut node, key_name)!
// console.print_debug('Key removed from remote node')
}
*/
console.print_header('SSH Agent example completed successfully')

View File

@@ -1,6 +1,7 @@
module main
import freeflowuniverse.herolib.osal.sshagent
import freeflowuniverse.herolib.osal.linux
fn do1() ! {
mut agent := sshagent.new()!
@@ -20,6 +21,31 @@ fn do1() ! {
// println(agent)
}
fn test_user_mgmt() ! {
mut lf := linux.new()!
// Test user creation
lf.user_create(
name: 'testuser'
sshkey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM3/2K7R8A/l0kM0/d'
)!
// Test ssh key creation
lf.sshkey_create(
username: 'testuser'
sshkey_name: 'testkey'
)!
// Test ssh key deletion
lf.sshkey_delete(
username: 'testuser'
sshkey_name: 'testkey'
)!
// Test user deletion
lf.user_delete(name: 'testuser')!
}
fn main() {
do1() or { panic(err) }
test_user_mgmt() or { panic(err) }
}

20
examples/osal/tmux.vsh Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run
import freeflowuniverse.herolib.osal.tmux
mut t := tmux.new()!
if !t.is_running()! {
t.start()!
}
if t.session_exist('main') {
t.session_delete('main')!
}
// Create session first, then create window
mut session := t.session_create(name: 'main')!
session.window_new(name: 'test', cmd: 'mc', reset: true)!
// Or use the convenience method
// t.window_new(session_name: 'main', name: 'test', cmd: 'mc', reset: true)!
println(t)

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run
import freeflowuniverse.herolib.osal.tmux
import freeflowuniverse.herolib.osal.core as osal
import time
mut t := tmux.new()!
if !t.is_running()! {
t.start()!
}
// Create a session and window
mut session := t.session_create(name: 'test')!
mut window := session.window_new(name: 'monitoring', cmd: 'top', reset: true)!
// Wait a moment for the process to start
time.sleep(1000 * time.millisecond)
// Get the active pane
if mut pane := window.pane_active() {
// Get process info for the pane and its children
process_map := pane.processinfo()!
println('Process tree for pane ${pane.id}:')
for process in process_map.processes {
println(' PID: ${process.pid}, CPU: ${process.cpu_perc}%, Memory: ${process.mem_perc}%, Command: ${process.cmd}')
}
// Get just the main process info
main_process := pane.processinfo_main()!
println('\nMain process: PID ${main_process.pid}, Command: ${main_process.cmd}')
}

View File

@@ -1 +0,0 @@
tmux

View File

@@ -1,15 +0,0 @@
module main
import freeflowuniverse.herolib.osal.tmux
fn do() ! {
mut t := tmux.new()!
t.session_delete('main')!
println(t)
t.window_new(name: 'test', cmd: 'mc', reset: true)!
println(t)
}
fn main() {
do() or { panic(err) }
}

View File

@@ -1,2 +0,0 @@
0263829989b6fd954f72baaf2fc64bc2e2f01d692d4de72986ea808f6e99813f|1662456738|1|test2.md
87428fc522803d31065e7bce3cf03fe475096631e5e07bbd7a0fde60c4cf25c7|1662456738|1|test.md

View File

@@ -1,5 +0,0 @@
a3a5e715f0cc574a73c3f9bebb6bc24f32ffd5b67b387244c2c909da779a1478|1662456738|1|test3.md
ef2d127de37b942baad06145e54b0c619a1f22327b2ebbcfbec78f5564afe39d|1662456824|2|test3.md
e7f6c011776e8db7cd330b54174fd76f7d0216b612387a5ffcfb81e6f0919683|1662456824|3|test3.md
ef2d127de37b942baad06145e54b0c619a1f22327b2ebbcfbec78f5564afe39d|1662457271|4|test3.md
ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb|1662457271|5|test3.md

View File

@@ -1,33 +0,0 @@
module main
import freeflowuniverse.herolib.vault
import freeflowuniverse.herolib.core.pathlib
import os
const testdir2 = os.dir(@FILE) + '/easy'
fn do() ? {
mut v := vault.do(testdir2)?
remember := v.hash()
mut p := pathlib.get('${testdir2}/subdir/subsudir/test3.md')
p.write('5')?
mut v2 := vault.do(testdir2)? // will remember the change
p.write('a')?
mut v3 := vault.do(testdir2)? // will remember the change
println(v3.superlist())
println(v3.hash())
// restore to the original scan
mut v4 := vault.restore(0)?
remember3 := v.hash()
assert remember == remember3
v3.delete()?
}
fn main() {
do() or { panic(err) }
}

View File

@@ -1,26 +0,0 @@
module main
import freeflowuniverse.herolib.core.pathlib
import freeflowuniverse.herolib.vault
import os
const testdir = os.dir(@FILE) + '/../../pathlib/examples/test_path'
fn do() ? {
// just to check it exists
mut p := pathlib.get_dir(testdir, false)?
p.absolute()
println(p)
// will load the vault, doesn't process files yet
// mut vault1 := vault.scan('myvault', mut p)?
// println(vault)
// vault1.delete()?
mut vault2 := vault.scan('myvault', mut p)?
vault2.shelve()?
// println(vault2)
vault2.delete()?
}
fn main() {
do() or { panic(err) }
}

View File

@@ -18,7 +18,7 @@ You can configure the client using a HeroScript file:
Here's how to get the client and use its methods.
```vlang
```v
import freeflowuniverse.herolib.clients.giteaclient
import freeflowuniverse.herolib.core.base

View File

@@ -1,9 +1,37 @@
module livekit
// App struct with `livekit.Client`, API keys, and other shared data
pub struct Client {
pub:
url string @[required]
api_key string @[required]
api_secret string @[required]
}
import net.http
import json
import time
fn (mut c LivekitClient) post(path string, body any) !http.Response {
mut token := c.new_access_token(
identity: 'api'
name: 'API User'
ttl: 10 * 60 // 10 minutes
)!
token.add_video_grant(VideoGrant{
room_create: true
room_admin: true
room_list: true
})
jwt := token.to_jwt()!
mut header := http.new_header()
header.add('Authorization', 'Bearer ' + jwt)!
header.add('Content-Type', 'application/json')!
url := '${c.url}/${path}'
data := json.encode(body)
mut req := http.Request{
method: .post
url: url
header: header
data: data
}
resp := http.fetch(req)!
if resp.status_code != 200 {
return error('failed to execute request: ${resp.body}')
}
return resp
}

View File

@@ -0,0 +1,34 @@
module livekit
import freeflowuniverse.herolib.data.caching
import os
const CACHING_METHOD = caching.CachingMethod.once_per_process
fn _init() ! {
if caching.is_set(key: 'livekit_clients') {
return
}
caching.set[map[string]LivekitClient](key: 'livekit_clients', val: map[string]LivekitClient{}, CachingMethod.once_per_process)!
}
fn _get() !map[string]LivekitClient {
_init()!
return caching.get[map[string]LivekitClient](key: 'livekit_clients')!
}
pub fn get(name string) !LivekitClient {
mut clients := _get()!
return clients[name] or { return error('livekit client ${name} not found') }
}
pub fn set(client LivekitClient) ! {
mut clients := _get()!
clients[client.name] = client
caching.set[map[string]LivekitClient](key: 'livekit_clients', val: clients, CachingMethod.once_per_process)!
}
pub fn exists(name string) !bool {
mut clients := _get()!
return name in clients
}

View File

@@ -0,0 +1,18 @@
module livekit
pub struct SendDataArgs {
pub mut:
room_name string
data []u8
kind DataPacket_Kind
destination_sids []string
}
pub enum DataPacket_Kind {
reliable
lossy
}
pub fn (mut c LivekitClient) send_data(args SendDataArgs) ! {
_ = c.post('twirp/livekit.RoomService/SendData', args)!
}

View File

@@ -0,0 +1,84 @@
module livekit
import json
pub struct EgressInfo {
pub mut:
egress_id string
room_id string
status string
started_at i64
ended_at i64
error string
}
pub struct StartRoomCompositeEgressArgs {
pub mut:
room_name string
layout string
audio_only bool
video_only bool
custom_base_url string
}
pub struct StartTrackCompositeEgressArgs {
pub mut:
room_name string
audio_track_id string
video_track_id string
}
pub struct StartWebEgressArgs {
pub mut:
url string
audio_only bool
video_only bool
}
pub struct UpdateStreamArgs {
pub mut:
add_output_urls []string
remove_output_urls []string
}
pub fn (mut c LivekitClient) start_room_composite_egress(args StartRoomCompositeEgressArgs) !EgressInfo {
mut resp := c.post('twirp/livekit.Egress/StartRoomCompositeEgress', args)!
egress_info := json.decode[EgressInfo](resp.body)!
return egress_info
}
pub fn (mut c LivekitClient) start_track_composite_egress(args StartTrackCompositeEgressArgs) !EgressInfo {
mut resp := c.post('twirp/livekit.Egress/StartTrackCompositeEgress', args)!
egress_info := json.decode[EgressInfo](resp.body)!
return egress_info
}
pub fn (mut c LivekitClient) start_web_egress(args StartWebEgressArgs) !EgressInfo {
mut resp := c.post('twirp/livekit.Egress/StartWebEgress', args)!
egress_info := json.decode[EgressInfo](resp.body)!
return egress_info
}
pub fn (mut c LivekitClient) update_layout(egress_id string, layout string) !EgressInfo {
mut resp := c.post('twirp/livekit.Egress/UpdateLayout', {'egress_id': egress_id, 'layout': layout})!
egress_info := json.decode[EgressInfo](resp.body)!
return egress_info
}
pub fn (mut c LivekitClient) update_stream(egress_id string, args UpdateStreamArgs) !EgressInfo {
mut resp := c.post('twirp/livekit.Egress/UpdateStream', {'egress_id': egress_id, 'add_output_urls': args.add_output_urls, 'remove_output_urls': args.remove_output_urls})!
egress_info := json.decode[EgressInfo](resp.body)!
return egress_info
}
pub fn (mut c LivekitClient) list_egress(room_name string) ![]EgressInfo {
mut resp := c.post('twirp/livekit.Egress/ListEgress', {'room_name': room_name})!
egress_infos := json.decode[[]EgressInfo](resp.body)!
return egress_infos
}
pub fn (mut c LivekitClient) stop_egress(egress_id string) !EgressInfo {
mut resp := c.post('twirp/livekit.Egress/StopEgress', {'egress_id': egress_id})!
egress_info := json.decode[EgressInfo](resp.body)!
return egress_info
}

View File

@@ -0,0 +1,128 @@
module livekit
import json
pub struct IngressInfo {
pub mut:
ingress_id string
name string
stream_key string
url string
input_type IngressInput
audio IngressAudioOptions
video IngressVideoOptions
state IngressState
}
pub enum IngressInput {
rtmp_input
whip_input
}
pub struct IngressAudioOptions {
pub mut:
name string
source TrackSource
preset AudioPreset
}
pub struct IngressVideoOptions {
pub mut:
name string
source TrackSource
preset VideoPreset
}
pub enum TrackSource {
camera
microphone
screen_share
screen_share_audio
}
pub enum AudioPreset {
opus_stereo_96kbps
opus_mono_64kbps
}
pub enum VideoPreset {
h264_720p_30fps_3mbps
h264_1080p_30fps_4_5mbps
h264_540p_25fps_2mbps
}
pub struct IngressState {
pub mut:
status IngressStatus
error string
video InputVideoState
audio InputAudioState
room_id string
started_at i64
}
pub enum IngressStatus {
endpoint_inactive
endpoint_buffering
endpoint_publishing
}
pub struct InputVideoState {
pub mut:
mime_type string
width u32
height u32
framerate u32
}
pub struct InputAudioState {
pub mut:
mime_type string
channels u32
sample_rate u32
}
pub struct CreateIngressArgs {
pub mut:
name string
room_name string
participant_identity string
participant_name string
input_type IngressInput
audio IngressAudioOptions
video IngressVideoOptions
}
pub struct UpdateIngressArgs {
pub mut:
name string
room_name string
participant_identity string
participant_name string
audio IngressAudioOptions
video IngressVideoOptions
}
pub fn (mut c LivekitClient) create_ingress(args CreateIngressArgs) !IngressInfo {
mut resp := c.post('twirp/livekit.Ingress/CreateIngress', args)!
ingress_info := json.decode[IngressInfo](resp.body)!
return ingress_info
}
pub fn (mut c LivekitClient) update_ingress(ingress_id string, args UpdateIngressArgs) !IngressInfo {
mut resp := c.post('twirp/livekit.Ingress/UpdateIngress', {'ingress_id': ingress_id, ...args})!
ingress_info := json.decode[IngressInfo](resp.body)!
return ingress_info
}
pub fn (mut c LivekitClient) list_ingress(room_name string) ![]IngressInfo {
mut resp := c.post('twirp/livekit.Ingress/ListIngress', {'room_name': room_name})!
ingress_infos := json.decode[[]IngressInfo](resp.body)!
return ingress_infos
}
pub fn (mut c LivekitClient) delete_ingress(ingress_id string) !IngressInfo {
mut resp := c.post('twirp/livekit.Ingress/DeleteIngress', {'ingress_id': ingress_id})!
ingress_info := json.decode[IngressInfo](resp.body)!
return ingress_info
}

View File

@@ -8,24 +8,26 @@ 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 LivekitClient {
pub mut:
name string = 'default'
mail_from string
mail_password string @[secret]
mail_port int
mail_server string
mail_username string
name string = 'default'
url string @[required]
api_key string @[required]
api_secret string @[required; secret]
}
// your checking & initialization code if needed
fn obj_init(mycfg_ LivekitClient) !LivekitClient {
mut mycfg := mycfg_
if mycfg.password == '' && mycfg.secret == '' {
return error('password or secret needs to be filled in for ${mycfg.name}')
if mycfg.url == '' {
return error('url needs to be filled in for ${mycfg.name}')
}
if mycfg.api_key == '' {
return error('api_key needs to be filled in for ${mycfg.name}')
}
if mycfg.api_secret == '' {
return error('api_secret needs to be filled in for ${mycfg.name}')
}
return mycfg
}
@@ -39,4 +41,4 @@ pub fn heroscript_dumps(obj LivekitClient) !string {
pub fn heroscript_loads(heroscript string) !LivekitClient {
mut obj := encoderhero.decode[LivekitClient](heroscript)!
return obj
}
}

View File

@@ -0,0 +1,57 @@
module livekit
import json
pub struct ParticipantInfo {
pub mut:
sid string
identity string
state string
metadata string
joined_at i64
name string
version u32
permission string
region string
publisher bool
}
pub struct UpdateParticipantArgs {
pub mut:
room_name string
identity string
metadata string
permission string
}
pub struct MutePublishedTrackArgs {
pub mut:
room_name string
identity string
track_sid string
muted bool
}
pub fn (mut c LivekitClient) list_participants(room_name string) ![]ParticipantInfo {
mut resp := c.post('twirp/livekit.RoomService/ListParticipants', {'room': room_name})!
participants := json.decode[[]ParticipantInfo](resp.body)!
return participants
}
pub fn (mut c LivekitClient) get_participant(room_name string, identity string) !ParticipantInfo {
mut resp := c.post('twirp/livekit.RoomService/GetParticipant', {'room': room_name, 'identity': identity})!
participant := json.decode[ParticipantInfo](resp.body)!
return participant
}
pub fn (mut c LivekitClient) remove_participant(room_name string, identity string) ! {
_ = c.post('twirp/livekit.RoomService/RemoveParticipant', {'room': room_name, 'identity': identity})!
}
pub fn (mut c LivekitClient) update_participant(args UpdateParticipantArgs) ! {
_ = c.post('twirp/livekit.RoomService/UpdateParticipant', args)!
}
pub fn (mut c LivekitClient) mute_published_track(args MutePublishedTrackArgs) ! {
_ = c.post('twirp/livekit.RoomService/MutePublishedTrack', args)!
}

167
lib/clients/livekit/play.v Normal file
View File

@@ -0,0 +1,167 @@
module livekit
import freeflowuniverse.herolib.core.playbook { PlayBook }
import freeflowuniverse.herolib.core.texttools
import freeflowuniverse.herolib.ui.console
pub fn play(mut plbook PlayBook) ! {
if !plbook.exists(filter: 'livekit.') {
return
}
// Handle livekit.init - configure the client
if plbook.exists_once(filter: 'livekit.init') {
mut action := plbook.get(filter: 'livekit.init')!
mut p := action.params
name := texttools.name_fix(p.get_default('name', 'default')!)
url := p.get('url')!
api_key := p.get('api_key')!
api_secret := p.get('api_secret')!
mut client := LivekitClient{
name: name
url: url
api_key: api_key
api_secret: api_secret
}
set(client)!
console.print_header('LiveKit client "${name}" configured')
action.done = true
}
// Handle room creation
mut room_create_actions := plbook.find(filter: 'livekit.room_create')!
for mut action in room_create_actions {
mut p := action.params
client_name := texttools.name_fix(p.get_default('client', 'default')!)
room_name := p.get('name')!
empty_timeout := p.get_u32_default('empty_timeout', 300)!
max_participants := p.get_u32_default('max_participants', 50)!
metadata := p.get_default('metadata', '')!
mut client := get(name: client_name)!
room := client.create_room(
name: room_name
empty_timeout: empty_timeout
max_participants: max_participants
metadata: metadata
)!
console.print_header('Room "${room_name}" created successfully')
action.done = true
}
// Handle room deletion
mut room_delete_actions := plbook.find(filter: 'livekit.room_delete')!
for mut action in room_delete_actions {
mut p := action.params
client_name := texttools.name_fix(p.get_default('client', 'default')!)
room_name := p.get('name')!
mut client := get(name: client_name)!
client.delete_room(room_name)!
console.print_header('Room "${room_name}" deleted successfully')
action.done = true
}
// Handle participant removal
mut participant_remove_actions := plbook.find(filter: 'livekit.participant_remove')!
for mut action in participant_remove_actions {
mut p := action.params
client_name := texttools.name_fix(p.get_default('client', 'default')!)
room_name := p.get('room')!
identity := p.get('identity')!
mut client := get(name: client_name)!
client.remove_participant(room_name, identity)!
console.print_header('Participant "${identity}" removed from room "${room_name}"')
action.done = true
}
// Handle participant mute/unmute
mut participant_mute_actions := plbook.find(filter: 'livekit.participant_mute')!
for mut action in participant_mute_actions {
mut p := action.params
client_name := texttools.name_fix(p.get_default('client', 'default')!)
room_name := p.get('room')!
identity := p.get('identity')!
track_sid := p.get('track_sid')!
muted := p.get_default_true('muted')
mut client := get(name: client_name)!
client.mute_published_track(
room_name: room_name
identity: identity
track_sid: track_sid
muted: muted
)!
status := if muted { 'muted' } else { 'unmuted' }
console.print_header('Track "${track_sid}" ${status} for participant "${identity}"')
action.done = true
}
// Handle room metadata update
mut room_update_actions := plbook.find(filter: 'livekit.room_update')!
for mut action in room_update_actions {
mut p := action.params
client_name := texttools.name_fix(p.get_default('client', 'default')!)
room_name := p.get('room')!
metadata := p.get('metadata')!
mut client := get(name: client_name)!
client.update_room_metadata(
room_name: room_name
metadata: metadata
)!
console.print_header('Room "${room_name}" metadata updated')
action.done = true
}
// Handle access token generation
mut token_create_actions := plbook.find(filter: 'livekit.token_create')!
for mut action in token_create_actions {
mut p := action.params
client_name := texttools.name_fix(p.get_default('client', 'default')!)
identity := p.get('identity')!
name := p.get_default('name', identity)!
room := p.get_default('room', '')!
ttl := p.get_int_default('ttl', 21600)!
can_publish := p.get_default_false('can_publish')
can_subscribe := p.get_default_true('can_subscribe')
can_publish_data := p.get_default_false('can_publish_data')
mut client := get(name: client_name)!
mut token := client.new_access_token(
identity: identity
name: name
ttl: ttl
)!
token.add_video_grant(VideoGrant{
room: room
room_join: true
can_publish: can_publish
can_subscribe: can_subscribe
can_publish_data: can_publish_data
})
jwt := token.to_jwt()!
console.print_header('Access token generated for "${identity}"')
console.print_debug('Token: ${jwt}')
action.done = true
}
}

View File

@@ -1,50 +1,47 @@
module livekit
import net.http
import json
import net.http
@[params]
pub struct ListRoomsParams {
names []string
pub struct Room {
pub mut:
sid string
name string
empty_timeout u32
max_participants u32
creation_time i64
turn_password string
enabled_codecs []string
metadata string
num_participants u32
num_connected_participants u32
active_recording bool
}
pub struct ListRoomsResponse {
pub:
rooms []Room
pub struct CreateRoomArgs {
pub mut:
name string
empty_timeout u32
max_participants u32
metadata string
}
pub fn (c Client) list_rooms(params ListRoomsParams) !ListRoomsResponse {
// Prepare request body
request := params
request_json := json.encode(request)
// create token and give grant to list rooms
mut token := c.new_access_token()!
token.grants.video.room_list = true
// make POST request
url := '${c.url}/twirp/livekit.RoomService/ListRooms'
// Configure HTTP request
mut headers := http.new_header_from_map({
http.CommonHeader.authorization: 'Bearer ${token.to_jwt()!}'
http.CommonHeader.content_type: 'application/json'
})
response := http.fetch(http.FetchConfig{
url: url
method: .post
header: headers
data: request_json
})!
if response.status_code != 200 {
return error('Failed to list rooms: ${response.status_code}')
}
// Parse response
rooms_response := json.decode(ListRoomsResponse, response.body) or {
return error('Failed to parse response: ${err}')
}
return rooms_response
pub struct UpdateRoomMetadataArgs {
pub mut:
room_name string
metadata string
}
pub fn (mut c LivekitClient) create_room(args CreateRoomArgs) !Room {
mut resp := c.post('twirp/livekit.RoomService/CreateRoom', args)!
room := json.decode[Room](resp.body)!
return room
}
pub fn (mut c LivekitClient) delete_room(room_name string) ! {
_ = c.post('twirp/livekit.RoomService/DeleteRoom', {'room': room_name})!
}
pub fn (mut c LivekitClient) update_room_metadata(args UpdateRoomMetadataArgs) ! {
_ = c.post('twirp/livekit.RoomService/UpdateRoomMetadata', args)!
}

View File

@@ -1,34 +1,52 @@
module livekit
import jwt
import time
import rand
import crypto.hmac
import crypto.sha256
import encoding.base64
import json
// Define AccessTokenOptions struct
@[params]
pub struct AccessTokenOptions {
pub struct AccessToken {
pub mut:
ttl int = 21600 // TTL in seconds
name string // Display name for the participant
identity string // Identity of the user
metadata string // Custom metadata to be passed to participants
api_key string
api_secret string
identity string
name string
ttl int
video_grant VideoGrant
}
// Constructor for AccessToken
pub fn (client Client) new_access_token(options AccessTokenOptions) !AccessToken {
pub struct VideoGrant {
pub mut:
room_create bool
room_admin bool
room_join bool
room_list bool
can_publish bool
can_subscribe bool
can_publish_data bool
room string
}
pub fn (mut c LivekitClient) new_access_token(identity string, name string, ttl int) !AccessToken {
return AccessToken{
api_key: client.api_key
api_secret: client.api_secret
identity: options.identity
ttl: options.ttl
grants: ClaimGrants{
exp: time.now().unix() + options.ttl
iss: client.api_key
sub: options.name
name: options.name
}
api_key: c.api_key
api_secret: c.api_secret
identity: identity
name: name
ttl: ttl
}
}
pub fn (mut t AccessToken) add_video_grant(grant VideoGrant) {
t.video_grant = grant
}
pub fn (t AccessToken) to_jwt() !string {
mut claims := jwt.new_claims()
claims.iss = t.api_key
claims.sub = t.identity
claims.exp = time.now().unix_time() + t.ttl
claims.nbf = time.now().unix_time()
claims.iat = time.now().unix_time()
claims.name = t.name
claims.video = t.video_grant
return jwt.encode(claims, t.api_secret, .hs256)
}

View File

@@ -0,0 +1,51 @@
module traefik
import freeflowuniverse.herolib.core.texttools
import freeflowuniverse.herolib.core.redisclient
import freeflowuniverse.herolib.osal.traefik as osal_traefik
__global (
traefik_managers map[string]&TraefikManager
)
@[params]
pub struct FactoryArgs {
pub mut:
name string = 'default'
redis_url string = '127.0.0.1:6379'
}
pub fn new(args FactoryArgs) !&TraefikManager {
name := texttools.name_fix(args.name)
if name in traefik_managers {
return traefik_managers[name]
}
mut redis := redisclient.core_get(redisclient.get_redis_url(args.redis_url)!)!
mut manager := &TraefikManager{
name: name
redis: redis
config: osal_traefik.new_traefik_config()
}
// Set redis connection in config
manager.config.redis = redis
traefik_managers[name] = manager
return manager
}
pub fn get(args FactoryArgs) !&TraefikManager {
name := texttools.name_fix(args.name)
return traefik_managers[name] or {
return error('traefik manager with name "${name}" does not exist')
}
}
pub fn default() !&TraefikManager {
if traefik_managers.len == 0 {
return new(name: 'default')!
}
return get(name: 'default')!
}

View File

@@ -0,0 +1,154 @@
module traefik
import freeflowuniverse.herolib.core.redisclient
import freeflowuniverse.herolib.osal.traefik as osal_traefik
import freeflowuniverse.herolib.core.texttools
@[heap]
pub struct TraefikManager {
pub mut:
name string
redis &redisclient.Redis
config osal_traefik.TraefikConfig
entrypoints []EntryPointConfig
}
pub struct EntryPointConfig {
pub mut:
name string @[required]
address string @[required]
tls bool
}
@[params]
pub struct RouterAddArgs {
pub mut:
name string @[required]
rule string @[required]
service string @[required]
entrypoints []string
middlewares []string
tls bool
priority int
}
@[params]
pub struct ServiceAddArgs {
pub mut:
name string @[required]
servers []string @[required]
strategy string = 'wrr' // wrr or p2c
}
@[params]
pub struct MiddlewareAddArgs {
pub mut:
name string @[required]
typ string @[required]
settings map[string]string
}
@[params]
pub struct EntryPointAddArgs {
pub mut:
name string @[required]
address string @[required]
tls bool
}
// Add router configuration
pub fn (mut tm TraefikManager) router_add(args RouterAddArgs) ! {
tm.config.add_route(
name: texttools.name_fix(args.name)
rule: args.rule
service: texttools.name_fix(args.service)
middlewares: args.middlewares.map(texttools.name_fix(it))
priority: args.priority
tls: args.tls
)
}
// Add service configuration
pub fn (mut tm TraefikManager) service_add(args ServiceAddArgs) ! {
mut servers := []osal_traefik.ServerConfig{}
for server_url in args.servers {
servers << osal_traefik.ServerConfig{
url: server_url.trim_space()
}
}
tm.config.add_service(
name: texttools.name_fix(args.name)
load_balancer: osal_traefik.LoadBalancerConfig{
servers: servers
}
)
}
// Add middleware configuration
pub fn (mut tm TraefikManager) middleware_add(args MiddlewareAddArgs) ! {
tm.config.add_middleware(
name: texttools.name_fix(args.name)
typ: args.typ
settings: args.settings
)
}
// Add entrypoint configuration (stored separately as these are typically static config)
pub fn (mut tm TraefikManager) entrypoint_add(args EntryPointAddArgs) ! {
entrypoint := EntryPointConfig{
name: texttools.name_fix(args.name)
address: args.address
tls: args.tls
}
// Check if entrypoint already exists
for mut ep in tm.entrypoints {
if ep.name == entrypoint.name {
ep.address = entrypoint.address
ep.tls = entrypoint.tls
return
}
}
tm.entrypoints << entrypoint
}
// Apply all configurations to Redis
pub fn (mut tm TraefikManager) apply() ! {
// Apply dynamic configuration (routers, services, middlewares)
tm.config.set()!
// Store entrypoints separately (these would typically be in static config)
for ep in tm.entrypoints {
tm.redis.hset('traefik:entrypoints', ep.name, '${ep.address}|${ep.tls}')!
}
}
// Get all entrypoints
pub fn (mut tm TraefikManager) entrypoints_get() ![]EntryPointConfig {
return tm.entrypoints.clone()
}
// Clear all configurations
pub fn (mut tm TraefikManager) clear() ! {
tm.config = osal_traefik.new_traefik_config()
tm.config.redis = tm.redis
tm.entrypoints = []EntryPointConfig{}
// Clear Redis keys
keys := tm.redis.keys('traefik/*')!
for key in keys {
tm.redis.del(key)!
}
}
// Get configuration status
pub fn (mut tm TraefikManager) status() !map[string]int {
return {
'routers': tm.config.routers.len
'services': tm.config.services.len
'middlewares': tm.config.middlewares.len
'entrypoints': tm.entrypoints.len
}
}

168
lib/clients/traefik/play.v Normal file
View File

@@ -0,0 +1,168 @@
module traefik
import freeflowuniverse.herolib.core.playbook { PlayBook }
import freeflowuniverse.herolib.core.texttools
import freeflowuniverse.herolib.ui.console
pub fn play(mut plbook PlayBook) ! {
if !plbook.exists(filter: 'traefik.') {
return
}
// Get or create default traefik manager
mut manager := default()!
// Process entrypoints first
play_entrypoints(mut plbook, mut manager)!
// Process services (before routers that might reference them)
play_services(mut plbook, mut manager)!
// Process middlewares (before routers that might reference them)
play_middlewares(mut plbook, mut manager)!
// Process routers
play_routers(mut plbook, mut manager)!
// Apply all configurations to Redis
manager.apply()!
console.print_debug('Traefik configuration applied successfully')
}
fn play_entrypoints(mut plbook PlayBook, mut manager TraefikManager) ! {
entrypoint_actions := plbook.find(filter: 'traefik.entrypoint')!
for mut action in entrypoint_actions {
mut p := action.params
manager.entrypoint_add(
name: p.get('name')!
address: p.get('address')!
tls: p.get_default_false('tls')
)!
action.done = true
}
}
fn play_routers(mut plbook PlayBook, mut manager TraefikManager) ! {
router_actions := plbook.find(filter: 'traefik.router')!
for mut action in router_actions {
mut p := action.params
// Parse entrypoints list
mut entrypoints := []string{}
if entrypoints_str := p.get_default('entrypoints', '') {
if entrypoints_str.len > 0 {
entrypoints = entrypoints_str.split(',').map(it.trim_space())
}
}
// Parse middlewares list
mut middlewares := []string{}
if middlewares_str := p.get_default('middlewares', '') {
if middlewares_str.len > 0 {
middlewares = middlewares_str.split(',').map(it.trim_space())
}
}
manager.router_add(
name: p.get('name')!
rule: p.get('rule')!
service: p.get('service')!
entrypoints: entrypoints
middlewares: middlewares
tls: p.get_default_false('tls')
priority: p.get_int_default('priority', 0)
)!
action.done = true
}
}
fn play_services(mut plbook PlayBook, mut manager TraefikManager) ! {
service_actions := plbook.find(filter: 'traefik.service')!
for mut action in service_actions {
mut p := action.params
// Parse servers list
servers_str := p.get('servers')!
servers := servers_str.split(',').map(it.trim_space())
manager.service_add(
name: p.get('name')!
servers: servers
strategy: p.get_default('strategy', 'wrr')!
)!
action.done = true
}
}
fn play_middlewares(mut plbook PlayBook, mut manager TraefikManager) ! {
middleware_actions := plbook.find(filter: 'traefik.middleware')!
for mut action in middleware_actions {
mut p := action.params
// Build settings map from remaining parameters
mut settings := map[string]string{}
middleware_type := p.get('type')!
// Handle common middleware types
match middleware_type {
'basicAuth' {
if users := p.get_default('users', '') {
settings['users'] = '["${users}"]'
}
}
'stripPrefix' {
if prefixes := p.get_default('prefixes', '') {
settings['prefixes'] = '["${prefixes}"]'
}
}
'addPrefix' {
if prefix := p.get_default('prefix', '') {
settings['prefix'] = prefix
}
}
'headers' {
if custom_headers := p.get_default('customRequestHeaders', '') {
settings['customRequestHeaders'] = custom_headers
}
if custom_response_headers := p.get_default('customResponseHeaders', '') {
settings['customResponseHeaders'] = custom_response_headers
}
}
'rateLimit' {
if rate := p.get_default('rate', '') {
settings['rate'] = rate
}
if burst := p.get_default('burst', '') {
settings['burst'] = burst
}
}
else {
// For other middleware types, get all parameters as settings
param_map := p.get_map()
for key, value in param_map {
if key !in ['name', 'type'] {
settings[key] = value
}
}
}
}
manager.middleware_add(
name: p.get('name')!
typ: middleware_type
settings: settings
)!
action.done = true
}
}

View File

@@ -17,7 +17,7 @@ pub fn scan(args_ GeneratorArgs) ! {
mut pathroot := pathlib.get_dir(path: args.path, create: false)!
mut plist := pathroot.list(
recursive: true
ignoredefault: false
ignore_default: false
regex: ['.heroscript']
)!

View File

@@ -10,7 +10,7 @@ pub struct ListArgs {
pub mut:
regex []string
recursive bool = true
ignoredefault bool = true // ignore files starting with . and _
ignore_default bool = true // ignore files starting with . and _
include_links bool // wether to include links in list
dirs_only bool
files_only bool
@@ -31,8 +31,8 @@ pub mut:
// params: .
// ```
// regex []string
// recursive bool // std off, means we recursive not over dirs by default
// ignoredefault bool = true // ignore files starting with . and _
// recursive bool = true // default true, means we recursive over dirs by default
// ignore_default bool = true // ignore files starting with . and _
// dirs_only bool
//
// example see https://github.com/freeflowuniverse/herolib/blob/development/examples/core/pathlib/examples/list/path_list.v
@@ -56,7 +56,7 @@ pub fn (mut path Path) list(args_ ListArgs) !PathList {
mut args := ListArgsInternal{
regex: r
recursive: args_.recursive
ignoredefault: args_.ignoredefault
ignore_default: args_.ignore_default
dirs_only: args_.dirs_only
files_only: args_.files_only
include_links: args_.include_links
@@ -74,7 +74,7 @@ pub struct ListArgsInternal {
mut:
regex []regex.RE // only put files in which follow one of the regexes
recursive bool = true
ignoredefault bool = true // ignore files starting with . and _
ignore_default bool = true // ignore files starting with . and _
dirs_only bool
files_only bool
include_links bool
@@ -108,7 +108,7 @@ fn (mut path Path) list_internal(args ListArgsInternal) ![]Path {
if new_path.is_link() && !args.include_links {
continue
}
if args.ignoredefault {
if args.ignore_default {
if item.starts_with('_') || item.starts_with('.') {
continue
}

View File

@@ -222,7 +222,7 @@ pub fn (mut path Path) move(args MoveArgs) ! {
// e.g. path is /tmp/rclone and there is /tmp/rclone/rclone-v1.64.2-linux-amd64 .
// that last dir needs to move 1 up
pub fn (mut path Path) moveup_single_subdir() ! {
mut plist := path.list(recursive: false, ignoredefault: true, dirs_only: true)!
mut plist := path.list(recursive: false, ignore_default: true, dirs_only: true)!
// console.print_debug(plist.str())
if plist.paths.len != 1 {
return error('could not find one subdir in ${path.path} , so cannot move up')

View File

@@ -75,7 +75,7 @@ mut pathlist_with_links := dir.list(
// Don't ignore hidden files (those starting with . or _)
mut pathlist_all := dir.list(
ignoredefault: false
ignore_default: false
)!
// Access the resulting paths

View File

@@ -38,7 +38,7 @@ fn (mut cw CodeWalker) filemap_get_from_path(path string, content_read bool) !Fi
return error('Source directory "${path}" does not exist')
}
mut files := dir.list(ignoredefault: false)!
mut files := dir.list(ignore_default: false)!
mut fm := FileMap{
source: path
}

View File

@@ -237,7 +237,7 @@ pub fn (mut gs GitStructure) do(args_ ReposActionsArgs) !string {
need_commit_repo := need_push_repo || need_pull_repo
|| (need_commit0 && g.need_commit()!)
// console.print_debug(" --- git_do ${g.cache_key()} \n need_commit_repo:${need_commit_repo} \n need_pull_repo:${need_pull_repo} \n need_push_repo:${need_push_repo}")
// console.print_debug(" --- git_do ${g.cache_key()} \n need_commit_repo:${need_commit_repo} \n need_pull_repo:${need_pull_repo} \n need_push_repo:${need_push_repo}")
if need_commit_repo {
mut msg := args.msg

View File

@@ -1,86 +0,0 @@
module core
import freeflowuniverse.herolib.core.pathlib
import os
@[params]
pub struct SSHConfig {
pub:
directory string = os.join_path(os.home_dir(), '.ssh')
}
// Returns a specific SSH key with the given name from the default SSH directory (~/.ssh)
pub fn get_ssh_key(key_name string, config SSHConfig) ?SSHKey {
mut ssh_dir := pathlib.get_dir(path: config.directory) or { return none }
list := ssh_dir.list(files_only: true) or { return none }
for file in list.paths {
if file.name() == key_name {
return SSHKey{
name: file.name()
directory: ssh_dir.path
}
}
}
return none
}
// Lists SSH keys in the default SSH directory (~/.ssh) and returns an array of SSHKey structs
fn list_ssh_keys(config SSHConfig) ![]SSHKey {
mut ssh_dir := pathlib.get_dir(path: config.directory) or {
return error('Error getting ssh directory: ${err}')
}
mut keys := []SSHKey{}
list := ssh_dir.list(files_only: true) or {
return error('Failed to list files in SSH directory')
}
for file in list.paths {
if file.extension() == 'pub' || file.name().starts_with('id_') {
keys << SSHKey{
name: file.name()
directory: ssh_dir.path
}
}
}
return keys
}
// Creates a new SSH key pair to the specified directory
pub fn new_ssh_key(key_name string, config SSHConfig) !SSHKey {
ssh_dir := pathlib.get_dir(
path: config.directory
create: true
) or { return error('Error getting SSH directory: ${err}') }
// Paths for the private and public keys
priv_key_path := os.join_path(ssh_dir.path, key_name)
pub_key_path := '${priv_key_path}.pub'
// Check if the key already exists
if os.exists(priv_key_path) || os.exists(pub_key_path) {
return error("Key pair already exists with the name '${key_name}'")
}
panic('implement shhkeygen logic')
// Generate a random private key (for demonstration purposes)
// Replace this with actual key generation logic (e.g., calling `ssh-keygen` or similar)
// private_key_content := '-----BEGIN PRIVATE KEY-----\n${rand.string(64)}\n-----END PRIVATE KEY-----'
// public_key_content := 'ssh-rsa ${rand.string(64)} user@host'
// Save the keys to their respective files
// os.write_file(priv_key_path, private_key_content) or {
// return error("Failed to write private key: ${err}")
// }
// os.write_file(pub_key_path, public_key_content) or {
// return error("Failed to write public key: ${err}")
// }
return SSHKey{
name: key_name
directory: ssh_dir.path
}
}

View File

@@ -39,3 +39,91 @@ pub fn (key SSHKey) private_key() !string {
content := path.read()!
return content
}
module core
import freeflowuniverse.herolib.core.pathlib
import os
@[params]
pub struct SSHConfig {
pub:
directory string = os.join_path(os.home_dir(), '.ssh')
}
// Returns a specific SSH key with the given name from the default SSH directory (~/.ssh)
pub fn get_ssh_key(key_name string, config SSHConfig) ?SSHKey {
mut ssh_dir := pathlib.get_dir(path: config.directory) or { return none }
list := ssh_dir.list(files_only: true) or { return none }
for file in list.paths {
if file.name() == key_name {
return SSHKey{
name: file.name()
directory: ssh_dir.path
}
}
}
return none
}
// Lists SSH keys in the default SSH directory (~/.ssh) and returns an array of SSHKey structs
fn list_ssh_keys(config SSHConfig) ![]SSHKey {
mut ssh_dir := pathlib.get_dir(path: config.directory) or {
return error('Error getting ssh directory: ${err}')
}
mut keys := []SSHKey{}
list := ssh_dir.list(files_only: true) or {
return error('Failed to list files in SSH directory')
}
for file in list.paths {
if file.extension() == 'pub' || file.name().starts_with('id_') {
keys << SSHKey{
name: file.name()
directory: ssh_dir.path
}
}
}
return keys
}
// Creates a new SSH key pair to the specified directory
pub fn new_ssh_key(key_name string, config SSHConfig) !SSHKey {
ssh_dir := pathlib.get_dir(
path: config.directory
create: true
) or { return error('Error getting SSH directory: ${err}') }
// Paths for the private and public keys
priv_key_path := os.join_path(ssh_dir.path, key_name)
pub_key_path := '${priv_key_path}.pub'
// Check if the key already exists
if os.exists(priv_key_path) || os.exists(pub_key_path) {
return error("Key pair already exists with the name '${key_name}'")
}
panic('implement shhkeygen logic')
// Generate a random private key (for demonstration purposes)
// Replace this with actual key generation logic (e.g., calling `ssh-keygen` or similar)
// private_key_content := '-----BEGIN PRIVATE KEY-----\n${rand.string(64)}\n-----END PRIVATE KEY-----'
// public_key_content := 'ssh-rsa ${rand.string(64)} user@host'
// Save the keys to their respective files
// os.write_file(priv_key_path, private_key_content) or {
// return error("Failed to write private key: ${err}")
// }
// os.write_file(pub_key_path, public_key_content) or {
// return error("Failed to write public key: ${err}")
// }
return SSHKey{
name: key_name
directory: ssh_dir.path
}
}

View File

@@ -2,6 +2,80 @@
This module provides functionality for managing DNS records in Redis for use with CoreDNS. It supports various DNS record types and provides a simple interface for adding and managing DNS records.
## Heroscript Examples
The following examples demonstrate how to define DNS records using heroscript actions:
### A Record
```
!!dns.a_record
sub_domain: 'host1'
ip: '1.2.3.4'
ttl: 300
```
### AAAA Record
```
!!dns.aaaa_record
sub_domain: 'host1'
ip: '2001:db8::1'
ttl: 300
```
### MX Record
```
!!dns.mx_record
sub_domain: '*'
host: 'mail.example.com'
preference: 10
ttl: 300
```
### TXT Record
```
!!dns.txt_record
sub_domain: '*'
text: 'v=spf1 mx ~all'
ttl: 300
```
### SRV Record
```
!!dns.srv_record
service: 'ssh'
protocol: 'tcp'
host: 'host1'
target: 'sip.example.com'
port: 5060
priority: 10
weight: 100
ttl: 300
```
### NS Record
```
!!dns.ns_record
sub_domain: '@'
host: 'ns1.example.com'
ttl: 300
```
### SOA Record
```
!!dns.soa_record
mbox: 'hostmaster.example.com'
ns: 'ns1.example.com'
refresh: 44
retry: 55
expire: 66
minttl: 100
ttl: 300
```
## v
```v
import freeflowuniverse.herolib.osal.core.coredns
@@ -93,3 +167,5 @@ SOARecord {
ttl int // Default: 300
}
```

29
lib/osal/linux/factory.v Normal file
View File

@@ -0,0 +1,29 @@
module linux
// import freeflowuniverse.herolib.osal.core as osal
import freeflowuniverse.herolib.core.texttools
// import freeflowuniverse.herolib.screen
import os
import time
// import freeflowuniverse.herolib.ui.console
import freeflowuniverse.herolib.osal.core as osal
@[heap]
pub struct LinuxFactory {
pub mut:
username string
}
@[params]
pub struct LinuxNewArgs {
pub:
username string
}
// return screen instance
pub fn new(args LinuxNewArgs) !LinuxFactory {
mut t := LinuxFactory{
username: args.username
}
return t
}

94
lib/osal/linux/play.v Normal file
View File

@@ -0,0 +1,94 @@
module linux
import freeflowuniverse.herolib.core.playbook { PlayBook }
pub fn play(mut plbook PlayBook) ! {
if !plbook.exists(filter: 'usermgmt.') {
return
}
mut lf := new()!
// Process user_create actions
play_user_create(mut plbook, mut lf)!
// Process user_delete actions
play_user_delete(mut plbook, mut lf)!
// Process sshkey_create actions
play_sshkey_create(mut plbook, mut lf)!
// Process sshkey_delete actions
play_sshkey_delete(mut plbook, mut lf)!
}
fn play_user_create(mut plbook PlayBook, mut lf LinuxFactory) ! {
mut actions := plbook.find(filter: 'usermgmt.user_create')!
for mut action in actions {
mut p := action.params
mut args := UserCreateArgs{
name: p.get('name')!
giteakey: p.get_default('giteakey', '')!
giteaurl: p.get_default('giteaurl', '')!
passwd: p.get_default('passwd', '')!
description: p.get_default('description', '')!
email: p.get_default('email', '')!
tel: p.get_default('tel', '')!
sshkey: p.get_default('sshkey', '')! // SSH public key
}
lf.user_create(args)!
action.done = true
}
}
fn play_user_delete(mut plbook PlayBook, mut lf LinuxFactory) ! {
mut actions := plbook.find(filter: 'usermgmt.user_delete')!
for mut action in actions {
mut p := action.params
mut args := UserDeleteArgs{
name: p.get('name')!
}
lf.user_delete(args)!
action.done = true
}
}
fn play_sshkey_create(mut plbook PlayBook, mut lf LinuxFactory) ! {
mut actions := plbook.find(filter: 'usermgmt.sshkey_create')!
for mut action in actions {
mut p := action.params
mut args := SSHKeyCreateArgs{
username: p.get('username')!
sshkey_name: p.get('sshkey_name')!
sshkey_pub: p.get_default('sshkey_pub', '')!
sshkey_priv: p.get_default('sshkey_priv', '')!
}
lf.sshkey_create(args)!
action.done = true
}
}
fn play_sshkey_delete(mut plbook PlayBook, mut lf LinuxFactory) ! {
mut actions := plbook.find(filter: 'usermgmt.sshkey_delete')!
for mut action in actions {
mut p := action.params
mut args := SSHKeyDeleteArgs{
username: p.get('username')!
sshkey_name: p.get('sshkey_name')!
}
lf.sshkey_delete(args)!
action.done = true
}
}

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "❌ Must be run as root"
exit 1
fi
# --- ask for username ---
read -rp "Enter username to create: " NEWUSER
# --- ask for SSH public key ---
read -rp "Enter SSH public key (or path to pubkey file): " PUBKEYINPUT
if [ -f "$PUBKEYINPUT" ]; then
PUBKEY="$(cat "$PUBKEYINPUT")"
else
PUBKEY="$PUBKEYINPUT"
fi
# --- ensure user exists ---
if id "$NEWUSER" >/dev/null 2>&1; then
echo "✅ User $NEWUSER already exists"
else
echo " Creating user $NEWUSER"
useradd -m -s /bin/bash "$NEWUSER"
fi
USERHOME=$(eval echo "~$NEWUSER")
# --- setup SSH authorized_keys ---
mkdir -p "$USERHOME/.ssh"
chmod 700 "$USERHOME/.ssh"
echo "$PUBKEY" > "$USERHOME/.ssh/authorized_keys"
chmod 600 "$USERHOME/.ssh/authorized_keys"
chown -R "$NEWUSER":"$NEWUSER" "$USERHOME/.ssh"
echo "✅ SSH key installed for $NEWUSER"
# --- ensure ourworld group exists ---
if getent group ourworld >/dev/null 2>&1; then
echo "✅ Group 'ourworld' exists"
else
echo " Creating group 'ourworld'"
groupadd ourworld
fi
# --- add user to group ---
if id -nG "$NEWUSER" | grep -qw ourworld; then
echo "$NEWUSER already in 'ourworld'"
else
usermod -aG ourworld "$NEWUSER"
echo "✅ Added $NEWUSER to 'ourworld' group"
fi
# --- setup /code ---
mkdir -p /code
chown root:ourworld /code
chmod 2775 /code # rwx for user+group, SGID bit so new files inherit group
echo "✅ /code prepared (group=ourworld, rwx for group, SGID bit set)"
# --- create login helper script for ssh-agent ---
PROFILE_SCRIPT="$USERHOME/.profile_sshagent"
cat > "$PROFILE_SCRIPT" <<'EOF'
# Auto-start ssh-agent if not running
SSH_AGENT_PID_FILE="$HOME/.ssh/agent.pid"
SSH_AUTH_SOCK_FILE="$HOME/.ssh/agent.sock"
chown "$NEWUSER":"$NEWUSER" "$PROFILE_SCRIPT"
chmod 644 "$PROFILE_SCRIPT"
# --- source it on login ---
if ! grep -q ".profile_sshagent" "$USERHOME/.bashrc"; then
echo "[ -f ~/.profile_sshagent ] && source ~/.profile_sshagent" >> "$USERHOME/.bashrc"
fi
echo "🎉 Setup complete for user $NEWUSER"

354
lib/osal/linux/user_mgmt.v Normal file
View File

@@ -0,0 +1,354 @@
module linux
import os
import json
import freeflowuniverse.herolib.core.pathlib
import freeflowuniverse.herolib.osal.core as osal
import freeflowuniverse.herolib.ui.console
@[params]
pub struct UserCreateArgs {
pub mut:
name string @[required]
giteakey string
giteaurl string
passwd string
description string
email string
tel string
sshkey string // SSH public key
}
@[params]
pub struct UserDeleteArgs {
pub mut:
name string @[required]
}
@[params]
pub struct SSHKeyCreateArgs {
pub mut:
username string @[required]
sshkey_name string @[required]
sshkey_pub string
sshkey_priv string
}
@[params]
pub struct SSHKeyDeleteArgs {
pub mut:
username string @[required]
sshkey_name string @[required]
}
struct UserConfig {
pub mut:
name string
giteakey string
giteaurl string
email string
description string
tel string
}
// Check if running as root
pub fn (mut lf LinuxFactory) check_root() ! {
if os.getuid() != 0 {
return error(' Must be run as root')
}
}
// Create a new user with all the configuration
pub fn (mut lf LinuxFactory) user_create(args UserCreateArgs) ! {
lf.check_root()!
console.print_header('Creating user: ${args.name}')
// Save config to ~/hero/cfg/myconfig.json
lf.save_user_config(args)!
// Create user using system commands
lf.create_user_system(args)!
}
// Delete a user
pub fn (mut lf LinuxFactory) user_delete(args UserDeleteArgs) ! {
lf.check_root()!
console.print_header('Deleting user: ${args.name}')
// Check if user exists
if !osal.user_exists(args.name) {
return error('User ${args.name} does not exist')
}
// Delete user and home directory
osal.exec(cmd: 'userdel -r ${args.name}')!
console.print_green(' User ${args.name} deleted')
// Remove from config
lf.remove_user_config(args.name)!
}
// Create SSH key for user
pub fn (mut lf LinuxFactory) sshkey_create(args SSHKeyCreateArgs) ! {
lf.check_root()!
console.print_header('Creating SSH key for user: ${args.username}')
user_home := '/home/${args.username}'
ssh_dir := '${user_home}/.ssh'
// Ensure SSH directory exists
osal.dir_ensure(ssh_dir)!
osal.exec(cmd: 'chmod 700 ${ssh_dir}')!
if args.sshkey_priv != '' && args.sshkey_pub != '' {
// Both private and public keys provided
priv_path := '${ssh_dir}/${args.sshkey_name}'
pub_path := '${ssh_dir}/${args.sshkey_name}.pub'
osal.file_write(priv_path, args.sshkey_priv)!
osal.file_write(pub_path, args.sshkey_pub)!
// Set permissions
osal.exec(cmd: 'chmod 600 ${priv_path}')!
osal.exec(cmd: 'chmod 644 ${pub_path}')!
console.print_green(' SSH keys installed for ${args.username}')
} else {
// Generate new SSH key (modern ed25519)
key_path := '${ssh_dir}/${args.sshkey_name}'
osal.exec(cmd: 'ssh-keygen -t ed25519 -f ${key_path} -N "" -C "${args.username}@$(hostname)"')!
console.print_green(' New SSH key generated for ${args.username}')
}
// Set ownership
osal.exec(cmd: 'chown -R ${args.username}:${args.username} ${ssh_dir}')!
}
// Delete SSH key for user
pub fn (mut lf LinuxFactory) sshkey_delete(args SSHKeyDeleteArgs) ! {
lf.check_root()!
console.print_header('Deleting SSH key for user: ${args.username}')
user_home := '/home/${args.username}'
ssh_dir := '${user_home}/.ssh'
priv_path := '${ssh_dir}/${args.sshkey_name}'
pub_path := '${ssh_dir}/${args.sshkey_name}.pub'
// Remove keys if they exist
if os.exists(priv_path) {
os.rm(priv_path)!
console.print_green(' Removed private key: ${priv_path}')
}
if os.exists(pub_path) {
os.rm(pub_path)!
console.print_green(' Removed public key: ${pub_path}')
}
}
// Save user configuration to JSON file
fn (mut lf LinuxFactory) save_user_config(args UserCreateArgs) ! {
config_dir := '${os.home_dir()}/hero/cfg'
osal.dir_ensure(config_dir)!
config_path := '${config_dir}/myconfig.json'
mut configs := []UserConfig{}
// Load existing configs if file exists
if os.exists(config_path) {
content := osal.file_read(config_path)!
configs = json.decode([]UserConfig, content) or { []UserConfig{} }
}
// Check if user already exists in config
mut found_idx := -1
for i, config in configs {
if config.name == args.name {
found_idx = i
break
}
}
new_config := UserConfig{
name: args.name
giteakey: args.giteakey
giteaurl: args.giteaurl
email: args.email
description: args.description
tel: args.tel
}
if found_idx >= 0 {
configs[found_idx] = new_config
} else {
configs << new_config
}
// Save updated configs
content := json.encode_pretty(configs)
osal.file_write(config_path, content)!
console.print_green(' User config saved to ${config_path}')
}
// Remove user from configuration
fn (mut lf LinuxFactory) remove_user_config(username string) ! {
config_dir := '${os.home_dir()}/hero/cfg'
config_path := '${config_dir}/myconfig.json'
if !os.exists(config_path) {
return // Nothing to remove
}
content := osal.file_read(config_path)!
mut configs := json.decode([]UserConfig, content) or { return }
// Filter out the user
configs = configs.filter(it.name != username)
// Save updated configs
updated_content := json.encode_pretty(configs)
osal.file_write(config_path, updated_content)!
console.print_green(' User config removed for ${username}')
}
// Create user in the system
fn (mut lf LinuxFactory) create_user_system(args UserCreateArgs) ! {
// Check if user exists
if osal.user_exists(args.name) {
console.print_green(' User ${args.name} already exists')
} else {
console.print_item(' Creating user ${args.name}')
osal.exec(cmd: 'useradd -m -s /bin/bash ${args.name}')!
}
user_home := '/home/${args.name}'
// Setup SSH if key provided
if args.sshkey != '' {
ssh_dir := '${user_home}/.ssh'
osal.dir_ensure(ssh_dir)!
osal.exec(cmd: 'chmod 700 ${ssh_dir}')!
authorized_keys := '${ssh_dir}/authorized_keys'
osal.file_write(authorized_keys, args.sshkey)!
osal.exec(cmd: 'chmod 600 ${authorized_keys}')!
osal.exec(cmd: 'chown -R ${args.name}:${args.name} ${ssh_dir}')!
console.print_green(' SSH key installed for ${args.name}')
}
// Ensure ourworld group exists
group_check := osal.exec(cmd: 'getent group ourworld', raise_error: false) or {
osal.Job{ exit_code: 1 }
}
if group_check.exit_code != 0 {
console.print_item(' Creating group ourworld')
osal.exec(cmd: 'groupadd ourworld')!
} else {
console.print_green(' Group ourworld exists')
}
// Add user to group
user_groups := osal.exec(cmd: 'id -nG ${args.name}', stdout: false)!
if !user_groups.output.contains('ourworld') {
osal.exec(cmd: 'usermod -aG ourworld ${args.name}')!
console.print_green(' Added ${args.name} to ourworld group')
} else {
console.print_green(' ${args.name} already in ourworld')
}
// Setup /code directory
osal.dir_ensure('/code')!
osal.exec(cmd: 'chown root:ourworld /code')!
osal.exec(cmd: 'chmod 2775 /code')! // rwx for user+group, SGID bit
console.print_green(' /code prepared (group=ourworld, rwx for group, SGID bit set)')
// Create SSH agent profile script
lf.create_ssh_agent_profile(args.name)!
// Set password if provided
if args.passwd != '' {
osal.exec(cmd: 'echo "${args.name}:${args.passwd}" | chpasswd')!
console.print_green(' Password set for ${args.name}')
}
console.print_header('🎉 Setup complete for user ${args.name}')
}
// Create SSH agent profile script
fn (mut lf LinuxFactory) create_ssh_agent_profile(username string) ! {
user_home := '/home/${username}'
profile_script := '${user_home}/.profile_sshagent'
script_content := '# Auto-start ssh-agent if not running
SSH_AGENT_PID_FILE="$HOME/.ssh/agent.pid"
SSH_AUTH_SOCK_FILE="$HOME/.ssh/agent.sock"
# Function to start ssh-agent
start_ssh_agent() {
mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"
# Start ssh-agent and save connection info
ssh-agent -s > "$SSH_AGENT_PID_FILE"
source "$SSH_AGENT_PID_FILE"
# Save socket path for future sessions
echo "$SSH_AUTH_SOCK" > "$SSH_AUTH_SOCK_FILE"
# Load all private keys found in ~/.ssh
if [ -d "$HOME/.ssh" ]; then
for KEY in "$HOME"/.ssh/*; do
if [ -f "$KEY" ] && [ ! "${KEY##*.}" = "pub" ] && grep -q "PRIVATE KEY" "$KEY" 2>/dev/null; then
'ssh-' + 'add "$KEY" >/dev/null 2>&1 && echo "🔑 Loaded key: $(basename $KEY)"'
fi
done
fi
}
# Check if ssh-agent is running
if [ -f "$SSH_AGENT_PID_FILE" ]; then
source "$SSH_AGENT_PID_FILE" >/dev/null 2>&1
# Test if agent is responsive
if ! ('ssh-' + 'add -l >/dev/null 2>&1'); then
start_ssh_agent
else
# Agent is running, restore socket path
if [ -f "$SSH_AUTH_SOCK_FILE" ]; then
export SSH_AUTH_SOCK=$(cat "$SSH_AUTH_SOCK_FILE")
fi
fi
else
start_ssh_agent
fi
# For interactive shells
if [[ $- == *i* ]]; then
echo "🔑 SSH Agent ready at $SSH_AUTH_SOCK"
# Show loaded keys
KEY_COUNT=$('ssh-' + 'add -l 2>/dev/null | wc -l')
if [ "$KEY_COUNT" -gt 0 ]; then
echo "🔑 $KEY_COUNT SSH key(s) loaded"
fi
fi
'
osal.file_write(profile_script, script_content)!
osal.exec(cmd: 'chown ${username}:${username} ${profile_script}')!
osal.exec(cmd: 'chmod 644 ${profile_script}')!
// Source it on login
bashrc := '${user_home}/.bashrc'
bashrc_content := if os.exists(bashrc) { osal.file_read(bashrc)! } else { '' }
if !bashrc_content.contains('.profile_sshagent') {
source_line := '[ -f ~/.profile_sshagent ] && source ~/.profile_sshagent\n'
osal.file_write(bashrc, bashrc_content + source_line)!
}
console.print_green(' SSH agent profile created for ${username}')
}

211
lib/osal/sshagent/agent.v Normal file
View File

@@ -0,0 +1,211 @@
module sshagent
// Check if SSH agent is properly configured and all is good
fn agent_check(mut agent SSHAgent) ! {
console.print_header('SSH Agent Check')
// Ensure single agent is running
agent.ensure_single_agent()!
// Get diagnostics
diag := agent.diagnostics()
for key, value in diag {
console.print_item('${key}: ${value}')
}
// Verify agent is responsive
if !agent.is_agent_responsive() {
return error('SSH agent is not responsive')
}
// Load all existing keys from ~/.ssh that aren't loaded yet
agent.init()!
console.print_green(' SSH Agent is properly configured and running')
// Show loaded keys
loaded_keys := agent.keys_loaded()!
console.print_item('Loaded keys: ${loaded_keys.len}')
for key in loaded_keys {
console.print_item(' - ${key.name} (${key.cat})')
}
}
// Create a new SSH key
fn sshkey_create(mut agent SSHAgent, name string, passphrase string) ! {
console.print_header('Creating SSH key: ${name}')
// Check if key already exists
if agent.exists(name: name) {
console.print_debug('SSH key "${name}" already exists')
return
}
// Generate new key
mut key := agent.generate(name, passphrase)!
console.print_green(' SSH key "${name}" created successfully')
// Automatically load the key
key.load()!
console.print_green(' SSH key "${name}" loaded into agent')
}
// Delete an SSH key
fn sshkey_delete(mut agent SSHAgent, name string) ! {
console.print_header('Deleting SSH key: ${name}')
// Check if key exists
mut key := agent.get(name: name) or {
console.print_debug('SSH key "${name}" does not exist')
return
}
// Get key paths before deletion
key_path := key.keypath() or {
console.print_debug('Private key path not available for "${name}"')
key.keypath_pub() or { return } // Just to trigger the path lookup
}
key_pub_path := key.keypath_pub() or {
console.print_debug('Public key path not available for "${name}"')
return
}
// Remove from agent if loaded (temporarily disabled due to reset_ssh panic)
// if key.loaded {
// key.forget()!
// }
// Delete key files
if key_path.exists() {
key_path.delete()!
console.print_debug('Deleted private key: ${key_path.path}')
}
if key_pub_path.exists() {
key_pub_path.delete()!
console.print_debug('Deleted public key: ${key_pub_path.path}')
}
// Reinitialize agent to update key list
agent.init()!
console.print_green(' SSH key "${name}" deleted successfully')
}
// Load SSH key into agent
fn sshkey_load(mut agent SSHAgent, name string) ! {
console.print_header('Loading SSH key: ${name}')
mut key := agent.get(name: name) or {
return error('SSH key "${name}" not found')
}
if key.loaded {
console.print_debug('SSH key "${name}" is already loaded')
return
}
key.load()!
console.print_green(' SSH key "${name}" loaded into agent')
}
// Check if SSH key is valid
fn sshkey_check(mut agent SSHAgent, name string) ! {
console.print_header('Checking SSH key: ${name}')
mut key := agent.get(name: name) or {
return error('SSH key "${name}" not found')
}
// Check if key files exist
key_path := key.keypath() or {
return error('Private key file not found for "${name}"')
}
key_pub_path := key.keypath_pub() or {
return error('Public key file not found for "${name}"')
}
if !key_path.exists() {
return error('Private key file does not exist: ${key_path.path}')
}
if !key_pub_path.exists() {
return error('Public key file does not exist: ${key_pub_path.path}')
}
// Verify key can be loaded (if not already loaded)
if !key.loaded {
// Test load without actually loading (since forget is disabled)
key_content := key_path.read()!
if !key_content.contains('PRIVATE KEY') {
return error('Invalid private key format in "${name}"')
}
}
console.print_item('Key type: ${key.cat}')
console.print_item('Loaded: ${key.loaded}')
console.print_item('Email: ${key.email}')
console.print_item('Private key: ${key_path.path}')
console.print_item('Public key: ${key_pub_path.path}')
console.print_green(' SSH key "${name}" is valid')
}
// Copy private key to remote node
fn remote_copy(mut agent SSHAgent, node_addr string, key_name string) ! {
console.print_header('Copying SSH key "${key_name}" to ${node_addr}')
// Get the key
mut key := agent.get(name: key_name) or {
return error('SSH key "${key_name}" not found')
}
// Create builder node
mut b := builder.new()!
mut node := b.node_new(ipaddr: node_addr)!
// Get private key content
key_path := key.keypath()!
if !key_path.exists() {
return error('Private key file not found: ${key_path.path}')
}
private_key_content := key_path.read()!
// Get home directory on remote
home_dir := node.environ_get()!['HOME'] or {
return error('Could not determine HOME directory on remote node')
}
remote_ssh_dir := '${home_dir}/.ssh'
remote_key_path := '${remote_ssh_dir}/${key_name}'
// Ensure .ssh directory exists with correct permissions
node.exec_silent('mkdir -p ${remote_ssh_dir}')!
node.exec_silent('chmod 700 ${remote_ssh_dir}')!
// Copy private key to remote
node.file_write(remote_key_path, private_key_content)!
node.exec_silent('chmod 600 ${remote_key_path}')!
// Generate public key on remote
node.exec_silent('ssh-keygen -y -f ${remote_key_path} > ${remote_key_path}.pub')!
node.exec_silent('chmod 644 ${remote_key_path}.pub')!
console.print_green(' SSH key "${key_name}" copied to ${node_addr}')
}
// Add public key to authorized_keys on remote node
fn remote_auth(mut agent SSHAgent, node_addr string, key_name string) ! {
console.print_header('Adding SSH key "${key_name}" to authorized_keys on ${node_addr}')
// Create builder node
mut b := builder.new()!
mut node := b.node_new(ipaddr: node_addr)!
// Use existing builder integration
agent.push_key_to_node(mut node, key_name)!
console.print_green(' SSH key "${key_name}" added to authorized_keys on ${node_addr}')
}

View File

@@ -0,0 +1,100 @@
module sshagent
import freeflowuniverse.herolib.builder
import freeflowuniverse.herolib.ui.console
// push SSH public key to a remote node's authorized_keys
pub fn (mut agent SSHAgent) push_key_to_node(mut node builder.Node, key_name string) ! {
// Verify this is an SSH node
node_info := node.info()
if node_info['category'] != 'ssh' {
return error('Can only push keys to SSH nodes, got: ${node_info['category']}')
}
// Find the key
mut key := agent.get(name: key_name) or {
return error('SSH key "${key_name}" not found in agent')
}
// Get public key content
pubkey_content := key.keypub()!
// Check if authorized_keys file exists on remote
home_dir := node.environ_get()!['HOME'] or {
return error('Could not determine HOME directory on remote node')
}
ssh_dir := '${home_dir}/.ssh'
authorized_keys_path := '${ssh_dir}/authorized_keys'
// Ensure .ssh directory exists with correct permissions
node.exec_silent('mkdir -p ${ssh_dir}')!
node.exec_silent('chmod 700 ${ssh_dir}')!
// Check if key already exists
if node.file_exists(authorized_keys_path) {
existing_keys := node.file_read(authorized_keys_path)!
if existing_keys.contains(pubkey_content.trim_space()) {
console.print_debug('SSH key already exists on remote node')
return
}
}
// Add key to authorized_keys
node.exec_silent('echo "${pubkey_content}" >> ${authorized_keys_path}')!
node.exec_silent('chmod 600 ${authorized_keys_path}')!
console.print_debug('SSH key "${key_name}" successfully pushed to node')
}
// remove SSH public key from a remote node's authorized_keys
pub fn (mut agent SSHAgent) remove_key_from_node(mut node builder.Node, key_name string) ! {
// Verify this is an SSH node
node_info := node.info()
if node_info['category'] != 'ssh' {
return error('Can only remove keys from SSH nodes, got: ${node_info['category']}')
}
// Find the key
mut key := agent.get(name: key_name) or {
return error('SSH key "${key_name}" not found in agent')
}
// Get public key content
pubkey_content := key.keypub()!
// Get authorized_keys path
home_dir := node.environ_get()!['HOME'] or {
return error('Could not determine HOME directory on remote node')
}
authorized_keys_path := '${home_dir}/.ssh/authorized_keys'
if !node.file_exists(authorized_keys_path) {
console.print_debug('authorized_keys file does not exist on remote node')
return
}
// Remove the key line from authorized_keys
escaped_key := pubkey_content.replace('/', '\\/')
node.exec_silent('sed -i "\\|${escaped_key}|d" ${authorized_keys_path}')!
console.print_debug('SSH key "${key_name}" removed from remote node')
}
// verify SSH key access to remote node
pub fn (mut agent SSHAgent) verify_key_access(mut node builder.Node, key_name string) !bool {
// This would attempt to connect with the specific key
// For now, we'll do a simple connectivity test
node_info := node.info()
if node_info['category'] != 'ssh' {
return error('Can only verify access to SSH nodes')
}
// Test basic connectivity
result := node.exec_silent('echo "SSH key verification successful"') or {
return false
}
return result.contains('SSH key verification successful')
}

View File

@@ -30,3 +30,16 @@ pub fn loaded() bool {
mut agent := new() or { panic(err) }
return agent.active
}
// create new SSH agent with single instance guarantee
pub fn new_single(args_ SSHAgentNewArgs) !SSHAgent {
mut agent := new(args_)!
agent.ensure_single_agent()!
return agent
}
// check if SSH agent is properly configured and running
pub fn agent_status() !map[string]string {
mut agent := new()!
return agent.diagnostics()
}

View File

@@ -1,128 +0,0 @@
module sshagent
// import freeflowuniverse.herolib.ui.console
// will see if there is one ssh key in sshagent
// or if not, if there is 1 ssh key in ${agent.homepath.path}/ if yes will load
// if we were able to define the key to use, it will be returned here
// will return the key which will be used
// pub fn load_interactive() ! {
// mut pubkeys := pubkeys_get()
// mut c := console.UIConsole{}
// pubkeys.map(listsplit)
// if pubkeys.len == 1 {
// c.ask_yesno(
// description: 'We found sshkey ${pubkeys[0]} in sshagent, want to use this one?'
// )!
// {
// key_load(pubkeys[0])!
// return pubkeys[0]
// }
// }
// if pubkeys.len > 1 {
// if c.ask_yesno(
// description: 'We found more than 1 sshkey in sshagent, want to use one of those!'
// )!
// {
// // keytouse := console.ask_dropdown(
// // items: pubkeys
// // description: 'Please choose the ssh key you want to use'
// // )
// // key_load(keytouse)!
// // return keytouse
// }
// }
// // now means nothing in ssh-agent, lets see if we find 1 key in .ssh directory
// mut sshdirpath := pathlib.get_dir(path: '${os.home_dir()}/.ssh', create: true)!
// mut pubkeys := []string{}
// pl := sshdirpath.list(recursive: false)!
// for p in pl.paths {
// if p.path.ends_with('.pub') {
// pubkeys << p.path.replace('.pub', '')
// }
// }
// // console.print_debug(keypaths)
// if pubkeys.len == 1 {
// if c.ask_yesno(
// description: 'We found sshkey ${pubkeys[0]} in ${agent.homepath.path} dir, want to use this one?'
// )!
// {
// key_load(pubkeys[0])!
// return pubkeys[0]
// }
// }
// if pubkeys.len > 1 {
// if c.ask_yesno(
// description: 'We found more than 1 sshkey in ${agent.homepath.path} dir, want to use one of those?'
// )!
// {
// // keytouse := console.ask_dropdown(
// // items: pubkeys
// // description: 'Please choose the ssh key you want to use'
// // )
// // key_load(keytouse)!
// // return keytouse
// }
// }
// will see if there is one ssh key in sshagent
// or if not, if there is 1 ssh key in ${agent.homepath.path}/ if yes will return
// if we were able to define the key to use, it will be returned here
// pub fn pubkey_guess() !string {
// pubkeys := pubkeys_get()
// if pubkeys.len == 1 {
// return pubkeys[0]
// }
// if pubkeys.len > 1 {
// return error('There is more than 1 ssh-key loaded in ssh-agent, cannot identify which one to use.')
// }
// // now means nothing in ssh-agent, lets see if we find 1 key in .ssh directory
// mut sshdirpath := pathlib.get_dir(path: '${os.home_dir()}/.ssh', create: true)!
// // todo: use ourregex field to nly list .pub files
// mut fl := sshdirpath.list()!
// mut sshfiles := fl.paths
// mut keypaths := sshfiles.filter(it.path.ends_with('.pub'))
// // console.print_debug(keypaths)
// if keypaths.len == 1 {
// keycontent := keypaths[0].read()!
// privkeypath := keypaths[0].path.replace('.pub', '')
// key_load(privkeypath)!
// return keycontent
// }
// if keypaths.len > 1 {
// return error('There is more than 1 ssh-key in your ${agent.homepath.path} dir, could not automatically load.')
// }
// return error('Could not find sshkey in your ssh-agent as well as in your ${agent.homepath.path} dir, please generate an ssh-key')
// }
// if c.ask_yesno(description: 'Would you like to generate a new key?') {
// // name := console.ask_question(question: 'name', minlen: 3)
// // passphrase := console.ask_question(question: 'passphrase', minlen: 5)
// // keytouse := key_generate(name, passphrase)!
// // if console.ask_yesno(description:"Please acknowledge you will remember your passphrase for ever (-: ?"){
// // key_load(keytouse)?
// // return keytouse
// // }else{
// // return error("Cannot continue, did not find sshkey to use")
// // }
// // key_load_with_passphrase(keytouse, passphrase)!
// }!
// return error('Cannot continue, did not find sshkey to use')
// // url_github_add := "https://library.threefold.me/info/publishtools/#/sshkey_github"
// // osal.execute_interactive("open $url_github_add")?
// // if console.ask_yesno(description:"Did you manage to add the github key to this repo ?"){
// // console.print_debug(" - CONGRATS: your sshkey is now loaded.")
// // }
// // return keytouse
// }

84
lib/osal/sshagent/play.v Normal file
View File

@@ -0,0 +1,84 @@
module sshagent
import freeflowuniverse.herolib.core.playbook { PlayBook }
import freeflowuniverse.herolib.ui.console
import freeflowuniverse.herolib.builder
pub fn play(mut plbook PlayBook) ! {
if !plbook.exists(filter: 'sshagent.') {
return
}
// Get or create a single SSH agent instance
mut agent := new_single()!
// Process sshagent.check actions
mut check_actions := plbook.find(filter: 'sshagent.check')!
for mut action in check_actions {
agent_check(mut agent)!
action.done = true
}
// Process sshagent.sshkey_create actions
mut create_actions := plbook.find(filter: 'sshagent.sshkey_create')!
for mut action in create_actions {
mut p := action.params
name := p.get('name')!
passphrase := p.get_default('passphrase', '')!
sshkey_create(mut agent, name, passphrase)!
action.done = true
}
// Process sshagent.sshkey_delete actions
mut delete_actions := plbook.find(filter: 'sshagent.sshkey_delete')!
for mut action in delete_actions {
mut p := action.params
name := p.get('name')!
sshkey_delete(mut agent, name)!
action.done = true
}
// Process sshagent.sshkey_load actions
mut load_actions := plbook.find(filter: 'sshagent.sshkey_load')!
for mut action in load_actions {
mut p := action.params
name := p.get('name')!
sshkey_load(mut agent, name)!
action.done = true
}
// Process sshagent.sshkey_check actions
mut check_key_actions := plbook.find(filter: 'sshagent.sshkey_check')!
for mut action in check_key_actions {
mut p := action.params
name := p.get('name')!
sshkey_check(mut agent, name)!
action.done = true
}
// Process sshagent.remote_copy actions
mut remote_copy_actions := plbook.find(filter: 'sshagent.remote_copy')!
for mut action in remote_copy_actions {
mut p := action.params
node_addr := p.get('node')!
key_name := p.get('name')!
remote_copy(mut agent, node_addr, key_name)!
action.done = true
}
// Process sshagent.remote_auth actions
mut remote_auth_actions := plbook.find(filter: 'sshagent.remote_auth')!
for mut action in remote_auth_actions {
mut p := action.params
node_addr := p.get('node')!
key_name := p.get('name')!
remote_auth(mut agent, node_addr, key_name)!
action.done = true
}
}

View File

@@ -15,7 +15,6 @@ FbJDzBkCJ5TDec1zGwOJAAAABWJvb2tz
-----END OPENSSH PRIVATE KEY-----
'
//make sure the name chose is same as original name of the key
mut sshkey:=agent.add("mykey:,privkey)!

View File

@@ -2,7 +2,7 @@ module sshagent
import os
import freeflowuniverse.herolib.core.pathlib
// import freeflowuniverse.herolib.ui.console
import freeflowuniverse.herolib.ui.console
@[heap]
pub struct SSHAgent {
@@ -12,9 +12,139 @@ pub mut:
homepath pathlib.Path
}
// ensure only one SSH agent is running for the current user
pub fn (mut agent SSHAgent) ensure_single_agent() ! {
user := os.getenv('USER')
socket_path := get_agent_socket_path(user)
// Check if we have a valid agent already
if agent.is_agent_responsive() {
console.print_debug('SSH agent already running and responsive')
return
}
// Kill any orphaned agents
agent.cleanup_orphaned_agents()!
// Start new agent with consistent socket
agent.start_agent_with_socket(socket_path)!
// Set environment variables
os.setenv('SSH_AUTH_SOCK', socket_path, true)
agent.active = true
}
// get consistent socket path per user
fn get_agent_socket_path(user string) string {
return '/tmp/ssh-agent-${user}.sock'
}
// check if current agent is responsive
pub fn (mut agent SSHAgent) is_agent_responsive() bool {
if os.getenv('SSH_AUTH_SOCK') == '' {
return false
}
res := os.execute('ssh-add -l 2>/dev/null')
return res.exit_code == 0 || res.exit_code == 1 // 1 means no keys, but agent is running
}
// cleanup orphaned ssh-agent processes
pub fn (mut agent SSHAgent) cleanup_orphaned_agents() ! {
user := os.getenv('USER')
// Find ssh-agent processes for current user
res := os.execute('pgrep -u ${user} ssh-agent')
if res.exit_code == 0 && res.output.len > 0 {
pids := res.output.trim_space().split('\n')
for pid in pids {
if pid.trim_space() != '' {
// Check if this agent has a valid socket
if !agent.is_agent_pid_valid(pid.int()) {
console.print_debug('Killing orphaned ssh-agent PID: ${pid}')
os.execute('kill ${pid}')
}
}
}
}
}
// check if specific agent PID is valid and responsive
fn (mut agent SSHAgent) is_agent_pid_valid(pid int) bool {
// Try to find socket for this PID
res := os.execute('find /tmp -name "agent.*" -user ${os.getenv('USER')} 2>/dev/null | head -10')
if res.exit_code != 0 {
return false
}
for socket_path in res.output.split('\n') {
if socket_path.trim_space() != '' {
// Test if this socket responds
old_sock := os.getenv('SSH_AUTH_SOCK')
os.setenv('SSH_AUTH_SOCK', socket_path, true)
test_res := os.execute('ssh-add -l 2>/dev/null')
os.setenv('SSH_AUTH_SOCK', old_sock, true)
if test_res.exit_code == 0 || test_res.exit_code == 1 {
return true
}
}
}
return false
}
// start new ssh-agent with specific socket path
pub fn (mut agent SSHAgent) start_agent_with_socket(socket_path string) ! {
// Remove existing socket if it exists
if os.exists(socket_path) {
os.rm(socket_path)!
}
// Start ssh-agent with specific socket
cmd := 'ssh-agent -a ${socket_path}'
res := os.execute(cmd)
if res.exit_code != 0 {
return error('Failed to start ssh-agent: ${res.output}')
}
// Verify socket was created
if !os.exists(socket_path) {
return error('SSH agent socket was not created at ${socket_path}')
}
// Set environment variable
os.setenv('SSH_AUTH_SOCK', socket_path, true)
// Verify agent is responsive
if !agent.is_agent_responsive() {
return error('SSH agent started but is not responsive')
}
console.print_debug('SSH agent started with socket: ${socket_path}')
}
// get agent status and diagnostics
pub fn (mut agent SSHAgent) diagnostics() map[string]string {
mut diag := map[string]string{}
diag['socket_path'] = os.getenv('SSH_AUTH_SOCK')
diag['socket_exists'] = os.exists(diag['socket_path']).str()
diag['agent_responsive'] = agent.is_agent_responsive().str()
diag['loaded_keys_count'] = agent.keys.filter(it.loaded).len.str()
diag['total_keys_count'] = agent.keys.len.str()
// Count running ssh-agent processes
user := os.getenv('USER')
res := os.execute('pgrep -u ${user} ssh-agent | wc -l')
diag['agent_processes'] = if res.exit_code == 0 { res.output.trim_space() } else { '0' }
return diag
}
// get all keys from sshagent and from the local .ssh dir
pub fn (mut agent SSHAgent) init() ! {
// first get keys out of ssh-add
// first get keys out of ssh-add
agent.keys = []SSHKey{}
res := os.execute('ssh-add -L')
if res.exit_code == 0 {

194
lib/osal/tmux/play.v Normal file
View File

@@ -0,0 +1,194 @@
module tmux
import freeflowuniverse.herolib.core.playbook { PlayBook }
import freeflowuniverse.herolib.core.texttools
import freeflowuniverse.herolib.osal.core as osal
pub fn play(mut plbook PlayBook) ! {
if !plbook.exists(filter: 'tmux.') {
return
}
// Create tmux instance
mut tmux_instance := new()!
// Start tmux if not running
if !tmux_instance.is_running()! {
tmux_instance.start()!
}
play_session_create(mut plbook, mut tmux_instance)!
play_session_delete(mut plbook, mut tmux_instance)!
play_window_create(mut plbook, mut tmux_instance)!
play_window_delete(mut plbook, mut tmux_instance)!
play_pane_execute(mut plbook, mut tmux_instance)!
play_pane_kill(mut plbook, mut tmux_instance)!
// TODO: Implement pane_create, pane_delete, pane_split when pane API is extended
}
struct ParsedWindowName {
session string
window string
}
struct ParsedPaneName {
session string
window string
pane string
}
fn parse_window_name(name string) !ParsedWindowName {
parts := name.split('|')
if parts.len != 2 {
return error('Window name must be in format "session|window", got: ${name}')
}
return ParsedWindowName{
session: texttools.name_fix(parts[0])
window: texttools.name_fix(parts[1])
}
}
fn parse_pane_name(name string) !ParsedPaneName {
parts := name.split('|')
if parts.len != 3 {
return error('Pane name must be in format "session|window|pane", got: ${name}')
}
return ParsedPaneName{
session: texttools.name_fix(parts[0])
window: texttools.name_fix(parts[1])
pane: texttools.name_fix(parts[2])
}
}
fn play_session_create(mut plbook PlayBook, mut tmux_instance Tmux) ! {
mut actions := plbook.find(filter: 'tmux.session_create')!
for mut action in actions {
mut p := action.params
session_name := p.get('name')!
reset := p.get_default_false('reset')
tmux_instance.session_create(
name: session_name
reset: reset
)!
action.done = true
}
}
fn play_session_delete(mut plbook PlayBook, mut tmux_instance Tmux) ! {
mut actions := plbook.find(filter: 'tmux.session_delete')!
for mut action in actions {
mut p := action.params
session_name := p.get('name')!
tmux_instance.session_delete(session_name)!
action.done = true
}
}
fn play_window_create(mut plbook PlayBook, mut tmux_instance Tmux) ! {
mut actions := plbook.find(filter: 'tmux.window_create')!
for mut action in actions {
mut p := action.params
name := p.get('name')!
parsed := parse_window_name(name)!
cmd := p.get_default('cmd', '')!
reset := p.get_default_false('reset')
// Parse environment variables if provided
mut env := map[string]string{}
if env_str := p.get_default('env', '') {
// Parse env as comma-separated key=value pairs
env_pairs := env_str.split(',')
for pair in env_pairs {
kv := pair.split('=')
if kv.len == 2 {
env[kv[0].trim_space()] = kv[1].trim_space()
}
}
}
// Get or create session
mut session := if tmux_instance.session_exist(parsed.session) {
tmux_instance.session_get(parsed.session)!
} else {
tmux_instance.session_create(name: parsed.session)!
}
session.window_new(
name: parsed.window
cmd: cmd
env: env
reset: reset
)!
action.done = true
}
}
fn play_window_delete(mut plbook PlayBook, mut tmux_instance Tmux) ! {
mut actions := plbook.find(filter: 'tmux.window_delete')!
for mut action in actions {
mut p := action.params
name := p.get('name')!
parsed := parse_window_name(name)!
if tmux_instance.session_exist(parsed.session) {
mut session := tmux_instance.session_get(parsed.session)!
session.window_delete(name: parsed.window)!
}
action.done = true
}
}
fn play_pane_execute(mut plbook PlayBook, mut tmux_instance Tmux) ! {
mut actions := plbook.find(filter: 'tmux.pane_execute')!
for mut action in actions {
mut p := action.params
name := p.get('name')!
cmd := p.get('cmd')!
parsed := parse_pane_name(name)!
// Find the session and window
if tmux_instance.session_exist(parsed.session) {
mut session := tmux_instance.session_get(parsed.session)!
if session.window_exist(name: parsed.window) {
mut window := session.window_get(name: parsed.window)!
// Send command to the window (goes to active pane by default)
tmux_cmd := 'tmux send-keys -t ${session.name}:@${window.id} "${cmd}" Enter'
osal.exec(cmd: tmux_cmd, stdout: false, name: 'tmux_pane_execute')!
}
}
action.done = true
}
}
fn play_pane_kill(mut plbook PlayBook, mut tmux_instance Tmux) ! {
mut actions := plbook.find(filter: 'tmux.pane_kill')!
for mut action in actions {
mut p := action.params
name := p.get('name')!
parsed := parse_pane_name(name)!
// Find the session and window, then kill the active pane
if tmux_instance.session_exist(parsed.session) {
mut session := tmux_instance.session_get(parsed.session)!
if session.window_exist(name: parsed.window) {
mut window := session.window_get(name: parsed.window)!
// Kill the active pane in the window
if pane := window.pane_active() {
tmux_cmd := 'tmux kill-pane -t ${session.name}:@${window.id}.%${pane.id}'
osal.exec(cmd: tmux_cmd, stdout: false, name: 'tmux_pane_kill', ignore_error: true)!
}
}
}
action.done = true
}
}

View File

@@ -3,6 +3,8 @@
TMUX is a very capable process manager.
> TODO: TTYD, need to integrate with TMUX for exposing TMUX over http
### Concepts
- tmux = is the factory, it represents the tmux process manager, linked to a node
@@ -21,4 +23,30 @@ tmux library provides functions for managing tmux sessions
## to attach to a tmux session
> TODO:
> TODO:
## HeroScript Usage Examples
```heroscript
!!tmux.session_create
name:'mysession'
reset:true
!!tmux.session_delete
name:'mysession'
!!tmux.window_create
name:"mysession|mywindow"
cmd:'htop'
env:'VAR1=value1,VAR2=value2'
reset:true
!!tmux.window_delete
name:"mysession|mywindow"
!!tmux.pane_execute
name:"mysession|mywindow|mypane"
cmd:'ls -la'
!!tmux.pane_kill
name:"mysession|mywindow|mypane"
```

View File

@@ -1,6 +1,7 @@
module tmux
import freeflowuniverse.herolib.osal.core as osal
import freeflowuniverse.herolib.core.texttools
// import freeflowuniverse.herolib.session
import os
import time
@@ -13,6 +14,76 @@ pub mut:
sessionid string // unique link to job
}
// get session (session has windows) .
// returns none if not found
pub fn (mut t Tmux) session_get(name_ string) !&Session {
name := texttools.name_fix(name_)
for s in t.sessions {
if s.name == name {
return s
}
}
return error('Can not find session with name: \'${name_}\', out of loaded sessions.')
}
pub fn (mut t Tmux) session_exist(name_ string) bool {
name := texttools.name_fix(name_)
t.session_get(name) or { return false }
return true
}
pub fn (mut t Tmux) session_delete(name_ string) ! {
if !(t.session_exist(name_)) {
return
}
name := texttools.name_fix(name_)
mut i := 0
for mut s in t.sessions {
if s.name == name {
s.stop()!
break
}
i += 1
}
t.sessions.delete(i)
}
@[params]
pub struct SessionCreateArgs {
pub mut:
name string @[required]
reset bool
}
// create session, if reset will re-create
pub fn (mut t Tmux) session_create(args SessionCreateArgs) !&Session {
name := texttools.name_fix(args.name)
if !(t.session_exist(name)) {
$if debug {
console.print_header(' tmux - create session: ${args}')
}
mut s2 := Session{
tmux: t // reference back
name: name
}
s2.create()!
t.sessions << &s2
}
mut s := t.session_get(name)!
if args.reset {
$if debug {
console.print_header(' tmux - session ${name} will be restarted.')
}
s.restart()!
}
t.scan()!
return s
}
@[params]
pub struct TmuxNewArgs {
sessionid string
@@ -28,15 +99,33 @@ pub fn new(args TmuxNewArgs) !Tmux {
return t
}
// // loads tmux session, populate the object
// pub fn (mut tmux Tmux) load() ! {
// // isrunning := tmux.is_running()!
// // if !isrunning {
// // tmux.start()!
// // }
// // console.print_debug("SCAN")
// tmux.scan()!
// }
@[params]
pub struct WindowNewArgs {
pub mut:
session_name string = 'main'
name string
cmd string
env map[string]string
reset bool
}
pub fn (mut t Tmux) window_new(args WindowNewArgs) !&Window {
// Get or create session
mut session := if t.session_exist(args.session_name) {
t.session_get(args.session_name)!
} else {
t.session_create(name: args.session_name)!
}
// Create window in session
return session.window_new(
name: args.name
cmd: args.cmd
env: args.env
reset: args.reset
)!
}
pub fn (mut t Tmux) stop() ! {
$if debug {
@@ -67,6 +156,7 @@ pub fn (mut t Tmux) start() ! {
t.scan()!
}
// print list of tmux sessions
pub fn (mut t Tmux) list_print() {
// os.log('TMUX - Start listing ....')

150
lib/osal/tmux/tmux_pane.v Normal file
View File

@@ -0,0 +1,150 @@
module tmux
import freeflowuniverse.herolib.osal.core as osal
import freeflowuniverse.herolib.data.ourtime
import time
// import freeflowuniverse.herolib.session
import os
import freeflowuniverse.herolib.ui.console
@[heap]
struct Pane {
pub mut:
window &Window @[str: skip]
id int // pane id (e.g., %1, %2)
pid int // process id
active bool // is this the active pane
cmd string // command running in pane
env map[string]string
created_at time.Time
last_output_offset int // for tracking new logs
}
pub fn (mut p Pane) stats() !ProcessStats {
if p.pid == 0 {
return ProcessStats{}
}
// Use ps command to get CPU and memory stats
cmd := 'ps -p ${p.pid} -o %cpu,%mem,rss --no-headers'
result := osal.execute_silent(cmd) or {
return error('Cannot get stats for PID ${p.pid}: ${err}')
}
if result.trim_space() == '' {
return error('Process ${p.pid} not found')
}
parts := result.trim_space().split_any(' \t').filter(it != '')
if parts.len < 3 {
return error('Invalid ps output: ${result}')
}
return ProcessStats{
cpu_percent: parts[0].f64()
memory_percent: parts[1].f64()
memory_bytes: parts[2].u64() * 1024 // ps returns KB, convert to bytes
}
}
pub struct TMuxLogEntry {
pub mut:
content string
timestamp time.Time
offset int
}
pub fn (mut p Pane) logs_get_new(reset bool) ![]TMuxLogEntry {
if reset{
p.last_output_offset = 0
}
// Capture pane content with line numbers
cmd := 'tmux capture-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id} -S ${p.last_output_offset} -p'
result := osal.execute_silent(cmd) or {
return error('Cannot capture pane output: ${err}')
}
lines := result.split_into_lines()
mut entries := []TMuxLogEntry{}
mut i:= 0
for line in lines {
if line.trim_space() != '' {
entries << TMuxLogEntry{
content: line
timestamp: time.now()
offset: p.last_output_offset + i + 1
}
}
}
// Update offset to avoid duplicates next time
if entries.len > 0 {
p.last_output_offset = entries.last().offset
}
return entries
}
pub fn (mut p Pane) exit_status() !ProcessStatus {
// Get the last few lines to see if there's an exit status
logs := p.logs_all()!
lines := logs.split_into_lines()
// Look for shell prompt indicating command finished
for line in lines.reverse() {
line_clean := line.trim_space()
if line_clean.contains('$') || line_clean.contains('#') || line_clean.contains('>') {
// Found shell prompt, command likely finished
// Could also check for specific exit codes in history
return .finished_ok
}
}
return .finished_error
}
pub fn (mut p Pane) logs_all() !string {
cmd := 'tmux capture-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id} -S -2000 -p'
return osal.execute_silent(cmd) or {
error('Cannot capture pane output: ${err}')
}
}
// Fix the output_wait method to use correct method name
pub fn (mut p Pane) output_wait(c_ string, timeoutsec int) ! {
mut t := ourtime.now()
start := t.unix()
c := c_.replace('\n', '')
for i in 0 .. 2000 {
entries := p.logs_get_new(reset: false)!
for entry in entries {
if entry.content.replace('\n', '').contains(c) {
return
}
}
mut t2 := ourtime.now()
if t2.unix() > start + timeoutsec {
return error('timeout on output wait for tmux.\n${p} .\nwaiting for:\n${c}')
}
time.sleep(100 * time.millisecond)
}
}
// Get process information for this pane and all its children
pub fn (mut p Pane) processinfo() !osal.ProcessMap {
if p.pid == 0 {
return error('Pane has no associated process (pid is 0)')
}
return osal.processinfo_with_children(p.pid)!
}
// Get process information for just this pane's main process
pub fn (mut p Pane) processinfo_main() !osal.ProcessInfo {
if p.pid == 0 {
return error('Pane has no associated process (pid is 0)')
}
return osal.processinfo_get(p.pid)!
}

View File

@@ -0,0 +1,21 @@
module tmux
pub struct ProcessStats {
pub mut:
cpu_percent f64
memory_bytes u64
memory_percent f64
}
enum ProcessStatus {
running
finished_ok
finished_error
not_found
}

View File

@@ -3,56 +3,69 @@ module tmux
import freeflowuniverse.herolib.osal.core as osal
import freeflowuniverse.herolib.core.texttools
import freeflowuniverse.herolib.ui.console
import time
fn (mut t Tmux) scan_add(line string) !&Window {
// console.print_debug(" -- scan add: $line")
if line.count('|') < 4 {
return error(@FN + 'expects line with at least 5 params separated by |')
}
fn (mut t Tmux) scan_add(line string) !&Pane {
// Parse the line to get session, window, and pane info
line_arr := line.split('|')
session_name := line_arr[0]
window_name := line_arr[1]
window_id := line_arr[2]
pane_active := line_arr[3]
pane_id := line_arr[4]
pane_pid := line_arr[5]
pane_start_command := line_arr[6] or { '' }
line_arr := line.split('|')
session_name := line_arr[0]
window_name := line_arr[1]
window_id := line_arr[2]
pane_active := line_arr[3]
pane_id := line_arr[4]
pane_pid := line_arr[5]
pane_start_command := line_arr[6] or { '' }
wid := (window_id.replace('@', '')).int()
pid := (pane_id.replace('%', '')).int()
wid := (window_id.replace('@', '')).int()
mut s := t.session_get(session_name)!
// os.log('TMUX FOUND: $line\n ++ $session_name:$window_name wid:$window_id pid:$pane_pid entrypoint:$pane_start_command')
mut s := t.session_get(session_name)!
// Get or create window
mut w := if s.window_exist(name: window_name, id: wid) {
s.window_get(name: window_name, id: wid)!
} else {
mut new_w := Window{
session: s
name: texttools.name_fix(window_name)
id: wid
panes: []&Pane{}
}
s.windows << &new_w
&new_w
}
mut active := false
if pane_active == '1' {
active = true
}
// Create or update pane
mut p := Pane{
window: w
id: pid
pid: pane_pid.int()
active: pane_active == '1'
cmd: pane_start_command
created_at: time.now()
}
mut name := texttools.name_fix(window_name)
// Check if pane already exists
mut found := false
for mut existing_pane in w.panes {
if existing_pane.id == pid {
existing_pane.pid = p.pid
existing_pane.active = p.active
existing_pane.cmd = p.cmd
found = true
break
}
}
mut w := Window{
session: s
name: name
}
if !found {
w.panes << &p
}
if !(s.window_exist(name: window_name, id: wid)) {
// console.print_debug("window not exists")
s.windows << &w
} else {
w = s.window_get(name: window_name, id: wid)!
}
w.id = wid
w.active = active
w.pid = pane_pid.int()
w.paneid = (pane_id.replace('%', '')).int()
w.cmd = pane_start_command
return &w
return &p
}
// scan the system to detect sessions .
//TODO needs to be done differently, here only find the sessions, then per session call the scan() which will find the windows, call scan() there as well ...
pub fn (mut t Tmux) scan() ! {
// os.log('TMUX - Scanning ....')

View File

@@ -13,100 +13,140 @@ pub mut:
name string
}
// get session (session has windows) .
// returns none if not found
pub fn (mut t Tmux) session_get(name_ string) !&Session {
name := texttools.name_fix(name_)
for s in t.sessions {
if s.name == name {
return s
}
}
return error('Can not find session with name: \'${name_}\', out of loaded sessions.')
}
pub fn (mut t Tmux) session_exist(name_ string) bool {
name := texttools.name_fix(name_)
t.session_get(name) or { return false }
return true
}
pub fn (mut t Tmux) session_delete(name_ string) ! {
if !(t.session_exist(name_)) {
return
}
name := texttools.name_fix(name_)
mut i := 0
for mut s in t.sessions {
if s.name == name {
s.stop()!
break
}
i += 1
}
t.sessions.delete(i)
}
@[params]
pub struct SessionCreateArgs {
pub struct WindowArgs {
pub mut:
name string @[required]
name string
cmd string
env map[string]string
reset bool
}
// create session, if reset will re-create
pub fn (mut t Tmux) session_create(args SessionCreateArgs) !&Session {
name := texttools.name_fix(args.name)
if !(t.session_exist(name)) {
$if debug {
console.print_header(' tmux - create session: ${args}')
}
mut s2 := Session{
tmux: t // reference back
name: name
}
s2.create()!
t.sessions << &s2
}
mut s := t.session_get(name)!
if args.reset {
$if debug {
console.print_header(' tmux - session ${name} will be restarted.')
}
s.restart()!
}
t.scan()!
return s
@[params]
pub struct WindowGetArgs {
pub mut:
name string
id int
}
pub fn (mut s Session) create() ! {
res_opt := "-P -F '#\{window_id\}'"
cmd := "tmux new-session ${res_opt} -d -s ${s.name} 'sh'"
window_id_ := osal.execute_silent(cmd) or {
return error("Can't create tmux session ${s.name} \n${cmd}\n${err}")
}
cmd3 := 'tmux set-option remain-on-exit on'
osal.execute_silent(cmd3) or { return error("Can't execute ${cmd3}\n${err}") }
window_id := window_id_.trim(' \n')
cmd2 := "tmux rename-window -t ${window_id} 'notused'"
osal.execute_silent(cmd2) or {
return error("Can't rename window ${window_id} to notused \n${cmd2}\n${err}")
}
// Check if session already exists
cmd_check := "tmux has-session -t ${s.name}"
check_result := osal.exec(cmd: cmd_check, stdout: false, ignore_error: true) or {
// Session doesn't exist, this is expected
osal.Job{}
}
if check_result.exit_code == 0 {
return error('duplicate session: ${s.name}')
}
// Create new session
cmd := "tmux new-session -d -s ${s.name}"
osal.exec(cmd: cmd, stdout: false, name: 'tmux_session_create') or {
return error("Can't create session ${s.name}: ${err}")
}
}
pub fn (mut s Session) restart() ! {
s.stop()!
s.create()!
//load info from reality
pub fn (mut s Session) scan() ! {
// Get current windows from tmux for this session
cmd := "tmux list-windows -t ${s.name} -F '#{window_name}|#{window_id}|#{window_active}'"
result := osal.execute_silent(cmd) or {
if err.msg().contains('session not found') {
return // Session doesn't exist anymore
}
return error('Cannot list windows for session ${s.name}: ${err}')
}
mut current_windows := map[string]bool{}
for line in result.split_into_lines() {
if line.contains('|') {
parts := line.split('|')
if parts.len >= 2 {
window_name := texttools.name_fix(parts[0])
window_id := parts[1].replace('@', '').int()
window_active := parts[2] == '1'
current_windows[window_name] = true
// Update existing window or create new one
mut found := false
for mut w in s.windows {
if w.name == window_name {
w.id = window_id
w.active = window_active
w.scan()! // Scan panes for this window
found = true
break
}
}
if !found {
mut new_window := Window{
session: &s
name: window_name
id: window_id
active: window_active
panes: []&Pane{}
env: map[string]string{}
}
new_window.scan()! // Scan panes for new window
s.windows << &new_window
}
}
}
}
// Remove windows that no longer exist in tmux
s.windows = s.windows.filter(current_windows[it.name] == true)
}
pub fn (mut s Session) stop() ! {
osal.execute_silent('tmux kill-session -t ${s.name}') or {
return error("Can't delete session ${s.name} - This may happen when session is not found: ${err}")
// window_name is the name of the window in session main (will always be called session main)
// cmd to execute e.g. bash file
// environment arguments to use
// reset, if reset it will create window even if it does already exist, will destroy it
// ```
// struct WindowArgs {
// pub mut:
// name string
// cmd string
// env map[string]string
// reset bool
// }
// ```
pub fn (mut s Session) window_new(args WindowArgs) !Window {
$if debug {
console.print_header(' start window: \n${args}')
}
namel := texttools.name_fix(args.name)
if s.window_exist(name: namel) {
if args.reset {
s.window_delete(name: namel)!
} else {
return error('cannot create new window it already exists, window ${namel} in session:${s.name}')
}
}
mut w := Window{
session: &s
name: namel
panes: []&Pane{}
env: args.env
}
s.windows << &w
// Create the window with the specified command
w.create(args.cmd)!
s.scan()!
return w
}
// get all windows as found in a session
pub fn (mut s Session) windows_get() []&Window {
mut res := []&Window{}
@@ -117,7 +157,12 @@ pub fn (mut s Session) windows_get() []&Window {
return res
}
pub fn (mut s Session) windownames_get() []string {
// List windows in a session
pub fn (mut s Session) window_list() []&Window {
return s.windows
}
pub fn (mut s Session) window_names() []string {
mut res := []string{}
for _, window in s.windows {
res << window.name
@@ -133,7 +178,18 @@ pub fn (mut s Session) str() string {
return out
}
// pub fn (mut s Session) activate()! {
pub fn (mut s Session) stats() !ProcessStats {
mut total := ProcessStats{}
for mut window in s.windows {
stats := window.stats() or { continue }
total.cpu_percent += stats.cpu_percent
total.memory_bytes += stats.memory_bytes
total.memory_percent += stats.memory_percent
}
return total
}
// pub fn (mut s Session) activate()! {
// active_session := s.tmux.redis.get('tmux:active_session') or { 'No active session found' }
// if active_session != 'No active session found' && s.name != active_session {
// s.tmuxexecutor.db.exec('tmux attach-session -t $active_session') or {
@@ -151,3 +207,56 @@ pub fn (mut s Session) str() string {
// os.log('SESSION - Session: $s.name already activate ')
// }
// }
fn (mut s Session) window_exist(args_ WindowGetArgs) bool {
mut args := args_
s.window_get(args) or { return false }
return true
}
pub fn (mut s Session) window_get(args_ WindowGetArgs) !&Window {
mut args := args_
args.name = texttools.name_fix(args.name)
for w in s.windows {
if w.name == args.name {
if (args.id > 0 && w.id == args.id) || args.id == 0 {
return w
}
}
}
return error('Cannot find window ${args.name} in session:${s.name}')
}
pub fn (mut s Session) window_delete(args_ WindowGetArgs) ! {
// $if debug { console.print_debug(" - window delete: $args_")}
mut args := args_
args.name = texttools.name_fix(args.name)
if !(s.window_exist(args)) {
return
}
mut i := 0
for mut w in s.windows {
if w.name == args.name {
if (args.id > 0 && w.id == args.id) || args.id == 0 {
w.stop()!
break
}
}
i += 1
}
s.windows.delete(i) // i is now the one in the list which needs to be removed
}
pub fn (mut s Session) restart() ! {
s.stop()!
s.create()!
}
pub fn (mut s Session) stop() ! {
osal.execute_silent('tmux kill-session -t ${s.name}') or {
return error("Can't delete session ${s.name} - This may happen when session is not found: ${err}")
}
}

View File

@@ -45,57 +45,37 @@ fn test_stop() ! {
}
fn test_windows_get() ! {
mut tmux := new(sessionid: '1234')!
// test windows_get when only starting window is running
tmux.start()!
mut windows := tmux.windows_get()
assert windows.len == 1
// test getting newly created window
// tmux.window_new(WindowArgs{ name: 'testwindow' })!
// windows = tmux.windows_get()
// mut is_name_exist := false
// mut is_active_window := false
// unsafe {
// for window in windows {
// if window.name == 'testwindow' {
// is_name_exist = true
// is_active_window = window.active
// }
// }
// }
// assert is_name_exist == true
// assert is_active_window == true
// tmux.stop()!
mut tmux := new()!
tmux.start()!
// After start, scan to get the initial session
tmux.scan()!
windows := tmux.windows_get()
assert windows.len >= 0 // At least the default session should exist
tmux.stop()!
}
// TODO: fix test
fn test_scan() ! {
console.print_debug('-----Testing scan------')
mut tmux := new(sessionid: '1234')!
tmux.start()!
console.print_debug('-----Testing scan------')
mut tmux := new()!
tmux.start()!
// check bash window is initialized
mut new_windows := tmux.windows_get()
// assert new_windows.len == 1
// assert new_windows[0].name == 'bash'
// test scan, should return no windows
// test scan with window in tmux but not in tmux struct
// mocking a failed command to see if scan identifies
// tmux.sessions['init'].windows['test'] = &Window{
// session: tmux.sessions['init']
// name: 'test'
// }
// new_windows = tmux.windows_get()
// panic('new windows ${new_windows.keys()}')
// unsafe {
// assert new_windows.keys().len == 1
// }
// new_windows = tmux.scan()!
// tmux.stop()!
// Test initial scan
tmux.scan()!
sessions_before := tmux.sessions.len
// Create a test session
mut session := tmux.session_create(name: 'test_scan')!
// Scan again
tmux.scan()!
sessions_after := tmux.sessions.len
assert sessions_after >= sessions_before
tmux.stop()!
}
// //TODO: fix test

View File

@@ -13,245 +13,166 @@ pub mut:
session &Session @[skip]
name string
id int
panes []&Pane // windows contain multiple panes
active bool
pid int
paneid int
cmd string
env map[string]string
}
pub struct WindowArgs {
@[params]
pub struct PaneNewArgs {
pub mut:
name string
reset bool //means we reset the pane if it already exists
cmd string
env map[string]string
reset bool
env map[string]string
}
// window_name is the name of the window in session main (will always be called session main)
// cmd to execute e.g. bash file
// environment arguments to use
// reset, if reset it will create window even if it does already exist, will destroy it
// ```
// struct WindowArgs {
// pub mut:
// name string
// cmd string
// env map[string]string
// reset bool
// }
// ```
pub fn (mut t Tmux) window_new(args WindowArgs) !Window {
mut s := t.session_create(name: 'main', reset: false)!
mut w := s.window_new(args)!
return w
pub fn (mut w Window) scan() ! {
// Get current panes for this window
cmd := "tmux list-panes -t ${w.session.name}:@${w.id} -F '#{pane_id}|#{pane_pid}|#{pane_active}|#{pane_start_command}'"
result := osal.execute_silent(cmd) or {
// Window might not exist anymore
return
}
mut current_panes := map[int]bool{}
for line in result.split_into_lines() {
if line.contains('|') {
parts := line.split('|')
if parts.len >= 3 {
pane_id := parts[0].replace('%', '').int()
pane_pid := parts[1].int()
pane_active := parts[2] == '1'
pane_cmd := if parts.len > 3 { parts[3] } else { '' }
current_panes[pane_id] = true
// Update existing pane or create new one
mut found := false
for mut p in w.panes {
if p.id == pane_id {
p.pid = pane_pid
p.active = pane_active
p.cmd = pane_cmd
found = true
break
}
}
if !found {
mut new_pane := Pane{
window: &w
id: pane_id
pid: pane_pid
active: pane_active
cmd: pane_cmd
env: map[string]string{}
created_at: time.now()
last_output_offset: 0
}
w.panes << &new_pane
}
}
}
}
// Remove panes that no longer exist
w.panes = w.panes.filter(current_panes[it.id] == true)
}
// is always in the main tmux
pub fn (mut t Tmux) window_delete(args WindowGetArgs) ! {
mut s := t.session_create(name: 'main', reset: false)!
s.window_delete(name: args.name)!
pub fn (mut w Window) stop() ! {
w.kill()!
}
//helper function
//TODO env variables are not inserted in pane
pub fn (mut w Window) create(cmd_ string) ! {
mut final_cmd := cmd_
if cmd_.contains('\n') {
os.mkdir_all('/tmp/tmux/${w.session.name}')!
// Fix: osal.exec_string doesn't exist, use file writing instead
script_path := '/tmp/tmux/${w.session.name}/${w.name}.sh'
script_content := '#!/bin/bash\n' + cmd_
os.write_file(script_path, script_content)!
os.chmod(script_path, 0o755)!
final_cmd = script_path
}
// window_name is the name of the window in session main (will always be called session main)
// cmd to execute e.g. bash file
// environment arguments to use
// reset, if reset it will create window even if it does already exist, will destroy it
// ```
// struct WindowArgs {
// pub mut:
// name string
// cmd string
// env map[string]string
// reset bool
// }
// ```
pub fn (mut s Session) window_new(args WindowArgs) !Window {
$if debug {
console.print_header(' start window: \n${args}')
}
namel := texttools.name_fix(args.name)
if s.window_exist(name: namel) {
if args.reset {
s.window_delete(name: namel)!
} else {
return error('cannot create new window it already exists, window ${namel} in session:${s.name}')
}
}
mut w := Window{
session: &s
name: namel
cmd: args.cmd
env: args.env
}
s.windows << &w
w.create()!
s.window_delete(name: 'notused')!
return w
}
mut newcmd := '/bin/bash -c "${final_cmd}"'
if cmd_ == "" {
newcmd = '/bin/bash'
}
pub struct WindowGetArgs {
pub mut:
name string
cmd string
id int
}
// Build environment arguments
mut env_args := ''
for key, value in w.env {
env_args += ' -e ${key}="${value}"'
}
fn (mut s Session) window_exist(args_ WindowGetArgs) bool {
mut args := args_
s.window_get(args) or { return false }
return true
}
pub fn (mut s Session) window_get(args_ WindowGetArgs) !&Window {
mut args := args_
args.name = texttools.name_fix(args.name)
for w in s.windows {
if w.name == args.name {
if (args.id > 0 && w.id == args.id) || args.id == 0 {
return w
}
}
}
return error('Cannot find window ${args.name} in session:${s.name}')
}
pub fn (mut s Session) window_delete(args_ WindowGetArgs) ! {
// $if debug { console.print_debug(" - window delete: $args_")}
mut args := args_
args.name = texttools.name_fix(args.name)
if !(s.window_exist(args)) {
return
}
mut i := 0
for mut w in s.windows {
if w.name == args.name {
if (args.id > 0 && w.id == args.id) || args.id == 0 {
w.stop()!
break
}
}
i += 1
}
s.windows.delete(i) // i is now the one in the list which needs to be removed
}
pub fn (mut w Window) create() ! {
// tmux new-window -P -c /tmp -e good=1 -e bad=0 -n koekoe -t main bash
if w.cmd.contains('\n') {
// means is multiline need to write it
// scriptpath string // is the path where the script will be put which is executed
// scriptkeep bool // means we don't remove the script
os.mkdir_all('/tmp/tmux/${w.session.name}')!
cmd_new := osal.exec_string(
cmd: w.cmd
scriptpath: '/tmp/tmux/${w.session.name}/${w.name}.sh'
scriptkeep: true
)!
w.cmd = cmd_new
}
// console.print_debug(w)
if w.active == false {
res_opt := "-P -F '#{session_name}|#{window_name}|#{window_id}|#{pane_active}|#{pane_id}|#{pane_pid}|#{pane_start_command}'"
cmd := 'tmux new-window ${res_opt} -t ${w.session.name} -n ${w.name} \'/bin/bash -c ${w.cmd}\''
console.print_debug(cmd)
res := osal.exec(cmd: cmd, stdout: false, name: 'tmux_window_create') or {
return error("Can't create new window ${w.name} \n${cmd}\n${err}")
}
// now look at output to get the window id = wid
line_arr := res.output.split('|')
wid := line_arr[2] or { panic('cannot split line for window create.\n${line_arr}') }
w.id = wid.replace('@', '').int()
$if debug {
console.print_header(' WINDOW - Window: ${w.name} created in session: ${w.session.name}')
}
} else {
return error('cannot create window, it already exists.\n${w.name}:${w.id}:${w.cmd}')
}
}
// do some good checks if the window is still active
// not implemented yet
pub fn (mut w Window) check() ! {
panic('not implemented yet')
}
// restart the window
pub fn (mut w Window) restart() ! {
w.stop()!
w.create()!
res_opt := "-P -F '#{session_name}|#{window_name}|#{window_id}|#{pane_active}|#{pane_id}|#{pane_pid}|#{pane_start_command}'"
cmd := 'tmux new-window ${res_opt}${env_args} -t ${w.session.name} -n ${w.name} \'${newcmd}\''
console.print_debug(cmd)
res := osal.exec(cmd: cmd, stdout: false, name: 'tmux_window_create') or {
return error("Can't create new window ${w.name} \n${cmd}\n${err}")
}
line_arr := res.output.split('|')
wid := line_arr[2] or { return error('cannot split line for window create.\n${line_arr}') }
w.id = wid.replace('@', '').int()
}
// stop the window
pub fn (mut w Window) stop() ! {
pub fn (mut w Window) kill() ! {
osal.exec(
cmd: 'tmux kill-window -t @${w.id}'
stdout: false
name: 'tmux_kill-window'
// die: false
) or { return error("Can't kill window with id:${w.id}: ${err}") }
w.pid = 0
w.active = false
w.active = false // Window is no longer active
}
pub fn (window Window) str() string {
return ' - name:${window.name} wid:${window.id} active:${window.active} pid:${window.pid} cmd:${window.cmd}'
mut out := ' - name:${window.name} wid:${window.id} active:${window.active}'
for pane in window.panes {
out += '\n ${*pane}'
}
return out
}
pub fn (mut w Window) stats() !ProcessStats {
mut total := ProcessStats{}
for mut pane in w.panes {
stats := pane.stats() or { continue }
total.cpu_percent += stats.cpu_percent
total.memory_bytes += stats.memory_bytes
total.memory_percent += stats.memory_percent
}
return total
}
// will select the current window so with tmux a we can go there .
// to login into a session do `tmux a -s mysessionname`
fn (mut w Window) activate() ! {
cmd2 := 'tmux select-window -t %${w.id}'
cmd2 := 'tmux select-window -t @${w.id}'
osal.execute_silent(cmd2) or {
return error("Couldn't select window ${w.name} \n${cmd2}\n${err}")
}
}
// show the environment
pub fn (mut w Window) environment_print() ! {
res := osal.execute_silent('tmux show-environment -t %${w.paneid}') or {
return error('Couldnt show enviroment cmd: ${w.cmd} \n${err}')
}
os.log(res)
// List panes in a window
pub fn (mut w Window) pane_list() []&Pane {
return w.panes
}
// capture the output
pub fn (mut w Window) output_print() ! {
o := w.output()!
console.print_debug(o)
}
// capture the output
pub fn (mut w Window) output() !string {
//-S is start, minus means go in history, otherwise its only the active output
// tmux capture-pane -t your-session-name:your-window-number -S -1000
cmd := 'tmux capture-pane -t ${w.session.name}:@${w.id} -S -1000 && tmux show-buffer'
res := osal.execute_silent(cmd) or {
return error('Couldnt show enviroment cmd: ${w.cmd} \n${err}')
}
return texttools.remove_empty_lines(res)
}
pub fn (mut w Window) output_wait(c_ string, timeoutsec int) ! {
mut t := ourtime.now()
start := t.unix()
c := c_.replace('\n', '')
for i in 0 .. 2000 {
o := w.output()!
// console.print_debug(o)
$if debug {
console.print_debug(" - tmux ${w.name}: wait for: '${c}'")
}
// need to replace \n because can be wrapped because of size of pane
if o.replace('\n', '').contains(c) {
return
}
mut t2 := ourtime.now()
if t2.unix() > start + timeoutsec {
return error('timeout on output wait for tmux.\n${w} .\nwaiting for:\n${c}')
}
time.sleep(100 * time.millisecond)
}
// Get active pane in window
pub fn (mut w Window) pane_active() ?&Pane {
for pane in w.panes {
if pane.active {
return pane
}
}
return none
}

View File

@@ -24,19 +24,23 @@ fn testsuite_end() {
}
fn test_window_new() ! {
mut tmux_ := new()!
mut tmux := new()!
tmux.start()!
// test window new with only name arg
window_args := WindowArgs{
name: 'TestWindow'
}
assert tmux_.sessions.filter(it.name == 'main').len == 0
mut window := tmux_.window_new(window_args)!
assert tmux_.sessions.filter(it.name == 'main').len > 0
// time.sleep(1000 * time.millisecond)
// window.stop()!
// Create session first
mut session := tmux.session_create(name: 'main')!
// Test window creation
mut window := session.window_new(
name: 'TestWindow'
cmd: 'bash'
reset: true
)!
assert window.name == 'testwindow' // name_fix converts to lowercase
assert session.window_exist(name: 'testwindow')
tmux.stop()!
}
// tests creating duplicate windows

View File

@@ -0,0 +1,424 @@
# Traefik EntryPoints — Concise Guide (v3)
> Source docs: Traefik “Routing & Load Balancing → EntryPoints” and “Reference → Install Configuration → EntryPoints” (links in chat).
## What are EntryPoints
EntryPoints are the network entry points into Traefik. They define **which port and protocol (TCP/UDP)** Traefik listens on for incoming traffic. An entryPoint can be referenced by routers (HTTP/TCP/UDP).
---
## Quick Configuration Examples
### Port 80 only
```yaml
# Static configuration
entryPoints:
web:
address: ":80"
```
```toml
[entryPoints]
[entryPoints.web]
address = ":80"
```
```bash
# CLI
--entryPoints.web.address=:80
```
### Ports 80 & 443
```yaml
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
```
```toml
[entryPoints]
[entryPoints.web]
address = ":80"
[entryPoints.websecure]
address = ":443"
```
```bash
--entryPoints.web.address=:80
--entryPoints.websecure.address=:443
```
### UDP on port 1704
```yaml
entryPoints:
streaming:
address: ":1704/udp"
```
```toml
[entryPoints]
[entryPoints.streaming]
address = ":1704/udp"
```
```bash
--entryPoints.streaming.address=:1704/udp
```
### TCP **and** UDP on the same port (3179)
```yaml
entryPoints:
tcpep:
address: ":3179" # TCP
udpep:
address: ":3179/udp" # UDP
```
```toml
[entryPoints]
[entryPoints.tcpep]
address = ":3179"
[entryPoints.udpep]
address = ":3179/udp"
```
```bash
--entryPoints.tcpep.address=:3179
--entryPoints.udpep.address=:3179/udp
```
### Listen on specific IPs only
```yaml
entryPoints:
specificIPv4:
address: "192.168.2.7:8888"
specificIPv6:
address: "[2001:db8::1]:8888"
```
```toml
[entryPoints.specificIPv4]
address = "192.168.2.7:8888"
[entryPoints.specificIPv6]
address = "[2001:db8::1]:8888"
```
```bash
--entryPoints.specificIPv4.address=192.168.2.7:8888
--entryPoints.specificIPv6.address=[2001:db8::1]:8888
```
---
## General Structure (Static Configuration)
```yaml
entryPoints:
<name>:
address: ":8888" # or ":8888/tcp" or ":8888/udp"
http2:
maxConcurrentStreams: 250
http3:
advertisedPort: 443 # requires TLS; see notes
transport:
lifeCycle:
requestAcceptGraceTimeout: 42s
graceTimeOut: 42s
respondingTimeouts:
readTimeout: 60s
writeTimeout: 0s
idleTimeout: 180s
proxyProtocol:
insecure: true # trust all (testing only)
trustedIPs:
- "127.0.0.1"
- "192.168.0.1"
forwardedHeaders:
insecure: true # trust all (testing only)
trustedIPs:
- "127.0.0.1/32"
- "192.168.1.7"
connection:
- "foobar"
```
```toml
[entryPoints]
[entryPoints.name]
address = ":8888"
[entryPoints.name.http2]
maxConcurrentStreams = 250
[entryPoints.name.http3]
advertisedPort = 443
[entryPoints.name.transport]
[entryPoints.name.transport.lifeCycle]
requestAcceptGraceTimeout = "42s"
graceTimeOut = "42s"
[entryPoints.name.transport.respondingTimeouts]
readTimeout = "60s"
writeTimeout = "0s"
idleTimeout = "180s"
[entryPoints.name.proxyProtocol]
insecure = true
trustedIPs = ["127.0.0.1", "192.168.0.1"]
[entryPoints.name.forwardedHeaders]
insecure = true
trustedIPs = ["127.0.0.1/32", "192.168.1.7"]
connection = ["foobar"]
```
```bash
--entryPoints.name.address=:8888
--entryPoints.name.http2.maxConcurrentStreams=250
--entryPoints.name.http3.advertisedport=443
--entryPoints.name.transport.lifeCycle.requestAcceptGraceTimeout=42s
--entryPoints.name.transport.lifeCycle.graceTimeOut=42s
--entryPoints.name.transport.respondingTimeouts.readTimeout=60s
--entryPoints.name.transport.respondingTimeouts.writeTimeout=0s
--entryPoints.name.transport.respondingTimeouts.idleTimeout=180s
--entryPoints.name.proxyProtocol.insecure=true
--entryPoints.name.proxyProtocol.trustedIPs=127.0.0.1,192.168.0.1
--entryPoints.name.forwardedHeaders.insecure=true
--entryPoints.name.forwardedHeaders.trustedIPs=127.0.0.1/32,192.168.1.7
--entryPoints.name.forwardedHeaders.connection=foobar
```
---
## Key Options (Explained)
### `address`
- Format: `[host]:port[/tcp|/udp]`. If protocol omitted ⇒ **TCP**.
- To use **both TCP & UDP** on the same port, define **two** entryPoints (one per protocol).
### `allowACMEByPass` (bool, default **false**)
- Allow user-defined routers to handle **ACME HTTP/TLS challenges** instead of Traefiks built-in handlers (useful if services also run their own ACME).
```yaml
entryPoints:
foo:
allowACMEByPass: true
```
### `reusePort` (bool, default **false**)
- Enables the OS `SO_REUSEPORT` option: multiple Traefik processes (or entryPoints) can **listen on the same TCP/UDP port**; the kernel load-balances incoming connections.
- Supported on **Linux, FreeBSD, OpenBSD, Darwin**.
- Example (same port, different hosts/IPs):
```yaml
entryPoints:
web:
address: ":80"
reusePort: true
privateWeb:
address: "192.168.1.2:80"
reusePort: true
```
### `asDefault` (bool, default **false**)
- Marks this entryPoint as **default** for HTTP/TCP routers **that dont specify** `entryPoints`.
```yaml
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
asDefault: true
```
- UDP entryPoints are **never** part of the default list.
- Built-in `traefik` entryPoint is **always excluded**.
### HTTP/2
- `http2.maxConcurrentStreams` (default **250**): max concurrent streams per connection.
### HTTP/3
- Enable by adding `http3: {}` (on a **TCP** entryPoint with **TLS**).
- When enabled on port **N**, Traefik also opens **UDP N** for HTTP/3.
- `http3.advertisedPort`: override the UDP port advertised via `alt-svc` (useful behind a different public port).
### Forwarded Headers
- Trust `X-Forwarded-*` only from `forwardedHeaders.trustedIPs`, or set `forwardedHeaders.insecure: true` (testing only).
- `forwardedHeaders.connection`: headers listed here are allowed to pass through the middleware chain before Traefik drops `Connection`-listed headers per RFC 7230.
### Transport Timeouts
- `transport.respondingTimeouts.readTimeout` (default **60s**): max duration to read the entire request (incl. body).
- `transport.respondingTimeouts.writeTimeout` (default **0s**): max duration for writing the response (0 = disabled).
- `transport.respondingTimeouts.idleTimeout` (default **180s**): max keep-alive idle time.
### Transport LifeCycle (graceful shutdown)
- `transport.lifeCycle.requestAcceptGraceTimeout` (default **0s**): keep accepting requests **before** starting graceful termination.
- `transport.lifeCycle.graceTimeOut` (default **10s**): time to let in-flight requests finish **after** Traefik stops accepting new ones.
### ProxyProtocol
- Enable accepting the **HAProxy PROXY** header and/or trust only from specific IPs.
```yaml
entryPoints:
name:
proxyProtocol:
insecure: true # trust all (testing only)
trustedIPs:
- "127.0.0.1"
- "192.168.0.1"
```
---
## HTTP Options (per entryPoint)
### Redirection → `http.redirections.entryPoint`
Redirect everything on one entryPoint to another (often `web` → `websecure`), and optionally change scheme.
```yaml
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure # or ":443"
scheme: https # default is https
permanent: true # 308/301
```
```toml
[entryPoints.web.http.redirections]
entryPoint = "websecure"
scheme = "https"
permanent = true
```
- `http.redirections.entryPoint.priority`: default priority for routers bound to the entryPoint (default `2147483646`).
### Encode Query Semicolons → `http.encodeQuerySemicolons` (bool, default **false**)
- If `true`, non-encoded semicolons in the query string are **encoded** before forwarding (prevents interpreting `;` as query parameter separators).
### SanitizePath → `http.sanitizePath` (bool, default **false**)
- Enable request **path sanitization/normalization** before routing.
### Middlewares → `http.middlewares`
Apply middlewares by name (with provider suffix) **to all routers attached to this entryPoint**.
```yaml
entryPoints:
websecure:
address: ":443"
tls: {}
middlewares:
- auth@kubernetescrd
- strip@kubernetescrd
```
### TLS → `http.tls`
Attach TLS options/resolvers and SNI domains at the entryPoint level (common for `websecure`).
```yaml
# YAML
entryPoints:
websecure:
address: ":443"
http:
tls:
options: foobar
certResolver: leresolver
domains:
- main: example.com
sans:
- foo.example.com
- bar.example.com
- main: test.com
sans:
- foo.test.com
- bar.test.com
```
```bash
--entryPoints.websecure.http.tls.options=foobar
--entryPoints.websecure.http.tls.certResolver=leresolver
--entryPoints.websecure.http.tls.domains[0].main=example.com
--entryPoints.websecure.http.tls.domains[0].sans=foo.example.com,bar.example.com
--entryPoints.websecure.http.tls.domains[1].main=test.com
--entryPoints.websecure.http.tls.domains[1].sans=foo.test.com,bar.test.com
```
---
## UDP Options
### `udp.timeout` (default **3s**)
Release idle UDP session resources after this duration.
```yaml
entryPoints:
foo:
address: ":8000/udp"
udp:
timeout: 10s
```
```toml
[entryPoints.foo]
address = ":8000/udp"
[entryPoints.foo.udp]
timeout = "10s"
```
```bash
--entryPoints.foo.address=:8000/udp
--entryPoints.foo.udp.timeout=10s
```
---
## Systemd Socket Activation
- Traefik supports **systemd socket activation**. If an fd name matches an entryPoint name, Traefik uses that fd as the listener.
```bash
systemd-socket-activate -l 80 -l 443 --fdname web:websecure ./traefik --entrypoints.web --entrypoints.websecure
```
- If using UDP with socket activation, the entryPoint address must include `/udp` (e.g., `--entrypoints.my-udp-entrypoint.address=/udp`).
- **Docker** does not support socket activation; **Podman** does.
- Each systemd socket file should define a **single** Listen directive, **except** for HTTP/3 which needs **both** `ListenStream` and `ListenDatagram` (same port). To run TCP **and** UDP on the same port, use **separate** socket files bound to different entryPoint names.
---
## Observability Options (per entryPoint)
> These control **defaults**; a routers own observability config can opt out.
```yaml
entryPoints:
foo:
address: ":8000"
observability:
accessLogs: false # default true
metrics: false # default true
tracing: false # default true
```
```toml
[entryPoints.foo]
address = ":8000"
[entryPoints.foo.observability]
accessLogs = false
metrics = false
tracing = false
```
```bash
--entryPoints.foo.observability.accessLogs=false
--entryPoints.foo.observability.metrics=false
--entryPoints.foo.observability.tracing=false
```
---
## Helm Chart Note
The Helm chart creates these entryPoints by default: `web` (80), `websecure` (443), `traefik` (8080), `metrics` (9100). `web` and `websecure` are exposed by default via a Service. You can override everything via values or `additionalArguments`.
---
## Quick Reference (selected fields)
| Field | Description | Default |
|---|---|---|
| `address` | Listener address & protocol `[host]:port[/tcp\|/udp]` | — |
| `asDefault` | Include in default entryPoints list for HTTP/TCP routers | `false` |
| `allowACMEByPass` | Let custom routers handle ACME challenges | `false` |
| `reusePort` | Enable `SO_REUSEPORT` to share the same port across processes | `false` |
| `http2.maxConcurrentStreams` | Max concurrent HTTP/2 streams per connection | `250` |
| `http3.advertisedPort` | UDP port advertised for HTTP/3 `alt-svc` | (entryPoint port) |
| `forwardedHeaders.trustedIPs` | IPs/CIDRs trusted for `X-Forwarded-*` | — |
| `forwardedHeaders.insecure` | Always trust forwarded headers | `false` |
| `transport.respondingTimeouts.readTimeout` | Max duration to read the request | `60s` |
| `transport.respondingTimeouts.writeTimeout` | Max duration to write the response | `0s` |
| `transport.respondingTimeouts.idleTimeout` | Keep-alive idle timeout | `180s` |
| `transport.lifeCycle.requestAcceptGraceTimeout` | Accept requests before graceful stop | `0s` |
| `transport.lifeCycle.graceTimeOut` | Time to finish in-flight requests | `10s` |
| `proxyProtocol.{insecure,trustedIPs}` | Accept PROXY headers (globally or from list) | — |
| `http.redirections.entryPoint.{to,scheme,permanent,priority}` | Redirect all requests on this entryPoint | `scheme=https`, `permanent=false`, `priority=2147483646` |
| `http.encodeQuerySemicolons` | Encode unescaped `;` in query string | `false` |
| `http.sanitizePath` | Normalize/sanitize request paths | `false` |
| `http.middlewares` | Middlewares applied to routers on this entryPoint | — |
| `http.tls` | TLS options/resolver/SNI domains at entryPoint level | — |
| `udp.timeout` | Idle session timeout for UDP routing | `3s` |
| `observability.{accessLogs,metrics,tracing}` | Defaults for router observability | `true` |
---
_This cheat sheet aggregates the salient bits from the official docs for quick use in config files._

View File

@@ -0,0 +1,183 @@
Heres the updated Markdown document, now enriched with direct links to the individual middleware reference pages to help you navigate easily.
---
# Traefik Proxy — Middlewares (Overview)
Middlewares are components you attach to **routers** to tweak requests before they reach a **service** (or to tweak responses before they reach clients). They can modify paths and headers, handle redirections, add authentication, rate-limit, and more. Multiple middlewares using the same protocol can be **chained** to fit complex scenarios. ([Overview page]({doc.traefik.io/traefik/middlewares/overview/})) ([Traefik Docs][1], [Traefik Docs][2])
> **Note — Provider Namespace**
> The “Providers Namespace” concept from Configuration Discovery also applies to middlewares (e.g., `foo@docker`, `bar@file`). ([Traefik Docs][1], [Traefik Docs][3])
---
## Configuration Examples
Examples showing how to **define** a middleware and **attach** it to a router across different providers. ([Traefik Docs][2])
<details>
<summary>Docker & Swarm (labels)</summary>
```yaml
whoami:
image: traefik/whoami
labels:
- "traefik.http.middlewares.foo-add-prefix.addprefix.prefix=/foo"
- "traefik.http.routers.router1.middlewares=foo-add-prefix@docker"
```
</details>
<details>
<summary>Kubernetes CRD (IngressRoute)</summary>
```yaml
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: stripprefix
spec:
stripPrefix:
prefixes:
- /stripit
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: ingressroute
spec:
routes:
- match: Host(`example.com`)
kind: Rule
services:
- name: my-svc
port: 80
middlewares:
- name: stripprefix
```
</details>
<details>
<summary>Consul Catalog (labels)</summary>
```text
"traefik.http.middlewares.foo-add-prefix.addprefix.prefix=/foo"
"traefik.http.routers.router1.middlewares=foo-add-prefix@consulcatalog"
```
</details>
<details>
<summary>File Provider (YAML)</summary>
```yaml
http:
routers:
router1:
rule: "Host(`example.com`)"
service: myService
middlewares:
- "foo-add-prefix"
middlewares:
foo-add-prefix:
addPrefix:
prefix: "/foo"
services:
myService:
loadBalancer:
servers:
- url: "http://127.0.0.1:80"
```
</details>
<details>
<summary>File Provider (TOML)</summary>
```toml
[http.routers.router1]
rule = "Host(`example.com`)"
service = "myService"
middlewares = ["foo-add-prefix"]
[http.middlewares.foo-add-prefix.addPrefix]
prefix = "/foo"
[http.services.myService.loadBalancer.servers]
url = "http://127.0.0.1:80"
```
</details>
---
## Available Middlewares
**HTTP Middlewares** — the complete list is detailed in the HTTP middlewares section:
AddPrefix, BasicAuth, Buffering, Chain, CircuitBreaker, Compress, ContentType, DigestAuth, Errors, ForwardAuth, GrpcWeb, Headers, IPAllowList / IPWhiteList, InFlightReq, PassTLSClientCert, RateLimit, RedirectRegex, RedirectScheme, ReplacePath, ReplacePathRegex, Retry, StripPrefix, StripPrefixRegex. ([Traefik Docs][4])
**TCP Middlewares** — covered in the TCP middlewares section:
InFlightConn, IPAllowList / IPWhiteList. ([Traefik Docs][5])
---
## Middleware Reference Links
Below are direct links to documentation for some of the most commonly used middlewares:
* **[AddPrefix](https://doc.traefik.io/traefik/middlewares/http/addprefix/)** — prepends a path segment to requests ([Traefik Docs][6], [Traefik Docs][7])
* **[BasicAuth](https://doc.traefik.io/traefik/middlewares/http/basicauth/)** — adds basic HTTP authentication ([Traefik Docs][8])
* **[IPAllowList (HTTP)](https://doc.traefik.io/traefik/middlewares/http/ipallowlist/)** — allows access only from specified IPs ([Traefik Docs][9])
* **[IPWhiteList (TCP)](https://doc.traefik.io/traefik/middlewares/tcp/ipwhitelist/)** — deprecated way to white-list TCP client IPs; prefer IPAllowList ([Traefik Docs][5])
(These are just a few examples—feel free to ask for more specific middleware links if needed.)
---
### Optional: Full Document Outline
If youd like the full reference structure in Markdown, here's a possible outline to expand further:
```
# Traefik Middlewares Reference
## Overview (link)
- Overview of Middlewares
## Configuration Examples
- Docker / Swarm
- Kubernetes CRD
- Consul Catalog
- File (YAML & TOML)
## HTTP Middlewares
- AddPrefix — [AddPrefix link]
- BasicAuth — [BasicAuth link]
- Buffering — [Buffering link]
- Chain — [Chain link]
- ... (and so on)
## TCP Middlewares
- IPAllowList (TCP) — [IPAllowList TCP link]
- (Any other TCP middleware)
## Additional Resources
- Kubernetes CRD Middleware — [CRD link]
- Routers and middleware chaining — [Routers link]
- Dynamic configuration via File provider — [File provider link]
```
[1]: https://doc.traefik.io/traefik/v2.2/middlewares/overview/?utm_source=chatgpt.com "Middlewares"
[2]: https://doc.traefik.io/traefik/middlewares/overview/?utm_source=chatgpt.com "Traefik Proxy Middleware Overview"
[3]: https://doc.traefik.io/traefik/reference/dynamic-configuration/file/?utm_source=chatgpt.com "Traefik File Dynamic Configuration"
[4]: https://doc.traefik.io/traefik/middlewares/http/overview/?utm_source=chatgpt.com "Traefik Proxy HTTP Middleware Overview"
[5]: https://doc.traefik.io/traefik/middlewares/tcp/ipwhitelist/?utm_source=chatgpt.com "Traefik TCP Middlewares IPWhiteList"
[6]: https://doc.traefik.io/traefik/routing/routers/?utm_source=chatgpt.com "Traefik Routers Documentation"
[7]: https://doc.traefik.io/traefik/middlewares/http/addprefix/?utm_source=chatgpt.com "Traefik AddPrefix Documentation"
[8]: https://doc.traefik.io/traefik/middlewares/http/basicauth/?utm_source=chatgpt.com "Traefik BasicAuth Documentation"
[9]: https://doc.traefik.io/traefik/middlewares/http/ipallowlist/?utm_source=chatgpt.com "Traefik HTTP Middlewares IPAllowList"

View File

@@ -0,0 +1,159 @@
# Traefik + Redis (KV provider): how to use it, where keys go, and how to notify Traefik
## 1) Enable the Redis provider (static config)
Add the Redis provider to Traefiks **install/static** configuration (YAML example):
```yaml
providers:
redis:
endpoints: # one or more Redis endpoints
- "127.0.0.1:6379"
rootKey: "traefik" # KV root/prefix (default: traefik)
db: 0 # optional
username: "" # optional
password: "" # optional
tls: # optional (use if Redis is TLS-enabled)
ca: /path/to/ca.crt
cert: /path/to/client.crt
key: /path/to/client.key
insecureSkipVerify: false
sentinel: # optional (if using Redis Sentinel)
masterName: my-master
# username/password/latencyStrategy/randomStrategy/replicaStrategy/useDisconnectedReplicas available
```
CLI equivalents (examples):
`--providers.redis.endpoints=127.0.0.1:6379 --providers.redis.rootkey=traefik --providers.redis.db=0` (see docs for all flags). ([Traefik Docs][1])
> **Important:** Traefik only *reads/watches* dynamic (routing) configuration from Redis. It doesnt store anything there automatically. You populate keys yourself (see §3). ([Traefik Docs][1])
---
## 2) “Notifying” Traefik about changes (Redis keyspace notifications)
To have Traefik react to updates **without restart**, Redis must have **keyspace notifications** enabled. A safe, common setting is:
```bash
# temporary (runtime):
redis-cli CONFIG SET notify-keyspace-events AKE
# verify:
redis-cli CONFIG GET notify-keyspace-events
```
Or set `notify-keyspace-events AKE` in `redis.conf`, or via your cloud providers parameter group (e.g., ElastiCache / Memorystore). ([Traefik Docs][1], [Redis][2], [Traefik Labs Community Forum][3])
> Notes
>
> * Managed Redis services often **disable** these notifications by default for performance reasons—enable them explicitly. ([Traefik Docs][1])
> * `AKE` means “all” (`A`) generic/string/list/set/zset/stream + keyspace (`K`) + keyevent (`E`) messages. ([TECHCOMMUNITY.MICROSOFT.COM][4])
---
## 3) Where values must live in Redis (key layout)
Traefik expects a **hierarchical path** under `rootKey` (default `traefik`). You set **one string value per path**. Examples below show minimal keys for an HTTP route + service.
### 3.1 Minimal HTTP router + service
```
traefik/http/routers/myrouter/rule = Host(`kv.example.com`)
traefik/http/routers/myrouter/entryPoints/0 = web
traefik/http/routers/myrouter/entryPoints/1 = websecure
traefik/http/routers/myrouter/service = myservice
traefik/http/services/myservice/loadBalancer/servers/0/url = http://10.0.10.5:8080
traefik/http/services/myservice/loadBalancer/servers/1/url = http://10.0.10.6:8080
```
(Write these with `redis-cli SET <key> "<value>"`.) ([Traefik Docs][5])
### 3.2 Add middlewares and TLS (optional)
```
traefik/http/routers/myrouter/middlewares/0 = auth
traefik/http/routers/myrouter/middlewares/1 = prefix
traefik/http/routers/myrouter/tls = true
traefik/http/routers/myrouter/tls/certResolver = myresolver
traefik/http/routers/myrouter/tls/domains/0/main = example.org
traefik/http/routers/myrouter/tls/domains/0/sans/0 = dev.example.org
```
([Traefik Docs][5])
### 3.3 TCP example (e.g., pass-through services)
```
traefik/tcp/routers/mytcprouter/rule = HostSNI(`*`)
traefik/tcp/routers/mytcprouter/entryPoints/0 = redis-tcp
traefik/tcp/routers/mytcprouter/service = mytcpservice
traefik/tcp/routers/mytcprouter/tls/passthrough = true
traefik/tcp/services/mytcpservice/loadBalancer/servers/0/address = 10.0.10.7:6379
```
([Traefik Docs][6])
> The full KV reference (all keys for routers/services/middlewares/TLS/options/observability) is here and shows many more fields you can set. ([Traefik Docs][6])
---
## 4) End-to-end quickstart (commands you can paste)
```bash
# 1) Enable keyspace notifications (see §2)
redis-cli CONFIG SET notify-keyspace-events AKE
# 2) Create minimal HTTP route + service (see §3.1)
redis-cli SET traefik/http/routers/myrouter/rule "Host(`kv.example.com`)"
redis-cli SET traefik/http/routers/myrouter/entryPoints/0 "web"
redis-cli SET traefik/http/routers/myrouter/entryPoints/1 "websecure"
redis-cli SET traefik/http/routers/myrouter/service "myservice"
redis-cli SET traefik/http/services/myservice/loadBalancer/servers/0/url "http://10.0.10.5:8080"
redis-cli SET traefik/http/services/myservice/loadBalancer/servers/1/url "http://10.0.10.6:8080"
```
Traefik will pick these up automatically (no restart) once keyspace notifications are on. ([Traefik Docs][1])
---
## 5) Operational tips / gotchas
* **Managed Redis**: enable `notify-keyspace-events` (e.g., ElastiCache parameter group; Memorystore config). Without it, Traefik wont react to updates. ([Traefik Docs][1], [Traefik Labs Community Forum][3])
* **Persistence**: if you want the config to survive Redis restarts, enable AOF or snapshots per your ops policy. (General Redis ops guidance.) ([JupyterHub Traefik Proxy][7])
* **Sentinel / TLS**: configure the provider fields accordingly (see §1). ([Traefik Docs][1])
* **Deletions**: some users reported recent Traefik versions not always propagating *deletions* from Redis the same way as updates—test your workflow; if needed, set empty values or overwrite keys. Track open issues. ([GitHub][8], [Traefik Labs Community Forum][9])
---
## 6) Checklist
1. Traefik started with `providers.redis` pointing at your Redis. ([Traefik Docs][1])
2. `notify-keyspace-events` enabled (e.g., `AKE`). ([Traefik Docs][1], [Redis][2])
3. Keys created under `rootKey` (default `traefik`) following the **KV path schema** shown above. ([Traefik Docs][5])
4. Verify in Traefik dashboard/API that routers/services appear. (General provider behavior.) ([Traefik Docs][10])
---
### Sources
* Traefik Redis provider docs (static options & keyspace note). ([Traefik Docs][1])
* KV dynamic configuration reference (full key paths). ([Traefik Docs][6])
* KV provider routing examples (HTTP services/routers). ([Traefik Docs][5])
* Example KV layout (Hub ref, same model). ([Traefik Docs][11])
* Redis keyspace notifications (what `AKE` means). ([Redis][2], [TECHCOMMUNITY.MICROSOFT.COM][4])
If you want, I can output the exact `redis-cli` commands for any specific route/service you plan to add.
[1]: https://doc.traefik.io/traefik/providers/redis/ "Traefik Redis Documentation - Traefik"
[2]: https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/?utm_source=chatgpt.com "Redis keyspace notifications | Docs"
[3]: https://community.traefik.io/t/traefik-not-re-configuring-using-aws-elasticicache-redis-on-change/5227?utm_source=chatgpt.com "Traefik not re-configuring using AWS Elasticicache Redis ..."
[4]: https://techcommunity.microsoft.com/blog/azurepaasblog/redis-keyspace-events-notifications/1551134?utm_source=chatgpt.com "Redis Keyspace Events Notifications"
[5]: https://doc.traefik.io/traefik/routing/providers/kv/ "Traefik Routing Configuration with KV stores - Traefik"
[6]: https://doc.traefik.io/traefik/reference/dynamic-configuration/kv/ "Traefik Dynamic Configuration with KV stores - Traefik"
[7]: https://jupyterhub-traefik-proxy.readthedocs.io/en/stable/redis.html?utm_source=chatgpt.com "Using TraefikRedisProxy - JupyterHub Traefik Proxy"
[8]: https://github.com/traefik/traefik/issues/11864?utm_source=chatgpt.com "Traefik does not handle rules deletion from redis kv #11864"
[9]: https://community.traefik.io/t/traefik-does-not-prune-deleted-rules-from-redis-kv/27789?utm_source=chatgpt.com "Traefik does not prune deleted rules from redis KV"
[10]: https://doc.traefik.io/traefik/providers/overview/?utm_source=chatgpt.com "Traefik Configuration Discovery Overview"
[11]: https://doc.traefik.io/traefik-hub/api-gateway/reference/ref-overview?utm_source=chatgpt.com "Install vs Routing Configuration | Traefik Hub Documentation"

View File

@@ -0,0 +1,229 @@
# Traefik Routers — Practical Guide
A **router** connects incoming traffic to a target **service**. It matches requests (or connections), optionally runs **middlewares**, and forwards to the chosen **service**. ([Traefik Docs][1])
---
## Quick examples
```yaml
# Dynamic (file provider) — HTTP: /foo -> service-foo
http:
routers:
my-router:
rule: Path(`/foo`)
service: service-foo
```
```toml
# Dynamic (file provider) — HTTP: /foo -> service-foo
[http.routers.my-router]
rule = "Path(`/foo`)"
service = "service-foo"
```
```yaml
# Dynamic — TCP: all non-TLS on :3306 -> database
tcp:
routers:
to-database:
entryPoints: ["mysql"]
rule: HostSNI(`*`)
service: database
```
```yaml
# Static — define entrypoints
entryPoints:
web: { address: ":80" }
mysql: { address: ":3306" }
```
([Traefik Docs][1])
---
## HTTP Routers
### EntryPoints
* If omitted, an HTTP router listens on all default entry points; set `entryPoints` to scope it. ([Traefik Docs][1])
```yaml
http:
routers:
r1:
rule: Host(`example.com`)
service: s1
entryPoints: ["web","websecure"]
```
### Rule (matchers)
A **rule** activates the router when it matches; then middlewares run, then the request is sent to the service. Common matchers (v3 syntax):
* `Host(...)`, `HostRegexp(...)`
* `Path(...)`, `PathPrefix(...)`, `PathRegexp(...)`
* `Header(...)`, `HeaderRegexp(...)`
* `Method(...)`
* `Query(...)`, `QueryRegexp(...)`
* `ClientIP(...)`
See the full table in the official page. ([Traefik Docs][1])
### Priority
Routers sort by **rule length** (desc) when `priority` is unset. Set `priority` to override (Max: `MaxInt32-1000` on 32-bit, `MaxInt64-1000` on 64-bit). ([Traefik Docs][1])
### Rule Syntax (`ruleSyntax`)
* Traefik v3 introduces a new rule syntax; you can set per-router `ruleSyntax: v2|v3`.
* Default inherits from static `defaultRuleSyntax` (defaults to `v3`). ([Traefik Docs][1])
### Middlewares
Attach a **list** in order; names cannot contain `@`. Applied only if the rule matches. ([Traefik Docs][1])
```yaml
http:
routers:
r-auth:
rule: Path(`/foo`)
middlewares: [authentication]
service: service-foo
```
### Service
Every HTTP router must target an **HTTP service** (not TCP). Some label-based providers auto-create defaults. ([Traefik Docs][1])
### TLS (HTTPS termination)
* Adding a `tls` section makes the router **HTTPS-only** and **terminates TLS** by default.
* To serve **both HTTP and HTTPS**, define **two routers**: one with `tls: {}` and one without.
* `tls.options`, `tls.certResolver`, and `tls.domains` follow the HTTP TLS reference. ([Traefik Docs][1])
### Observability (per-router)
Per-router toggles for `accessLogs`, `metrics`, `tracing`. Router-level settings override entrypoint defaults, but require the global features enabled first. Internal resources obey `AddInternals` guards. ([Traefik Docs][1])
```yaml
http:
routers:
r:
rule: Path(`/foo`)
service: s
observability:
accessLogs: false
metrics: false
tracing: false
```
---
## TCP Routers
### General
* If HTTP and TCP routers listen on the **same entry point**, **TCP routers apply first**; if none matches, HTTP routers take over.
* Names cannot contain `@`. ([Traefik Docs][1])
### EntryPoints & “server-first” protocols
* Omit `entryPoints` → listens on all default.
* For **server-first** protocols (e.g., SMTP), ensure **no TLS routers** exist on that entry point and have **at least one non-TLS TCP router** to avoid deadlocks (both sides waiting). ([Traefik Docs][1])
### Rule (matchers)
* `HostSNI(...)`, `HostSNIRegexp(...)` (for TLS SNI)
* `ClientIP(...)`
* `ALPN(...)`
Same flow: match → middlewares → service. ([Traefik Docs][1])
### Priority & Rule Syntax
* Same priority model as HTTP; set `priority` to override.
* `ruleSyntax: v2|v3` supported per router (example below). ([Traefik Docs][1])
```yaml
tcp:
routers:
r-v3:
rule: ClientIP(`192.168.0.11`) || ClientIP(`192.168.0.12`)
ruleSyntax: v3
service: s1
r-v2:
rule: ClientIP(`192.168.0.11`, `192.168.0.12`)
ruleSyntax: v2
service: s2
```
### Middlewares
Order matters; names cannot contain `@`. ([Traefik Docs][1])
### Services
TCP routers **must** target **TCP services** (not HTTP). ([Traefik Docs][1])
### TLS
* Adding `tls` makes the router **TLS-only**.
* Default is **TLS termination**; set `tls.passthrough: true` to forward encrypted bytes unchanged.
* `tls.options` (cipher suites, versions), `tls.certResolver`, `tls.domains` are supported when `HostSNI` is defined. ([Traefik Docs][1])
```yaml
tcp:
routers:
r-pass:
rule: HostSNI(`db.example.com`)
service: db
tls:
passthrough: true
```
**Postgres STARTTLS:** Traefik can detect Postgres STARTTLS negotiation and proceed with TLS routing; prefer client `sslmode=require`. Be careful with TLS passthrough and certain `sslmode` values. ([Traefik Docs][1])
---
## UDP Routers
### General
* UDP has no URL or SNI to match; UDP “routers” are effectively **load-balancers** with no rule criteria.
* Traefik maintains **sessions** (with a **timeout**) to map backend responses to clients. Configure timeout via `entryPoints.<name>.udp.timeout`. Names cannot contain `@`. ([Traefik Docs][1])
### EntryPoints
* Omit `entryPoints` → listens on all **UDP** entry points; specify to scope. ([Traefik Docs][1])
```yaml
udp:
routers:
r:
entryPoints: ["streaming"]
service: s1
```
### Services
UDP routers **must** target **UDP services** (not HTTP/TCP). ([Traefik Docs][1])
---
## Tips & gotchas
* `@` is **not allowed** in router, middleware, or service names. ([Traefik Docs][1])
* To serve the **same route on HTTP and HTTPS**, create **two routers** (with and without `tls`). ([Traefik Docs][1])
* Priority defaults to **rule length**; explicit `priority` wins and is often needed when a specific case should beat a broader matcher. ([Traefik Docs][1])
* **TCP vs HTTP precedence** on the same entry point: **TCP first**. ([Traefik Docs][1])
---
### Sources
Official Traefik docs — **Routers** (HTTP/TCP/UDP), examples, TLS, observability. ([Traefik Docs][1])
If you want this as a separate `.md` file in a specific structure (e.g., your repo), tell me the filename/path and Ill format it accordingly.
[1]: https://doc.traefik.io/traefik/routing/routers/ "Traefik Routers Documentation - Traefik"

View File

@@ -0,0 +1,263 @@
# Traefik Services (HTTP/TCP/UDP)
Services define **how Traefik reaches your backends** and how requests are **load-balanced** across them. Every service has a load balancer—even with a single server. ([Traefik Docs][1])
---
## Quick examples
```yaml
# Dynamic config (file provider)
http:
services:
web:
loadBalancer:
servers:
- url: "http://10.0.0.11:8080/"
- url: "http://10.0.0.12:8080/"
tcp:
services:
db:
loadBalancer:
servers:
- address: "10.0.0.21:5432"
- address: "10.0.0.22:5432"
udp:
services:
dns:
loadBalancer:
servers:
- address: "10.0.0.31:53"
- address: "10.0.0.32:53"
```
([Traefik Docs][1])
---
## HTTP services
### Servers Load Balancer
* **servers\[].url** each backend instance.
* **preservePath** keep the path segment of the URL when forwarding (note: not preserved for health-check requests). ([Traefik Docs][1])
```yaml
http:
services:
api:
loadBalancer:
servers:
- url: "http://10.0.0.10/base"
preservePath: true
```
#### Load-balancing strategy
* **WRR (default)** optional **weight** per server.
* **P2C** “power of two choices”; picks two random servers, chooses the one with fewer active requests. ([Traefik Docs][1])
```yaml
# WRR with weights
http:
services:
api:
loadBalancer:
servers:
- url: "http://10.0.0.10/"; weight: 2
- url: "http://10.0.0.11/"; weight: 1
# P2C
http:
services:
api:
loadBalancer:
strategy: p2c
servers:
- url: "http://10.0.0.10/"
- url: "http://10.0.0.11/"
- url: "http://10.0.0.12/"
```
([Traefik Docs][1])
#### Sticky sessions
Adds an affinity cookie so subsequent requests hit the same server.
* Works across nested LBs if stickiness is enabled at **each** level.
* If the chosen server becomes unhealthy, Traefik selects a new one and updates the cookie.
* Cookie options: `name`, `secure`, `httpOnly`, `sameSite`, `domain`, `maxAge`. ([Traefik Docs][1])
```yaml
http:
services:
web:
loadBalancer:
sticky:
cookie:
name: app_affinity
secure: true
httpOnly: true
sameSite: lax
domain: example.com
```
#### Health check
Periodically probes backends and **removes unhealthy servers** from rotation.
* HTTP(S): healthy if status is 2xx/3xx (or a configured status).
* gRPC: healthy if it returns `SERVING` (gRPC health v1).
* Options include `path`, `interval`, `timeout`, `scheme`, `hostname`, `port`. ([Traefik Docs][1])
```yaml
http:
services:
web:
loadBalancer:
healthCheck:
path: /health
interval: 10s
timeout: 3s
```
#### Pass Host Header
Controls forwarding of the original `Host` header. **Default: true**. ([Traefik Docs][1])
```yaml
http:
services:
web:
loadBalancer:
passHostHeader: false
```
#### ServersTransport (HTTP)
Fine-tunes the connection from Traefik to your upstreams.
* TLS: `serverName`, `certificates`, `insecureSkipVerify`, `rootCAs`, `peerCertURI`, SPIFFE (`spiffe.ids`, `spiffe.trustDomain`)
* HTTP/2 toggle: `disableHTTP2`
* Pooling: `maxIdleConnsPerHost`
* Timeouts (`forwardingTimeouts`): `dialTimeout`, `responseHeaderTimeout`, `idleConnTimeout`, `readIdleTimeout`, `pingTimeout`
Attach by name via `loadBalancer.serversTransport`. ([Traefik Docs][1])
```yaml
http:
serversTransports:
mtls:
rootCAs:
- /etc/ssl/my-ca.pem
serverName: backend.internal
insecureSkipVerify: false
forwardingTimeouts:
responseHeaderTimeout: "1s"
http:
services:
web:
loadBalancer:
serversTransport: mtls
servers:
- url: "https://10.0.0.10:8443/"
```
#### Response forwarding
Control how Traefik flushes response bytes to clients.
* `flushInterval` (ms): default **100**; negative = flush after each write; streaming responses are auto-flushed. ([Traefik Docs][1])
```yaml
http:
services:
streamy:
loadBalancer:
responseForwarding:
flushInterval: 50
```
---
## Composite HTTP services
### Weighted Round Robin (service)
Combine **services** (not just servers) with weights; health status propagates upward if enabled. ([Traefik Docs][1])
### Mirroring (service)
Send requests to a **main service** and mirror a percentage to others.
* Defaults: `percent` = 0 (no traffic), `mirrorBody` = true, `maxBodySize` = -1 (unlimited).
* Providers: File, CRD IngressRoute.
* Health status can propagate upward (File provider). ([Traefik Docs][1])
```yaml
http:
services:
mirrored-api:
mirroring:
service: appv1
mirrorBody: false
maxBodySize: 1024
mirrors:
- name: appv2
percent: 10
```
### Failover (service)
Route to **fallback** only when **main** is unreachable (relies on HealthCheck).
* Currently available with the **File** provider.
* HealthCheck on a Failover service requires all descendants to also enable it. ([Traefik Docs][1])
```yaml
http:
services:
app:
failover:
service: main
fallback: backup
main:
loadBalancer:
healthCheck: { path: /status, interval: 10s, timeout: 3s }
servers: [{ url: "http://10.0.0.50/" }]
backup:
loadBalancer:
servers: [{ url: "http://10.0.0.60/" }]
```
---
## TCP services (summary)
* **servers\[].address** (`host:port`), optional **tls** to upstream, attach a **ServersTransport** (TCP) with `dialTimeout`, `dialKeepAlive`, `terminationDelay`, TLS/SPIFEE options, and optional **PROXY Protocol** send. ([Traefik Docs][1])
---
## UDP services (summary)
* **servers\[].address** (`host:port`). Weighted round robin supported. ([Traefik Docs][1])
---
## Notes & gotchas
* Stickiness across nested load balancers requires enabling sticky at **each** level, and clients will carry **multiple key/value pairs** in the cookie. ([Traefik Docs][1])
* Health checks: enabling at a parent requires **all descendants** to support/enable it; otherwise service creation fails (applies to Mirroring/Failover health-check sections). ([Traefik Docs][1])
---
**Source:** Traefik “Routing & Load Balancing → Services” (current docs). ([Traefik Docs][1])
[1]: https://doc.traefik.io/traefik/routing/services/ "Traefik Services Documentation - Traefik"

View File

@@ -16,14 +16,14 @@ console.print_header("BUILDAH Demo.")
//if herocompile on, then will forced compile hero, which might be needed in debug mode for hero
// to execute hero scripts inside build container
mut pm:=herocontainers.new(herocompile=true)!
//mut b:=pm.builder_new(name:"test")!
mut factory:=herocontainers.new(herocompile=true)!
//mut b:=factory.builder_new(name:"test")!
//create
pm.builderv_create()!
factory.builderv_create()!
//get the container
//mut b2:=pm.builder_get("builderv")!
//mut b2:=factory.builder_get("builderv")!
//b2.shell()!