Merge branch 'development' into development_builders

* development:
  minor example fixes
  feat: add comprehensive SSH agent management command
  refactor: Harden and improve SSH agent module
  ...
  feat: add editable ttyd dashboard mode
  feat: add CLI for dashboard management and 4-pane layout
  fix: Fix build
  ...
  refactor: update SSH agent examples and module structure
  feat: add tmux dashboard with ttyd integration
  refactor: Remove is_tmux_server_not_running_error function
  wip: pushing the code to sync in other branch
  refactor: Improve tmux API consistency and formatting

# Conflicts:
#	lib/osal/core/net.v
#	lib/virt/podman/factory.v
This commit is contained in:
2025-08-28 14:26:06 +02:00
36 changed files with 3174 additions and 2111 deletions

View File

@@ -1,9 +1,6 @@
module base
import freeflowuniverse.herolib.data.paramsparser
import freeflowuniverse.herolib.ui
import freeflowuniverse.herolib.ui.console
import crypto.md5
@[params]
pub struct ContextConfigArgs {

View File

@@ -1,5 +1,6 @@
module herocmds
import os
import freeflowuniverse.herolib.osal.sshagent
import freeflowuniverse.herolib.ui.console
import freeflowuniverse.herolib.ui
@@ -7,33 +8,88 @@ import cli { Command, Flag }
pub fn cmd_sshagent(mut cmdroot Command) {
mut cmd_run := Command{
name: 'sshagent'
description: 'Work with SSHAgent'
// required_args: 1
usage: 'sub commands of generate are list, generate, unload, load'
name: 'sshagent'
description: 'Comprehensive SSH Agent Management'
usage: '
Hero SSH Agent Management Tool
COMMANDS:
profile Initialize SSH agent with smart key loading
list List available SSH keys
generate <name> Generate new SSH key
load <name> Load SSH key into agent
forget <name> Remove SSH key from agent
reset Remove all loaded SSH keys
push <target> [key] Deploy SSH key to remote machine
auth <target> [key] Verify SSH key authorization
status Show SSH agent status
EXAMPLES:
hero sshagent profile
hero sshagent push user@server.com
hero sshagent push user@server.com:2222 my_key
hero sshagent auth user@server.com
hero sshagent load my_key
hero sshagent status
TARGET FORMAT:
user@hostname[:port] # Port defaults to 22
'
execute: cmd_sshagent_execute
sort_commands: true
}
// Profile command - primary initialization
mut sshagent_command_profile := Command{
sort_flags: true
name: 'profile'
execute: cmd_sshagent_execute
description: 'Initialize SSH agent with smart key loading and shell integration'
}
mut sshagent_command_list := Command{
sort_flags: true
name: 'list'
execute: cmd_sshagent_execute
description: 'list ssh-keys.'
description: 'List available SSH keys and their status'
}
mut sshagent_command_generate := Command{
sort_flags: true
name: 'generate'
execute: cmd_sshagent_execute
description: 'generate ssh-key.'
description: 'Generate new SSH key pair'
}
mut sshagent_command_add := Command{
sort_flags: true
name: 'add'
execute: cmd_sshagent_execute
description: 'add a key starting from private key, only works interactive for nows.'
description: 'Add existing private key to SSH agent'
}
// Status command
mut sshagent_command_status := Command{
sort_flags: true
name: 'status'
execute: cmd_sshagent_execute
description: 'Show SSH agent status and diagnostics'
}
// Push command for remote deployment
mut sshagent_command_push := Command{
sort_flags: true
name: 'push'
execute: cmd_sshagent_execute
description: 'Deploy SSH key to remote machine'
}
// Auth command for verification
mut sshagent_command_auth := Command{
sort_flags: true
name: 'auth'
execute: cmd_sshagent_execute
description: 'Verify SSH key authorization on remote machine'
}
sshagent_command_generate.add_flag(Flag{
@@ -43,6 +99,37 @@ pub fn cmd_sshagent(mut cmdroot Command) {
description: 'should key be loaded'
})
// Add target flag for push and auth commands
sshagent_command_push.add_flag(Flag{
flag: .string
name: 'target'
abbrev: 't'
required: true
description: 'target in format user@hostname[:port]'
})
sshagent_command_push.add_flag(Flag{
flag: .string
name: 'key'
abbrev: 'k'
description: 'specific key name to deploy (optional)'
})
sshagent_command_auth.add_flag(Flag{
flag: .string
name: 'target'
abbrev: 't'
required: true
description: 'target in format user@hostname[:port]'
})
sshagent_command_auth.add_flag(Flag{
flag: .string
name: 'key'
abbrev: 'k'
description: 'specific key name to verify (optional)'
})
mut sshagent_command_load := Command{
sort_flags: true
name: 'load'
@@ -64,6 +151,7 @@ pub fn cmd_sshagent(mut cmdroot Command) {
description: 'Reset all keys, means unload them all.'
}
// Commands that require a name parameter
mut allcmdsref_gen0 := [&sshagent_command_generate, &sshagent_command_load, &sshagent_command_unload,
&sshagent_command_reset, &sshagent_command_add]
for mut d in allcmdsref_gen0 {
@@ -76,63 +164,486 @@ pub fn cmd_sshagent(mut cmdroot Command) {
})
}
// Commands that support script mode
mut allcmdsref_gen := [&sshagent_command_list, &sshagent_command_generate, &sshagent_command_load,
&sshagent_command_unload, &sshagent_command_reset]
&sshagent_command_unload, &sshagent_command_reset, &sshagent_command_status]
for mut c in allcmdsref_gen {
// c.add_flag(Flag{
// flag: .bool
// name: 'reset'
// abbrev: 'r'
// description: 'do you want to reset all? Dangerous!'
// })
c.add_flag(Flag{
flag: .bool
name: 'script'
abbrev: 's'
description: 'runs non interactive!'
})
cmd_run.add_command(*c)
}
// Add all commands to the main sshagent command
cmd_run.add_command(sshagent_command_profile)
cmd_run.add_command(sshagent_command_list)
cmd_run.add_command(sshagent_command_generate)
cmd_run.add_command(sshagent_command_add)
cmd_run.add_command(sshagent_command_load)
cmd_run.add_command(sshagent_command_unload)
cmd_run.add_command(sshagent_command_reset)
cmd_run.add_command(sshagent_command_status)
cmd_run.add_command(sshagent_command_push)
cmd_run.add_command(sshagent_command_auth)
cmdroot.add_command(cmd_run)
}
fn cmd_sshagent_execute(cmd Command) ! {
// mut reset := cmd.flags.get_bool('reset') or {false }
mut isscript := cmd.flags.get_bool('script') or { false }
mut load := cmd.flags.get_bool('load') or { false }
mut name := cmd.flags.get_string('name') or { '' }
mut target := cmd.flags.get_string('target') or { '' }
mut key := cmd.flags.get_string('key') or { '' }
mut agent := sshagent.new()!
if cmd.name == 'list' {
if !isscript {
console.clear()
match cmd.name {
'profile' {
cmd_profile_execute(mut agent, isscript)!
}
console.print_debug(agent.str())
} else if cmd.name == 'generate' {
agent.generate(name, '')!
if load {
agent.load(name)!
'list' {
cmd_list_execute(mut agent, isscript)!
}
'generate' {
cmd_generate_execute(mut agent, name, load)!
}
'load' {
cmd_load_execute(mut agent, name)!
}
'forget' {
cmd_forget_execute(mut agent, name)!
}
'reset' {
cmd_reset_execute(mut agent, isscript)!
}
'add' {
cmd_add_execute(mut agent, name)!
}
'status' {
cmd_status_execute(mut agent)!
}
'push' {
cmd_push_execute(mut agent, target, key)!
}
'auth' {
cmd_auth_execute(mut agent, target, key)!
}
else {
return error(cmd.help_message())
}
} else if cmd.name == 'load' {
agent.load(name)!
} else if cmd.name == 'forget' {
agent.forget(name)!
} else if cmd.name == 'reset' {
agent.reset()!
} else if cmd.name == 'add' {
panic("can't work, no support for multiline yet")
mut myui := ui.new()!
privkey := myui.ask_question(
question: 'private key of your ssh key'
)!
agent.add(name, privkey)!
} else {
// console.print_debug(1)
return error(cmd.help_message())
// console.print_debug(" Supported commands are: ${gentools.gencmds}")
// return error('unknown subcmd')
}
}
// Profile command - comprehensive SSH agent initialization
fn cmd_profile_execute(mut agent sshagent.SSHAgent, isscript bool) ! {
console.print_header('🔑 Hero SSH Agent Profile Initialization')
// Ensure single agent instance
agent.ensure_single_agent()!
console.print_green(' SSH agent instance verified')
// Smart key loading
available_keys := agent.keys
loaded_keys := agent.keys_loaded()!
console.print_debug('Found ${available_keys.len} available keys, ${loaded_keys.len} loaded')
// If only one key and none loaded, auto-load it
if available_keys.len == 1 && loaded_keys.len == 0 {
key_name := available_keys[0].name
console.print_debug('Auto-loading single key: ${key_name}')
mut key := agent.get(name: key_name) or {
console.print_stderr('Failed to get key: ${err}')
return
}
key.load() or { console.print_debug('Key loading failed (may need passphrase): ${err}') }
}
// Update shell profile
update_shell_profile()!
console.print_green(' SSH agent profile initialized successfully')
cmd_status_execute(mut agent)!
}
// Update shell profile with SSH agent initialization
fn update_shell_profile() ! {
home := os.home_dir()
ssh_dir := '${home}/.ssh'
socket_path := '${ssh_dir}/hero-agent.sock'
// Find appropriate profile file
profile_candidates := [
'${home}/.profile',
'${home}/.bash_profile',
'${home}/.bashrc',
'${home}/.zshrc',
]
mut profile_file := '${home}/.profile'
for candidate in profile_candidates {
if os.exists(candidate) {
profile_file = candidate
break
}
}
profile_content := if os.exists(profile_file) {
os.read_file(profile_file)!
} else {
''
}
hero_init_block := '
# Hero SSH Agent initialization
if [ -f "${socket_path}" ]; then
export SSH_AUTH_SOCK="${socket_path}"
fi'
// Check if already present
if profile_content.contains('Hero SSH Agent initialization') {
console.print_debug('Hero initialization already present in profile')
return
}
// Add hero initialization
updated_content := profile_content + hero_init_block
os.write_file(profile_file, updated_content)!
console.print_green(' Updated shell profile: ${profile_file}')
}
// List command
fn cmd_list_execute(mut agent sshagent.SSHAgent, isscript bool) ! {
if !isscript {
console.clear()
}
console.print_header('SSH Keys Status')
println(agent.str())
loaded_keys := agent.keys_loaded()!
if loaded_keys.len > 0 {
console.print_header('Currently Loaded Keys:')
for key in loaded_keys {
console.print_item('- ${key.name} (${key.cat})')
}
} else {
console.print_debug('No keys currently loaded in agent')
}
}
// Generate command
fn cmd_generate_execute(mut agent sshagent.SSHAgent, name string, load bool) ! {
if name == '' {
return error('Key name is required for generate command')
}
console.print_debug('Generating SSH key: ${name}')
mut key := agent.generate(name, '')!
console.print_green(' Generated SSH key: ${name}')
if load {
console.print_debug('Loading key into agent...')
key.load() or {
console.print_stderr('Failed to load key: ${err}')
return
}
console.print_green(' Key loaded into agent')
}
}
// Load command
fn cmd_load_execute(mut agent sshagent.SSHAgent, name string) ! {
if name == '' {
return error('Key name is required for load command')
}
mut key := agent.get(name: name) or { return error('SSH key "${name}" not found') }
console.print_debug('Loading SSH key: ${name}')
key.load()!
console.print_green(' SSH key "${name}" loaded successfully')
}
// Forget command
fn cmd_forget_execute(mut agent sshagent.SSHAgent, name string) ! {
if name == '' {
return error('Key name is required for forget command')
}
console.print_debug('Removing SSH key from agent: ${name}')
agent.forget(name)!
console.print_green(' SSH key "${name}" removed from agent')
}
// Reset command
fn cmd_reset_execute(mut agent sshagent.SSHAgent, isscript bool) ! {
if !isscript {
print('This will remove all loaded SSH keys. Continue? (y/N): ')
input := os.input('')
if input.trim_space().to_lower() != 'y' {
console.print_debug('Reset cancelled')
return
}
}
console.print_debug('Resetting SSH agent - removing all keys')
agent.reset()!
console.print_green(' All SSH keys removed from agent')
}
// Add command
fn cmd_add_execute(mut agent sshagent.SSHAgent, name string) ! {
if name == '' {
return error('Key name is required for add command')
}
mut myui := ui.new()!
privkey := myui.ask_question(
question: 'Enter the private key content:'
)!
console.print_debug('Adding SSH key: ${name}')
agent.add(name, privkey)!
console.print_green(' SSH key "${name}" added successfully')
}
// Status command
fn cmd_status_execute(mut agent sshagent.SSHAgent) ! {
console.print_header('SSH Agent Status')
diag := agent.diagnostics()
for key, value in diag {
console.print_item('${key}: ${value}')
}
loaded_keys := agent.keys_loaded()!
if loaded_keys.len > 0 {
console.print_header('Loaded Keys:')
for key in loaded_keys {
console.print_item('- ${key.name} (${key.cat})')
}
} else {
console.print_debug('No keys currently loaded')
}
}
// Push command - deploy SSH key to remote machine
fn cmd_push_execute(mut agent sshagent.SSHAgent, target string, key_name string) ! {
if target == '' {
return error('Target is required for push command (format: user@hostname[:port])')
}
console.print_header('🚀 SSH Key Deployment')
// Parse target
parsed_target := parse_target(target)!
console.print_debug('Target: ${parsed_target.user}@${parsed_target.hostname}:${parsed_target.port}')
// Select key to deploy
mut selected_key := select_key_for_deployment(mut agent, key_name)!
console.print_debug('Selected key: ${selected_key.name}')
// Deploy key
deploy_key_to_target(mut selected_key, parsed_target)!
console.print_green(' SSH key deployed successfully to ${target}')
}
// Auth command - verify SSH key authorization
fn cmd_auth_execute(mut agent sshagent.SSHAgent, target string, key_name string) ! {
if target == '' {
return error('Target is required for auth command (format: user@hostname[:port])')
}
console.print_header('🔐 SSH Key Authorization Verification')
// Parse target
parsed_target := parse_target(target)!
// Select key to verify
mut selected_key := select_key_for_deployment(mut agent, key_name)!
// Verify authorization
verify_key_authorization(mut selected_key, parsed_target)!
console.print_green(' SSH key authorization verified for ${target}')
}
// Helper structures and functions for remote operations
struct RemoteTarget {
mut:
user string
hostname string
port int = 22
}
// Parse target string in format user@hostname[:port]
fn parse_target(target_str string) !RemoteTarget {
if !target_str.contains('@') {
return error('Target must be in format user@hostname[:port]')
}
parts := target_str.split('@')
if parts.len != 2 {
return error('Invalid target format: ${target_str}')
}
user := parts[0]
mut hostname := parts[1]
mut port := 22
// Check for port specification
if hostname.contains(':') {
host_port := hostname.split(':')
if host_port.len != 2 {
return error('Invalid hostname:port format: ${hostname}')
}
hostname = host_port[0]
port = host_port[1].int()
}
return RemoteTarget{
user: user
hostname: hostname
port: port
}
}
// Select appropriate key for deployment
fn select_key_for_deployment(mut agent sshagent.SSHAgent, key_name string) !sshagent.SSHKey {
available_keys := agent.keys
if available_keys.len == 0 {
return error('No SSH keys found. Generate a key first with: hero sshagent generate <name>')
}
// If specific key requested
if key_name.len > 0 {
for key in available_keys {
if key.name == key_name {
return key
}
}
return error('SSH key "${key_name}" not found')
}
// Auto-select if only one key
if available_keys.len == 1 {
console.print_debug('Auto-selecting single available key: ${available_keys[0].name}')
return available_keys[0]
}
// Interactive selection for multiple keys
return interactive_key_selection(available_keys)!
}
// Interactive key selection when multiple keys are available
fn interactive_key_selection(keys []sshagent.SSHKey) !sshagent.SSHKey {
console.print_header('Multiple SSH keys available:')
for i, key in keys {
console.print_item('${i + 1}. ${key.name} (${key.cat})')
}
print('Select key number (1-${keys.len}): ')
input := os.input('')
selection := input.trim_space().int() - 1
if selection < 0 || selection >= keys.len {
return error('Invalid selection: ${input}')
}
return keys[selection]
}
// Deploy key to remote target
fn deploy_key_to_target(mut key sshagent.SSHKey, target RemoteTarget) ! {
console.print_debug('Deploying key ${key.name} to ${target.user}@${target.hostname}')
// Get public key content
pub_key_content := key.keypub()!
// Use ssh-copy-id if available, otherwise manual deployment
if has_ssh_copy_id() {
deploy_with_ssh_copy_id(mut key, target)!
} else {
deploy_manually(pub_key_content, target)!
}
}
// Check if ssh-copy-id is available
fn has_ssh_copy_id() bool {
result := os.execute('which ssh-copy-id')
return result.exit_code == 0
}
// Deploy using ssh-copy-id
fn deploy_with_ssh_copy_id(mut key sshagent.SSHKey, target RemoteTarget) ! {
mut key_path := key.keypath()!
mut cmd := 'ssh-copy-id -i ${key_path.path}'
if target.port != 22 {
cmd += ' -p ${target.port}'
}
cmd += ' ${target.user}@${target.hostname}'
console.print_debug('Executing: ${cmd}')
result := os.execute(cmd)
if result.exit_code != 0 {
return error('ssh-copy-id failed: ${result.output}')
}
}
// Manual deployment by appending to authorized_keys
fn deploy_manually(pub_key_content string, target RemoteTarget) ! {
mut ssh_cmd := 'ssh'
if target.port != 22 {
ssh_cmd += ' -p ${target.port}'
}
// Command to append key to authorized_keys
remote_cmd := 'mkdir -p ~/.ssh && echo "${pub_key_content}" >> ~/.ssh/authorized_keys && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys'
full_cmd := '${ssh_cmd} ${target.user}@${target.hostname} "${remote_cmd}"'
console.print_debug('Executing manual deployment')
result := os.execute(full_cmd)
if result.exit_code != 0 {
return error('Manual key deployment failed: ${result.output}')
}
}
// Verify that key is properly authorized on remote target
fn verify_key_authorization(mut key sshagent.SSHKey, target RemoteTarget) ! {
console.print_debug('Verifying key authorization for ${key.name}')
// Test SSH connection
mut ssh_cmd := 'ssh -o BatchMode=yes -o ConnectTimeout=10'
if target.port != 22 {
ssh_cmd += ' -p ${target.port}'
}
ssh_cmd += ' ${target.user}@${target.hostname} "echo SSH_CONNECTION_SUCCESS"'
console.print_debug('Testing SSH connection...')
result := os.execute(ssh_cmd)
if result.exit_code != 0 {
return error('SSH connection failed: ${result.output}')
}
if !result.output.contains('SSH_CONNECTION_SUCCESS') {
return error('SSH connection test failed - unexpected output')
}
console.print_green(' SSH key is properly authorized and working')
}

View File

@@ -1,6 +1,5 @@
module core
import base
import os
// check path is accessible, e.g. do we need sudo and are we sudo

View File

@@ -1,7 +1,6 @@
module ipaddress
import os
import freeflowuniverse.herolib.osal.core as osal
import freeflowuniverse.herolib.core
import freeflowuniverse.herolib.ui.console

View File

@@ -1,6 +1,5 @@
module gittools
import freeflowuniverse.herolib.osal.sshagent
import freeflowuniverse.herolib.core.pathlib
import freeflowuniverse.herolib.ui.console
import freeflowuniverse.herolib.develop.vscode
@@ -106,7 +105,9 @@ fn (self GitRepo) get_repo_url_for_clone() !string {
// return url
// }
if sshagent.loaded() {
// Check if SSH agent is loaded (avoid importing sshagent to prevent circular dependency)
ssh_check := os.execute('ssh-add -l')
if ssh_check.exit_code == 0 {
return self.get_ssh_url()!
} else {
return self.get_http_url()!

View File

@@ -4,8 +4,11 @@ import net
import time
import freeflowuniverse.herolib.ui.console
import freeflowuniverse.herolib.core
<<<<<<< HEAD
import os
=======
>>>>>>> development
pub enum PingResult {
ok

View File

@@ -3,7 +3,6 @@ module core
import freeflowuniverse.herolib.ui.console
import freeflowuniverse.herolib.core.texttools
import freeflowuniverse.herolib.core
import os
// update the package list
pub fn package_refresh() ! {

View File

@@ -2,7 +2,6 @@ module linux
import os
import json
import freeflowuniverse.herolib.core.pathlib
import freeflowuniverse.herolib.osal.core as osal
import freeflowuniverse.herolib.ui.console
@@ -288,9 +287,43 @@ fn (mut lf LinuxFactory) create_ssh_agent_profile(username string) ! {
user_home := '/home/${username}'
profile_script := '${user_home}/.profile_sshagent'
// script_content := ''
// Create SSH agent auto-start script content
script_content := '#!/bin/bash
# Auto-start ssh-agent if not running
SSH_AGENT_SOCKET="/tmp/ssh-agent-${username}.sock"
panic('implement')
# Check if agent is already running and responsive
if [ -n "\$SSH_AUTH_SOCK" ] && ssh-add -l >/dev/null 2>&1; then
# Agent is running and responsive
exit 0
fi
# Check if our socket exists and is responsive
if [ -S "\$SSH_AGENT_SOCKET" ]; then
export SSH_AUTH_SOCK="\$SSH_AGENT_SOCKET"
if ssh-add -l >/dev/null 2>&1; then
# Socket is responsive
exit 0
fi
fi
# Clean up any orphaned agents
pkill -u ${username} ssh-agent >/dev/null 2>&1 || true
# Remove stale socket
rm -f "\$SSH_AGENT_SOCKET"
# Start new ssh-agent with consistent socket
ssh-agent -a "\$SSH_AGENT_SOCKET" >/dev/null 2>&1
# Export the socket path
export SSH_AUTH_SOCK="\$SSH_AGENT_SOCKET"
# Verify agent is responsive
if ! ssh-add -l >/dev/null 2>&1; then
echo "Warning: SSH agent started but is not responsive" >&2
fi
'
osal.file_write(profile_script, script_content)!
osal.exec(cmd: 'chown ${username}:${username} ${profile_script}')!

View File

@@ -0,0 +1,33 @@
### 1. **Network namespaces**
* Each namespace can have its own interfaces, routing table, firewall rules, etc.
* You can move the users processes into a network namespace that only has access to a given bridge.
Example:
```bash
# Create a new netns for user "alice"
ip netns add alice
# Add a veth pair
ip link add veth-alice type veth peer name veth-alice-br
# Attach one side to the bridge
ip link set veth-alice-br master br0
ip link set veth-alice-br up
# Move the other side into the netns
ip link set veth-alice netns alice
# Configure inside namespace
ip netns exec alice ip addr add 192.168.100.2/24 dev veth-alice
ip netns exec alice ip link set veth-alice up
ip netns exec alice ip route add default via 192.168.100.1
# Now run a shell in alices namespace
sudo -u alice ip netns exec alice bash
```
Now all processes run by `alice` will use only that veth → bridge → network.

View File

@@ -74,10 +74,10 @@ pub fn sshkey_delete(mut agent SSHAgent, name string) ! {
return
}
// Remove from agent if loaded (temporarily disabled due to reset_ssh panic)
// if key.loaded {
// key.forget()!
// }
// Remove from agent if loaded
if key.loaded {
key.forget()!
}
// Delete key files
if key_path.exists() {
@@ -148,62 +148,3 @@ pub fn sshkey_check(mut agent SSHAgent, name string) ! {
console.print_green(' SSH key "${name}" is valid')
}
// Copy private key to remote node
pub 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() or { return error('Failed to create builder') }
mut node := b.node_new(ipaddr: node_addr) or { return error('Failed to create node') }
// Get private key content
mut 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_map := node.environ_get() or {
return error('Could not get environment on remote node')
}
home_dir := home_dir_map['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
pub 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() or { return error('Failed to create builder') }
mut node := b.node_new(ipaddr: node_addr) or { return error('Failed to create node') }
// 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

@@ -96,3 +96,59 @@ pub fn (mut agent SSHAgent) verify_key_access(mut node builder.Node, key_name st
return result.contains('SSH key verification successful')
}
// Copy private key to remote node
pub 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
mut 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
pub 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

@@ -27,7 +27,7 @@ pub fn new(args_ SSHAgentNewArgs) !SSHAgent {
}
pub fn loaded() bool {
mut agent := new() or { panic(err) }
mut agent := new() or { return false }
return agent.active
}

View File

@@ -44,7 +44,8 @@ fn (mut agent SSHAgent) pop(pubkey_ string) {
if agent.keys.len > result {
agent.keys.delete(x)
} else {
panic('bug')
// This should not happen, but handle gracefully
return
}
}
}

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

@@ -0,0 +1,82 @@
module sshagent
import freeflowuniverse.herolib.core.playbook { PlayBook }
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

@@ -1,43 +1,81 @@
## ssh agent
# SSH Agent Module
SSH agent management library for V language. Provides secure key handling, agent lifecycle control, and remote integration.
## Features
* Manage SSH keys (generate, load, import)
* Single agent per user with auto-cleanup
* Start/stop/reset agent easily
* Diagnostics and status checks
* Push keys to remote nodes & verify access
* Security-first (file permissions, socket handling)
## Platform Support
* Linux, macOS
* Windows (not yet supported)
## Quick Start
```v
import freeflowuniverse.herolib.osal.sshagent
mut agent := sshagent.new()!
privkey:='
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDXf9Z/2AH8/8a1ppagCplQdhWyQ8wZAieUw3nNcxsDiQAAAIhb3ybRW98m
0QAAAAtzc2gtZWQyNTUxOQAAACDXf9Z/2AH8/8a1ppagCplQdhWyQ8wZAieUw3nNcxsDiQ
AAAEC+fcDBPqdJHlJOQJ2zXhU2FztKAIl3TmWkaGCPnyts49d/1n/YAfz/xrWmlqAKmVB2
FbJDzBkCJ5TDec1zGwOJAAAABWJvb2tz
-----END OPENSSH PRIVATE KEY-----
'
mut sshkey:=agent.add("mykey:,privkey)!
sshkey.forget()!
mut key := agent.generate('my_key', '')!
key.load()!
println(agent)
```
### hero
## Usage
there is also a hero command
```js
//will add the key and load (at this stage no support for passphrases)
!!sshagent.key_add name:'myname'
privkey:'
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDXf9Z/2AH8/8a1ppagCplQdhWyQ8wZAieUw3nNcxsDiQAAAIhb3ybRW98m
0QAAAAtzc2gtZWQysdsdsddsdsdsdsdsdsd8/8a1ppagCplQdhWyQ8wZAieUw3nNcxsDiQ
AAAEC+fcDBPqdJHlJOQJ2zXhU2FztKAIl3TmWkaGCPnyts49d/1n/YAfz/xrWmlqAKmVB2
FbJDzBkCJ5TDec1zGwOJAAAABWJvb2tz
-----END OPENSSH PRIVATE KEY-----
'
### Agent
```v
mut agent := sshagent.new()!
mut agent := sshagent.new(homepath: '/custom/ssh/path')!
mut agent := sshagent.new_single()!
```
### Keys
```v
mut key := agent.generate('my_key', '')!
agent.add('imported_key', privkey)!
key.load()!
if agent.exists(name: 'my_key') { println('Key exists') }
agent.forget('my_key')!
```
### Agent Ops
```v
println(agent.diagnostics())
println(agent.keys_loaded()!)
agent.reset()!
```
### Remote
```v
import freeflowuniverse.herolib.builder
mut node := builder.node_new(ipaddr: 'user@remote:22')!
agent.push_key_to_node(mut node, 'my_key')!
```
## Security
* Private keys set to `0600`
* Secure sockets & user isolation
* Validated inputs & safe memory handling
## Examples
See `examples/osal/sshagent/` for demos.
## Testing
```bash
v test lib/osal/sshagent/
```

View File

@@ -0,0 +1,241 @@
module sshagent
import os
import freeflowuniverse.herolib.core.texttools
// Security validation functions for SSH agent operations
// validate_key_name ensures SSH key names are safe and follow conventions
pub fn validate_key_name(name string) !string {
if name.len == 0 {
return error('SSH key name cannot be empty')
}
if name.len > 255 {
return error('SSH key name too long (max 255 characters)')
}
// Check for dangerous characters
dangerous_chars := ['/', '\\', '..', '~', '$', '`', ';', '|', '&', '>', '<', '*', '?', '[',
']', '{', '}', '(', ')', '"', "'", ' ']
for dangerous_char in dangerous_chars {
if name.contains(dangerous_char) {
return error('SSH key name contains invalid character: ${dangerous_char}')
}
}
// Ensure it starts with alphanumeric
if !name[0].is_alnum() {
return error('SSH key name must start with alphanumeric character')
}
return texttools.name_fix(name)
}
// validate_private_key checks if the provided string is a valid SSH private key
pub fn validate_private_key(privkey string) !string {
if privkey.len == 0 {
return error('Private key cannot be empty')
}
// Check for valid private key headers
valid_headers := [
'-----BEGIN OPENSSH PRIVATE KEY-----',
'-----BEGIN RSA PRIVATE KEY-----',
'-----BEGIN DSA PRIVATE KEY-----',
'-----BEGIN EC PRIVATE KEY-----',
'-----BEGIN PRIVATE KEY-----',
]
mut has_valid_header := false
for header in valid_headers {
if privkey.contains(header) {
has_valid_header = true
break
}
}
if !has_valid_header {
return error('Invalid private key format - missing valid header')
}
// Check for corresponding footer
valid_footers := [
'-----END OPENSSH PRIVATE KEY-----',
'-----END RSA PRIVATE KEY-----',
'-----END DSA PRIVATE KEY-----',
'-----END EC PRIVATE KEY-----',
'-----END PRIVATE KEY-----',
]
mut has_valid_footer := false
for footer in valid_footers {
if privkey.contains(footer) {
has_valid_footer = true
break
}
}
if !has_valid_footer {
return error('Invalid private key format - missing valid footer')
}
// Basic length check (private keys should be substantial)
if privkey.len < 200 {
return error('Private key appears to be too short')
}
return privkey
}
// validate_file_path ensures file paths are safe and within expected directories
pub fn validate_file_path(path string, base_dir string) !string {
if path.len == 0 {
return error('File path cannot be empty')
}
// Resolve absolute path
abs_path := os.abs_path(path)
abs_base := os.abs_path(base_dir)
// Ensure path is within base directory (prevent directory traversal)
if !abs_path.starts_with(abs_base) {
return error('File path outside of allowed directory: ${path}')
}
// Check for dangerous path components
dangerous_components := ['..', './', '~/', '$']
for component in dangerous_components {
if path.contains(component) {
return error('File path contains dangerous component: ${component}')
}
}
return abs_path
}
// secure_file_permissions sets appropriate permissions for SSH key files
pub fn secure_file_permissions(file_path string, is_private bool) ! {
if !os.exists(file_path) {
return error('File does not exist: ${file_path}')
}
if is_private {
// Private keys should be readable/writable only by owner
os.chmod(file_path, 0o600)!
} else {
// Public keys can be readable by others
os.chmod(file_path, 0o644)!
}
}
// get_secure_socket_path returns a secure socket path for the given user
pub fn get_secure_socket_path(user string) !string {
if user.len == 0 {
return error('User cannot be empty')
}
// Validate user name
validated_user := validate_key_name(user)!
// Use more secure temporary directory if available
mut temp_dir := '/tmp'
// Check for user-specific temp directory
user_temp := os.getenv('XDG_RUNTIME_DIR')
if user_temp.len > 0 && os.exists(user_temp) {
temp_dir = user_temp
}
socket_path := '${temp_dir}/ssh-agent-${validated_user}.sock'
// Ensure parent directory exists and has correct permissions
parent_dir := os.dir(socket_path)
if !os.exists(parent_dir) {
os.mkdir_all(parent_dir)!
os.chmod(parent_dir, 0o700)! // Only owner can access
}
return socket_path
}
// sanitize_environment_variables cleans SSH-related environment variables
pub fn sanitize_environment_variables() {
// List of SSH-related environment variables that might need cleaning
ssh_env_vars := ['SSH_AUTH_SOCK', 'SSH_AGENT_PID', 'SSH_CLIENT', 'SSH_CONNECTION']
for var in ssh_env_vars {
env_val := os.getenv(var)
if env_val.len > 0 {
// Basic validation of environment variable values
if env_val.contains('..') || env_val.contains(';') || env_val.contains('|') {
// Unset potentially dangerous environment variables
os.unsetenv(var)
}
}
}
}
// validate_passphrase checks passphrase strength (basic validation)
pub fn validate_passphrase(passphrase string) !string {
// Allow empty passphrase (user choice)
if passphrase.len == 0 {
return passphrase
}
// Basic length check
if passphrase.len < 8 {
return error('Passphrase should be at least 8 characters long')
}
// Check for common weak passphrases
weak_passphrases := ['password', '12345678', 'qwerty', 'admin', 'root', 'test']
for weak in weak_passphrases {
if passphrase.to_lower() == weak {
return error('Passphrase is too weak - avoid common passwords')
}
}
return passphrase
}
// check_system_security performs basic system security checks
pub fn check_system_security() !map[string]string {
mut security_status := map[string]string{}
// Check if running as root (generally not recommended)
if os.getuid() == 0 {
security_status['root_user'] = 'WARNING: Running as root user'
} else {
security_status['root_user'] = 'OK: Not running as root'
}
// Check SSH directory permissions
ssh_dir := '${os.home_dir()}/.ssh'
if os.exists(ssh_dir) {
// Get directory permissions (simplified check)
if os.is_readable(ssh_dir) && os.is_writable(ssh_dir) {
security_status['ssh_dir_permissions'] = 'OK: SSH directory accessible'
} else {
security_status['ssh_dir_permissions'] = 'WARNING: SSH directory permission issues'
}
} else {
security_status['ssh_dir_permissions'] = 'INFO: SSH directory does not exist'
}
// Check for SSH agent processes
user := os.getenv('USER')
res := os.execute('pgrep -u ${user} ssh-agent | wc -l')
if res.exit_code == 0 {
agent_count := res.output.trim_space().int()
if agent_count == 0 {
security_status['ssh_agents'] = 'INFO: No SSH agents running'
} else if agent_count == 1 {
security_status['ssh_agents'] = 'OK: One SSH agent running'
} else {
security_status['ssh_agents'] = 'WARNING: Multiple SSH agents running (${agent_count})'
}
}
return security_status
}

View File

@@ -34,9 +34,18 @@ pub fn (mut agent SSHAgent) ensure_single_agent() ! {
agent.active = true
}
// get consistent socket path per user
// get consistent socket path per user in home directory
fn get_agent_socket_path(user string) string {
return '/tmp/ssh-agent-${user}.sock'
home := os.home_dir()
ssh_dir := '${home}/.ssh'
// Ensure SSH directory exists with correct permissions
if !os.exists(ssh_dir) {
os.mkdir_all(ssh_dir) or { return '/tmp/ssh-agent-${user}.sock' }
os.chmod(ssh_dir, 0o700) or {}
}
return '${ssh_dir}/hero-agent.sock'
}
// check if current agent is responsive
@@ -155,7 +164,7 @@ pub fn (mut agent SSHAgent) init() ! {
if line.contains(' ') {
splitted := line.split(' ')
if splitted.len < 2 {
panic('bug')
return error('Invalid SSH key format in agent output: ${line}')
}
pubkey := splitted[1]
mut sshkey := SSHKey{
@@ -166,12 +175,12 @@ pub fn (mut agent SSHAgent) init() ! {
if splitted[0].contains('ed25519') {
sshkey.cat = .ed25519
if splitted.len > 2 {
sshkey.email = splitted[2] or { panic('bug') }
sshkey.email = splitted[2] or { '' }
}
} else if splitted[0].contains('rsa') {
sshkey.cat = .rsa
} else {
panic('bug: implement other cat for ssh-key.\n${line}')
return error('Unsupported SSH key type in line: ${line}')
}
if !(agent.exists(pubkey: pubkey)) {
@@ -191,7 +200,7 @@ pub fn (mut agent SSHAgent) init() ! {
c = c.replace(' ', ' ').replace(' ', ' ') // deal with double spaces, or tripple (need to do this 2x
splitted := c.trim_space().split(' ')
if splitted.len < 2 {
panic('bug')
return error('Invalid public key format in file: ${pkp.path}')
}
mut name := pkp.name()
name = name[0..(name.len - 4)]
@@ -211,7 +220,7 @@ pub fn (mut agent SSHAgent) init() ! {
} else if splitted[0].contains('rsa') {
sshkey2.cat = .rsa
} else {
panic('bug: implement other cat for ssh-key')
return error('Unsupported SSH key type in file: ${pkp.path}')
}
if splitted.len > 2 {
sshkey2.email = splitted[2]
@@ -223,53 +232,74 @@ pub fn (mut agent SSHAgent) init() ! {
// returns path to sshkey
pub fn (mut agent SSHAgent) generate(name string, passphrase string) !SSHKey {
dest := '${agent.homepath.path}/${name}'
// Validate inputs
validated_name := validate_key_name(name)!
validated_passphrase := validate_passphrase(passphrase)!
dest := '${agent.homepath.path}/${validated_name}'
if os.exists(dest) {
os.rm(dest)!
}
cmd := 'ssh-keygen -t ed25519 -f ${dest} -P ${passphrase} -q'
cmd := 'ssh-keygen -t ed25519 -f ${dest} -P ${validated_passphrase} -q'
// console.print_debug(cmd)
rc := os.execute(cmd)
if !(rc.exit_code == 0) {
return error('Could not generated sshkey,\n${rc}')
return error('Could not generate SSH key: ${rc.output}')
}
// Set secure permissions
secure_file_permissions(dest, true)! // private key
secure_file_permissions('${dest}.pub', false)! // public key
agent.init()!
return agent.get(name: name) or { panic(err) }
return agent.get(name: validated_name) or {
return error("Generated SSH key '${validated_name}' not found in agent after creation: ${err}")
}
}
// unload all ssh keys
pub fn (mut agent SSHAgent) reset() ! {
if true {
panic('reset_ssh')
}
console.print_debug('Resetting SSH agent - removing all loaded keys')
res := os.execute('ssh-add -D')
if res.exit_code > 0 {
return error('cannot reset sshkeys.')
return error('cannot reset sshkeys: ${res.output}')
}
agent.init()! // should now be empty for loaded keys
console.print_green(' All SSH keys removed from agent')
}
// load the key, they key is content (private key) .
// a name is required
pub fn (mut agent SSHAgent) add(name string, privkey_ string) !SSHKey {
mut privkey := privkey_
path := '${agent.homepath.path}/${name}'
if os.exists(path) {
os.rm(path)!
// Validate inputs
validated_name := validate_key_name(name)!
validated_privkey := validate_private_key(privkey_)!
mut privkey := validated_privkey
path := '${agent.homepath.path}/${validated_name}'
// Validate file path
validated_path := validate_file_path(path, agent.homepath.path)!
if os.exists(validated_path) {
os.rm(validated_path)!
}
if os.exists('${path}.pub') {
os.rm('${path}.pub')!
if os.exists('${validated_path}.pub') {
os.rm('${validated_path}.pub')!
}
if !privkey.ends_with('\n') {
privkey += '\n'
}
os.write_file(path, privkey)!
os.chmod(path, 0o600)!
res4 := os.execute('ssh-keygen -y -f ${path} > ${path}.pub')
os.write_file(validated_path, privkey)!
secure_file_permissions(validated_path, true)! // private key
res4 := os.execute('ssh-keygen -y -f ${validated_path} > ${validated_path}.pub')
if res4.exit_code > 0 {
return error('cannot generate pubkey ${path}.\n${res4.output}')
return error('Cannot generate public key from private key: ${res4.output}')
}
return agent.load(path)!
secure_file_permissions('${validated_path}.pub', false)! // public key
return agent.load(validated_path)!
}
// load key starting from path to private key
@@ -288,18 +318,17 @@ pub fn (mut agent SSHAgent) load(keypath string) !SSHKey {
}
agent.init()!
return agent.get(name: name) or {
panic("can't find sshkey with name:'${name}' from agent.\n${err}")
return error("Cannot find SSH key '${name}' in agent after loading from '${keypath}': ${err}")
}
}
// forget the specified key
pub fn (mut agent SSHAgent) forget(name string) ! {
if true {
panic('reset_ssh')
}
mut key := agent.get(name: name) or { return }
console.print_debug('Forgetting SSH key: ${name}')
mut key := agent.get(name: name) or { return error('SSH key "${name}" not found in agent') }
agent.pop(key.pubkey)
key.forget()!
console.print_green(' SSH key "${name}" removed from agent')
}
pub fn (mut agent SSHAgent) str() string {

View File

@@ -0,0 +1,216 @@
module sshagent
import os
import freeflowuniverse.herolib.ui.console
// Test helper to create temporary directory for testing
fn setup_test_env() !string {
test_dir := '/tmp/sshagent_test_${os.getpid()}'
os.mkdir_all(test_dir)!
return test_dir
}
// Test helper to cleanup test environment
fn cleanup_test_env(test_dir string) {
os.rmdir_all(test_dir) or {}
}
// Test SSH agent creation
fn test_sshagent_new() ! {
test_dir := setup_test_env()!
defer { cleanup_test_env(test_dir) }
mut agent := new(homepath: test_dir)!
assert agent.homepath.path == test_dir
assert agent.keys.len >= 0
}
// Test SSH agent with single instance
fn test_sshagent_new_single() ! {
test_dir := setup_test_env()!
defer { cleanup_test_env(test_dir) }
mut agent := new_single(homepath: test_dir)!
assert agent.homepath.path == test_dir
// Test that agent is responsive
// Note: This might fail in CI environments without SSH agent
// agent.is_agent_responsive() // Commented out for CI compatibility
}
// Test SSH key generation
fn test_sshkey_generation() ! {
test_dir := setup_test_env()!
defer { cleanup_test_env(test_dir) }
mut agent := new(homepath: test_dir)!
// Generate a test key
key_name := 'test_key'
mut key := agent.generate(key_name, '')!
assert key.name == key_name
assert key.cat == .ed25519
// Verify key files exist
mut key_path := key.keypath()!
mut pub_key_path := key.keypath_pub()!
assert key_path.exists()
assert pub_key_path.exists()
// Verify key content
private_content := key_path.read()!
public_content := key.keypub()!
assert private_content.contains('PRIVATE KEY')
assert public_content.starts_with('ssh-ed25519')
// Cleanup
key_path.delete()!
pub_key_path.delete()!
}
// Test SSH key operations
fn test_sshkey_operations() ! {
test_dir := setup_test_env()!
defer { cleanup_test_env(test_dir) }
mut agent := new(homepath: test_dir)!
// Test key existence check
assert !agent.exists(name: 'nonexistent_key')
// Generate key
key_name := 'ops_test_key'
mut key := agent.generate(key_name, '')!
// Test key retrieval
retrieved_key := agent.get(name: key_name) or {
assert false, 'Key should exist after generation'
return
}
assert retrieved_key.name == key_name
// Test key existence after generation
assert agent.exists(name: key_name)
// Cleanup
mut cleanup_key_path := key.keypath()!
mut cleanup_pub_path := key.keypath_pub()!
cleanup_key_path.delete()!
cleanup_pub_path.delete()!
}
// Test SSH agent diagnostics
fn test_sshagent_diagnostics() ! {
test_dir := setup_test_env()!
defer { cleanup_test_env(test_dir) }
mut agent := new(homepath: test_dir)!
diag := agent.diagnostics()
// Check that all expected diagnostic keys are present
expected_keys := ['socket_path', 'socket_exists', 'agent_responsive', 'loaded_keys_count',
'total_keys_count', 'agent_processes']
for key in expected_keys {
assert key in diag, 'Missing diagnostic key: ${key}'
}
// Verify diagnostic values are reasonable
assert diag['loaded_keys_count'].int() >= 0
assert diag['total_keys_count'].int() >= 0
assert diag['agent_processes'].int() >= 0
}
// Test error handling
fn test_error_handling() ! {
test_dir := setup_test_env()!
defer { cleanup_test_env(test_dir) }
mut agent := new(homepath: test_dir)!
// Test loading non-existent key
if _ := agent.load('/nonexistent/path') {
assert false, 'Should fail to load non-existent key'
}
// Test getting non-existent key
if _ := agent.get(name: 'nonexistent') {
assert false, 'Should return none for non-existent key'
}
// Test forgetting non-existent key
if _ := agent.forget('nonexistent') {
assert false, 'Should fail to forget non-existent key'
}
}
// Test key string representation
fn test_sshkey_string() ! {
test_dir := setup_test_env()!
defer { cleanup_test_env(test_dir) }
mut agent := new(homepath: test_dir)!
// Generate key for testing
key_name := 'string_test_key'
mut key := agent.generate(key_name, '')!
// Test key string representation
key_str := key.str()
assert key_str.contains(key_name)
assert key_str.contains('ed25519')
// Test agent string representation
agent_str := agent.str()
assert agent_str.contains('SSHAGENT')
assert agent_str.contains(key_name)
// Cleanup
mut cleanup_key_path2 := key.keypath()!
mut cleanup_pub_path2 := key.keypath_pub()!
cleanup_key_path2.delete()!
cleanup_pub_path2.delete()!
}
// Test private key addition (simplified - just test file creation)
fn test_add_private_key() ! {
test_dir := setup_test_env()!
defer { cleanup_test_env(test_dir) }
mut agent := new(homepath: test_dir)!
// Create a simple test private key content (not a real key, just for testing file operations)
test_private_key := '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDXf9Z/2AH8/8a1ppagCplQdhWyQ8wZAieUw3nNcxsDiQAAAIhb3ybRW98m
0QAAAAtzc2gtZWQyNTUxOQAAACDXf9Z/2AH8/8a1ppagCplQdhWyQ8wZAieUw3nNcxsDiQ
AAAEC+fcDBPqdJHlJOQJ2zXhU2FztKAIl3TmWkaGCPnyts49d/1n/YAfz/xrWmlqAKmVB2
FbJDzBkCJ5TDec1zGwOJAAAABWJvb2tz
-----END OPENSSH PRIVATE KEY-----'
// Test input validation
key_name := 'test_added_key'
// This should work for file creation but may fail on public key generation
// which is expected since this is not a real private key
if mut added_key := agent.add(key_name, test_private_key) {
// If it succeeds, verify files were created
mut added_key_path := added_key.keypath()!
assert added_key_path.exists()
// Cleanup
added_key_path.delete()!
if pub_path := added_key.keypath_pub() {
mut pub_file := pub_path
pub_file.delete() or {}
}
} else {
// Expected to fail with invalid key, which is fine for this test
// We're mainly testing the validation and file handling logic
console.print_debug('Add private key failed as expected with test key')
}
}

View File

@@ -177,7 +177,7 @@ pub fn (mut t Tmux) windows_get() []&Window {
pub fn (mut t Tmux) is_running() !bool {
res := os.execute('tmux info')
if res.exit_code != 0 {
if res.output.contains('no server running') {
if is_tmux_server_not_running_error(res.output) {
// console.print_debug(" TMUX NOT RUNNING")
return false
}

View File

@@ -7,6 +7,83 @@ import time
import os
import freeflowuniverse.herolib.ui.console
// Constants for memory calculations
const kb_to_bytes_factor = 1024
const memory_display_precision = 3
const memory_cache_ttl_seconds = 300 // Cache system memory for 5 minutes
// Global cache for system memory to avoid repeated syscalls
struct MemoryCache {
mut:
total_bytes u64
cached_at time.Time
}
__global (
memory_cache MemoryCache
)
// Platform-specific memory detection
fn get_total_system_memory() !u64 {
$if macos {
result := osal.execute_silent('sysctl -n hw.memsize') or {
return error('Failed to get system memory on macOS: ${err}')
}
return result.trim_space().u64()
} $else $if linux {
// Read from /proc/meminfo
content := os.read_file('/proc/meminfo') or {
return error('Failed to read /proc/meminfo on Linux: ${err}')
}
for line in content.split_into_lines() {
if line.starts_with('MemTotal:') {
parts := line.split_any(' \t').filter(it.len > 0)
if parts.len >= 2 {
kb_value := parts[1].u64()
return kb_value * kb_to_bytes_factor
}
}
}
return error('Could not parse MemTotal from /proc/meminfo')
} $else {
return error('Unsupported platform for memory detection')
}
}
// Get cached or fresh system memory
fn get_system_memory_cached() u64 {
now := time.now()
// Check if cache is valid
if memory_cache.total_bytes > 0
&& now.unix() - memory_cache.cached_at.unix() < memory_cache_ttl_seconds {
return memory_cache.total_bytes
}
// Refresh cache
total_memory := get_total_system_memory() or {
console.print_debug('Failed to get system memory: ${err}')
return 0
}
memory_cache.total_bytes = total_memory
memory_cache.cached_at = now
return total_memory
}
// Calculate accurate memory percentage
fn calculate_memory_percentage(memory_bytes u64, ps_fallback_percent f64) f64 {
total_memory := get_system_memory_cached()
if total_memory > 0 {
return (f64(memory_bytes) / f64(total_memory)) * 100.0
}
// Fallback to ps value if system memory detection fails
return ps_fallback_percent
}
@[heap]
struct Pane {
pub mut:
@@ -22,28 +99,47 @@ pub mut:
pub fn (mut p Pane) stats() !ProcessStats {
if p.pid == 0 {
return ProcessStats{}
return ProcessStats{
cpu_percent: 0.0
memory_percent: 0.0
memory_bytes: 0
}
}
// Use ps command to get CPU and memory stats
cmd := 'ps -p ${p.pid} -o %cpu,%mem,rss --no-headers'
// Use ps command to get CPU and memory stats (cross-platform compatible)
cmd := 'ps -p ${p.pid} -o %cpu,%mem,rss'
result := osal.execute_silent(cmd) or {
return error('Cannot get stats for PID ${p.pid}: ${err}')
}
if result.trim_space() == '' {
lines := result.split_into_lines()
if lines.len < 2 {
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}')
// Skip header line, get data line
data_line := lines[1].trim_space()
if data_line == '' {
return error('Process ${p.pid} not found')
}
parts := data_line.split_any(' \t').filter(it != '')
if parts.len < 3 {
return error('Invalid ps output: ${data_line}')
}
// Parse values from ps output
cpu_percent := parts[0].f64()
ps_memory_percent := parts[1].f64()
memory_bytes := parts[2].u64() * kb_to_bytes_factor
// Calculate accurate memory percentage using cached system memory
memory_percent := calculate_memory_percentage(memory_bytes, ps_memory_percent)
return ProcessStats{
cpu_percent: parts[0].f64()
memory_percent: parts[1].f64()
memory_bytes: parts[2].u64() * 1024 // ps returns KB, convert to bytes
cpu_percent: cpu_percent
memory_percent: memory_percent
memory_bytes: memory_bytes
}
}
@@ -54,8 +150,14 @@ pub mut:
offset int
}
pub fn (mut p Pane) logs_get_new(reset bool) ![]TMuxLogEntry {
if reset {
pub struct LogsGetArgs {
pub mut:
reset bool
}
// get new logs since last call
pub fn (mut p Pane) logs_get_new(args LogsGetArgs) ![]TMuxLogEntry {
if args.reset {
p.last_output_offset = 0
}
// Capture pane content with line numbers
@@ -141,3 +243,66 @@ pub fn (mut p Pane) processinfo_main() !osal.ProcessInfo {
return osal.processinfo_get(p.pid)!
}
// Send a command to this pane
pub fn (mut p Pane) send_command(command string) ! {
cmd := 'tmux send-keys -t ${p.window.session.name}:@${p.window.id}.%${p.id} "${command}" Enter'
osal.execute_silent(cmd) or { return error('Cannot send command to pane %${p.id}: ${err}') }
}
// Send raw keys to this pane (without Enter)
pub fn (mut p Pane) send_keys(keys string) ! {
cmd := 'tmux send-keys -t ${p.window.session.name}:@${p.window.id}.%${p.id} "${keys}"'
osal.execute_silent(cmd) or { return error('Cannot send keys to pane %${p.id}: ${err}') }
}
// Kill this specific pane
pub fn (mut p Pane) kill() ! {
cmd := 'tmux kill-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id}'
osal.execute_silent(cmd) or { return error('Cannot kill pane %${p.id}: ${err}') }
}
// Select/activate this pane
pub fn (mut p Pane) select() ! {
cmd := 'tmux select-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id}'
osal.execute_silent(cmd) or { return error('Cannot select pane %${p.id}: ${err}') }
p.active = true
}
@[params]
pub struct PaneResizeArgs {
pub mut:
direction string = 'right' // 'up', 'down', 'left', 'right'
cells int = 5 // number of cells to resize by
}
// Resize this pane
pub fn (mut p Pane) resize(args PaneResizeArgs) ! {
direction_flag := match args.direction.to_lower() {
'up', 'u' { '-U' }
'down', 'd' { '-D' }
'left', 'l' { '-L' }
'right', 'r' { '-R' }
else { return error('Invalid resize direction: ${args.direction}. Use up, down, left, or right') }
}
cmd := 'tmux resize-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id} ${direction_flag} ${args.cells}'
osal.execute_silent(cmd) or { return error('Cannot resize pane %${p.id}: ${err}') }
}
// Convenience methods for resizing
pub fn (mut p Pane) resize_up(cells int) ! {
p.resize(direction: 'up', cells: cells)!
}
pub fn (mut p Pane) resize_down(cells int) ! {
p.resize(direction: 'down', cells: cells)!
}
pub fn (mut p Pane) resize_left(cells int) ! {
p.resize(direction: 'left', cells: cells)!
}
pub fn (mut p Pane) resize_right(cells int) ! {
p.resize(direction: 'right', cells: cells)!
}

View File

@@ -5,9 +5,30 @@ import freeflowuniverse.herolib.core.texttools
import freeflowuniverse.herolib.ui.console
import time
// Check if error message indicates tmux server is not running
fn is_tmux_server_not_running_error(error_msg string) bool {
// Common tmux server not running error patterns
tmux_not_running_patterns := [
'no server running',
'error connecting to',
'No such file or directory', // when socket doesn't exist
]
error_lower := error_msg.to_lower()
for pattern in tmux_not_running_patterns {
if error_lower.contains(pattern.to_lower()) {
return true
}
}
return false
}
fn (mut t Tmux) scan_add(line string) !&Pane {
// Parse the line to get session, window, and pane info
line_arr := line.split('|')
if line_arr.len < 6 {
return error('Invalid tmux pane line format: ${line}')
}
session_name := line_arr[0]
window_name := line_arr[1]
window_id := line_arr[2]
@@ -16,6 +37,11 @@ fn (mut t Tmux) scan_add(line string) !&Pane {
pane_pid := line_arr[5]
pane_start_command := line_arr[6] or { '' }
// Skip if window name is empty
if window_name.len == 0 {
return error('Window name is empty in line: ${line}')
}
wid := (window_id.replace('@', '')).int()
pid := (pane_id.replace('%', '')).int()
@@ -71,7 +97,7 @@ pub fn (mut t Tmux) scan() ! {
cmd_list_session := "tmux list-sessions -F '#{session_name}'"
exec_list := osal.exec(cmd: cmd_list_session, stdout: false, name: 'tmux_list') or {
if err.msg().contains('no server running') {
if is_tmux_server_not_running_error(err.msg()) {
return
}
return error('could not execute list sessions.\n${err}')

View File

@@ -63,8 +63,11 @@ pub fn (mut s Session) scan() ! {
for line in result.split_into_lines() {
if line.contains('|') {
parts := line.split('|')
if parts.len >= 2 {
if parts.len >= 3 && parts[0].len > 0 && parts[1].len > 0 {
window_name := texttools.name_fix(parts[0])
if window_name.len == 0 {
continue
}
window_id := parts[1].replace('@', '').int()
window_active := parts[2] == '1'
@@ -73,7 +76,7 @@ pub fn (mut s Session) scan() ! {
// Update existing window or create new one
mut found := false
for mut w in s.windows {
if w.name == window_name {
if w.name.len > 0 && window_name.len > 0 && w.name == window_name {
w.id = window_id
w.active = window_active
w.scan()! // Scan panes for this window
@@ -99,7 +102,7 @@ pub fn (mut s Session) scan() ! {
}
// Remove windows that no longer exist in tmux
s.windows = s.windows.filter(current_windows[it.name] == true)
s.windows = s.windows.filter(it.name.len > 0 && current_windows[it.name] == true)
}
// window_name is the name of the window in session main (will always be called session main)
@@ -115,7 +118,7 @@ pub fn (mut s Session) scan() ! {
// reset bool
// }
// ```
pub fn (mut s Session) window_new(args WindowArgs) !Window {
pub fn (mut s Session) window_new(args WindowArgs) !&Window {
$if debug {
console.print_header(' start window: \n${args}')
}
@@ -127,7 +130,7 @@ pub fn (mut s Session) window_new(args WindowArgs) !Window {
return error('cannot create new window it already exists, window ${namel} in session:${s.name}')
}
}
mut w := Window{
mut w := &Window{
session: &s
name: namel
panes: []&Pane{}
@@ -211,9 +214,12 @@ fn (mut s Session) window_exist(args_ WindowGetArgs) bool {
pub fn (mut s Session) window_get(args_ WindowGetArgs) !&Window {
mut args := args_
if args.name.len == 0 {
return error('Window name cannot be empty')
}
args.name = texttools.name_fix(args.name)
for w in s.windows {
if w.name == args.name {
if w.name.len > 0 && w.name == args.name {
if (args.id > 0 && w.id == args.id) || args.id == 0 {
return w
}
@@ -252,3 +258,29 @@ pub fn (mut s Session) stop() ! {
return error("Can't delete session ${s.name} - This may happen when session is not found: ${err}")
}
}
// Run ttyd for this session so it can be accessed in the browser
pub fn (mut s Session) run_ttyd(args TtydArgs) ! {
target := '${s.name}'
// Add -W flag for write access if editable mode is enabled
mut ttyd_flags := '-p ${args.port}'
if args.editable {
ttyd_flags += ' -W'
}
cmd := 'nohup ttyd ${ttyd_flags} tmux attach -t ${target} >/dev/null 2>&1 &'
code := os.system(cmd)
if code != 0 {
return error('Failed to start ttyd on port ${args.port} for session ${s.name}')
}
mode_str := if args.editable { 'editable' } else { 'read-only' }
println('ttyd started for session ${s.name} at http://localhost:${args.port} (${mode_str} mode)')
}
// Backward compatibility method - runs ttyd in read-only mode
pub fn (mut s Session) run_ttyd_readonly(port int) ! {
s.run_ttyd(port: port, editable: false)!
}

View File

@@ -175,3 +175,117 @@ pub fn (mut w Window) pane_active() ?&Pane {
}
return none
}
@[params]
pub struct PaneSplitArgs {
pub mut:
cmd string // command to run in new pane
horizontal bool // true for horizontal split, false for vertical
env map[string]string // environment variables
}
// Split the active pane horizontally or vertically
pub fn (mut w Window) pane_split(args PaneSplitArgs) !&Pane {
mut cmd_to_run := args.cmd
if cmd_to_run == '' {
cmd_to_run = '/bin/bash'
}
// Build environment arguments
mut env_args := ''
for key, value in args.env {
env_args += ' -e ${key}="${value}"'
}
// Choose split direction
split_flag := if args.horizontal { '-h' } else { '-v' }
// Execute tmux split-window command
res_opt := "-P -F '#{session_name}|#{window_name}|#{window_id}|#{pane_active}|#{pane_id}|#{pane_pid}|#{pane_start_command}'"
cmd := 'tmux split-window ${split_flag} ${res_opt}${env_args} -t ${w.session.name}:@${w.id} \'${cmd_to_run}\''
console.print_debug('Splitting pane: ${cmd}')
res := osal.exec(cmd: cmd, stdout: false, name: 'tmux_pane_split') or {
return error("Can't split pane in window ${w.name}: ${err}")
}
// Parse the result to get new pane info
line_arr := res.output.split('|')
if line_arr.len < 7 {
return error('Invalid tmux split-window output: ${res.output}')
}
pane_id := line_arr[4].replace('%', '').int()
pane_pid := line_arr[5].int()
pane_active := line_arr[3] == '1'
pane_cmd := line_arr[6] or { '' }
// Create new pane object
mut new_pane := Pane{
window: &w
id: pane_id
pid: pane_pid
active: pane_active
cmd: pane_cmd
env: args.env
created_at: time.now()
last_output_offset: 0
}
// Add to window's panes and rescan to get current state
w.panes << &new_pane
w.scan()!
// Return reference to the new pane
for mut pane in w.panes {
if pane.id == pane_id {
return pane
}
}
return error('Could not find newly created pane with ID ${pane_id}')
}
// Split pane horizontally (side by side)
pub fn (mut w Window) pane_split_horizontal(cmd string) !&Pane {
return w.pane_split(cmd: cmd, horizontal: true)
}
// Split pane vertically (top and bottom)
pub fn (mut w Window) pane_split_vertical(cmd string) !&Pane {
return w.pane_split(cmd: cmd, horizontal: false)
}
@[params]
pub struct TtydArgs {
pub mut:
port int
editable bool // if true, allows write access to the terminal
}
// Run ttyd for this window so it can be accessed in the browser
pub fn (mut w Window) run_ttyd(args TtydArgs) ! {
target := '${w.session.name}:@${w.id}'
// Add -W flag for write access if editable mode is enabled
mut ttyd_flags := '-p ${args.port}'
if args.editable {
ttyd_flags += ' -W'
}
cmd := 'nohup ttyd ${ttyd_flags} tmux attach -t ${target} >/dev/null 2>&1 &'
code := os.system(cmd)
if code != 0 {
return error('Failed to start ttyd on port ${args.port} for window ${w.name}')
}
mode_str := if args.editable { 'editable' } else { 'read-only' }
println('ttyd started for window ${w.name} at http://localhost:${args.port} (${mode_str} mode)')
}
// Backward compatibility method - runs ttyd in read-only mode
pub fn (mut w Window) run_ttyd_readonly(port int) ! {
w.run_ttyd(port: port, editable: false)!
}

View File

@@ -1,22 +1,16 @@
module podman
module herocontainers
import os
import freeflowuniverse.herolib.osal.core as osal { exec }
import freeflowuniverse.herolib.core
import freeflowuniverse.herolib.installers.virt.podman as podman_installer
import freeflowuniverse.herolib.installers.lang.herolib
import freeflowuniverse.herolib.ui.console
import json
import rand
@[heap]
pub struct PodmanFactory {
pub mut:
// sshkeys_allowed []string // all keys here have access over ssh into the machine, when ssh enabled
images []Image
containers []Container
builders []Builder
buildpath string
images []Image
containers []Container
buildpath string
// cache bool = true
// push bool
// platform []BuildPlatformType // used to build
@@ -24,30 +18,6 @@ pub mut:
prefix string
}
// BuildPlatformType represents different build platforms
pub enum BuildPlatformType {
linux_amd64
linux_arm64
darwin_amd64
darwin_arm64
}
// ContainerRuntimeConfig represents container runtime configuration
pub struct ContainerRuntimeConfig {
pub mut:
name string
image string
command []string
env map[string]string
ports []string
volumes []string
detach bool = true
remove bool
interactive bool
tty bool
working_dir string
entrypoint string
}
@[params]
pub struct NewArgs {
@@ -57,38 +27,12 @@ pub mut:
herocompile bool
}
pub fn new(args_ NewArgs) !PodmanFactory {
mut args := args_
// Support both Linux and macOS
if !core.is_linux()! && !core.is_osx()! {
return error('only linux and macOS supported as host for now')
}
if args.install {
mut podman_installer0 := podman_installer.get()!
podman_installer0.install()!
}
// Ensure podman machine is available (macOS/Windows)
ensure_machine_available() or {
console.print_debug('Warning: Failed to ensure podman machine availability: ${err}')
console.print_debug('Continuing anyway - podman operations may fail if machine is not running')
}
if args.herocompile {
herolib.check()! // will check if install, if not will do
herolib.hero_compile(reset: true)!
}
mut factory := PodmanFactory{}
factory.init()!
if args.reset {
factory.reset_all()!
}
return factory
}
fn (mut e PodmanFactory) init() ! {
if e.buildpath == '' {
@@ -118,294 +62,31 @@ pub fn (mut e PodmanFactory) reset_all() ! {
exec(cmd: 'podman rmi -a -f', stdout: false)!
e.builders_delete_all()!
osal.done_reset()!
// Only check systemctl on Linux
if core.is_linux()! && core.platform()! == core.PlatformType.arch {
if core.platform()! == core.PlatformType.arch {
exec(cmd: 'systemctl status podman.socket', stdout: false)!
}
e.load()!
}
// Get free port - simple implementation
// Get free port
pub fn (mut e PodmanFactory) get_free_port() ?int {
// Simple implementation - return a random port in the range
// In a real implementation, you'd check for port availability
return 20000 + (rand.int() % 20000)
}
mut used_ports := []int{}
mut range := []int{}
// create_from_buildah_image creates a podman container from a buildah image
pub fn (mut e PodmanFactory) create_from_buildah_image(image_name string, config ContainerRuntimeConfig) !string {
// Check if image exists in podman
image_exists := e.image_exists(repo: image_name) or { false }
if !image_exists {
// Try to transfer from buildah to podman
exec(cmd: 'buildah push ${image_name} containers-storage:${image_name}') or {
return new_image_error('create_from_buildah', image_name, 1, 'Failed to transfer image from buildah',
err.msg())
}
// Reload images after transfer
e.images_load()!
}
// Create container using the image
args := ContainerCreateArgs{
name: config.name
image_repo: image_name
command: config.command.join(' ')
env: config.env
forwarded_ports: config.ports
mounted_volumes: config.volumes
detach: config.detach
remove_when_done: config.remove
interactive: config.interactive
}
container := e.container_create(args)!
return container.id
}
// build_and_run_workflow performs a complete buildah build to podman run workflow
pub fn (mut e PodmanFactory) build_and_run_workflow(build_config ContainerRuntimeConfig, run_config ContainerRuntimeConfig, image_name string) !string {
// Simple implementation - just create a container from the image
// In a full implementation, this would coordinate with buildah
return e.create_from_buildah_image(image_name, run_config)
}
// Simple API functions (from client.v) - these use a default factory instance
// run_container runs a container with the specified image and options.
// Returns the container ID of the created container.
pub fn run_container(image string, options RunOptions) !string {
mut factory := new(install: false)!
// Convert RunOptions to ContainerCreateArgs
args := ContainerCreateArgs{
name: options.name
image_repo: image
command: options.command.join(' ')
env: options.env
forwarded_ports: options.ports
mounted_volumes: options.volumes
detach: options.detach
interactive: options.interactive
remove_when_done: options.remove
// Map other options as needed
}
container := factory.container_create(args)!
return container.id
}
// exec_podman executes a podman command with the given arguments
fn exec_podman(args []string) !string {
cmd := 'podman ' + args.join(' ')
result := exec(cmd: cmd, stdout: false)!
return result.output
}
// parse_json_output parses JSON output into the specified type
fn parse_json_output[T](output string) ![]T {
if output.trim_space() == '' {
return []T{}
}
return json.decode([]T, output)!
}
// list_containers lists running containers, or all containers if all=true.
pub fn list_containers(all bool) ![]PodmanContainer {
mut args := ['ps', '--format', 'json']
if all {
args << '--all'
}
output := exec_podman(args)!
return parse_json_output[PodmanContainer](output) or {
return new_container_error('list', 'containers', 1, err.msg(), err.msg())
}
}
// list_images lists all available images.
pub fn list_images() ![]PodmanImage {
output := exec_podman(['images', '--format', 'json'])!
return parse_json_output[PodmanImage](output) or {
return new_image_error('list', 'images', 1, err.msg(), err.msg())
}
}
// inspect_container returns detailed information about a container.
pub fn inspect_container(id string) !PodmanContainer {
output := exec_podman(['inspect', '--format', 'json', id])!
containers := parse_json_output[PodmanContainer](output) or {
return new_container_error('inspect', id, 1, err.msg(), err.msg())
}
if containers.len == 0 {
return new_container_error('inspect', id, 1, 'Container not found', 'Container ${id} not found')
}
return containers[0]
}
// stop_container stops a running container.
pub fn stop_container(id string) ! {
exec_podman(['stop', id]) or { return new_container_error('stop', id, 1, err.msg(), err.msg()) }
}
// remove_container removes a container.
// If force=true, the container will be forcefully removed even if running.
pub fn remove_container(id string, force bool) ! {
mut args := ['rm']
if force {
args << '-f'
}
args << id
exec_podman(args) or { return new_container_error('remove', id, 1, err.msg(), err.msg()) }
}
// remove_image removes an image by ID or name.
// If force=true, the image will be forcefully removed even if in use.
pub fn remove_image(id string, force bool) ! {
mut args := ['rmi']
if force {
args << '-f'
}
args << id
exec_podman(args) or { return new_image_error('remove', id, 1, err.msg(), err.msg()) }
}
// =============================================================================
// MACHINE MANAGEMENT (macOS/Windows support)
// =============================================================================
// Machine represents a podman machine (VM)
pub struct Machine {
pub:
name string
vm_type string
created string
last_up string
cpus string
memory string
disk string
running bool
}
// ensure_machine_available ensures a podman machine is available and running
// This is required on macOS and Windows where podman runs in a VM
pub fn ensure_machine_available() ! {
// Only needed on macOS and Windows
if os.user_os() !in ['macos', 'windows'] {
return
}
// Check if any machine exists
machines := list_machines() or { []Machine{} }
if machines.len == 0 {
console.print_debug('No podman machine found, initializing...')
machine_init() or { return error('Failed to initialize podman machine: ${err}') }
}
// Check if a machine is running
if !is_any_machine_running() {
console.print_debug('Starting podman machine...')
machine_start() or { return error('Failed to start podman machine: ${err}') }
}
}
// list_machines returns all available podman machines
pub fn list_machines() ![]Machine {
return parse_machine_list_text()!
}
// parse_machine_list_text parses text format output as fallback
fn parse_machine_list_text() ![]Machine {
job := exec(cmd: 'podman machine list', stdout: false) or {
return error('Failed to list podman machines: ${err}')
}
lines := job.output.split_into_lines()
if lines.len <= 1 {
return []Machine{} // No machines or only header
}
mut machines := []Machine{}
for i in 1 .. lines.len {
line := lines[i].trim_space()
if line == '' {
continue
}
fields := line.split_any(' \t').filter(it.trim_space() != '')
if fields.len >= 6 {
machine := Machine{
name: fields[0]
vm_type: fields[1]
created: fields[2]
last_up: fields[3]
cpus: fields[4]
memory: fields[5]
disk: if fields.len > 6 { fields[6] } else { '' }
running: line.contains('Currently running') || line.contains('Running')
}
machines << machine
for c in e.containers {
for p in c.forwarded_ports {
used_ports << p.split(':')[0].int()
}
}
return machines
}
// is_any_machine_running checks if any podman machine is currently running
pub fn is_any_machine_running() bool {
machines := list_machines() or { return false }
return machines.any(it.running)
}
// machine_init initializes a new podman machine with default settings
pub fn machine_init() ! {
machine_init_named('podman-machine-default')!
}
// machine_init_named initializes a new podman machine with specified name
pub fn machine_init_named(name string) ! {
console.print_debug('Initializing podman machine: ${name}')
exec(cmd: 'podman machine init ${name}', stdout: false) or {
return error('Failed to initialize podman machine: ${err}')
for i in 20000 .. 40000 {
if i !in used_ports {
range << i
}
}
console.print_debug(' Podman machine initialized: ${name}')
}
// machine_start starts the default podman machine
pub fn machine_start() ! {
machine_start_named('')!
}
// machine_start_named starts a specific podman machine
pub fn machine_start_named(name string) ! {
mut cmd := 'podman machine start'
if name != '' {
cmd += ' ${name}'
// arrays.shuffle<int>(mut range, 0)
if range.len == 0 {
return none
}
console.print_debug('Starting podman machine...')
exec(cmd: cmd, stdout: false) or { return error('Failed to start podman machine: ${err}') }
console.print_debug(' Podman machine started')
}
// machine_stop stops the default podman machine
pub fn machine_stop() ! {
machine_stop_named('')!
}
// machine_stop_named stops a specific podman machine
pub fn machine_stop_named(name string) ! {
mut cmd := 'podman machine stop'
if name != '' {
cmd += ' ${name}'
}
exec(cmd: cmd, stdout: false) or { return error('Failed to stop podman machine: ${err}') }
return range[0]
}