refactor: enhance container lifecycle and Crun executor

- Refactor container definition and creation flow
- Implement idempotent behavior for `container.start()`
- Add comprehensive `ExecutorCrun` support to all Node methods
- Standardize OCI image pulling and rootfs export via Podman
- Update default OCI config for persistent containers and no terminal
This commit is contained in:
Mahmoud-Emad
2025-09-08 13:54:40 +03:00
parent ef211882af
commit 196bcebb27
6 changed files with 213 additions and 40 deletions

View File

@@ -1,16 +1,62 @@
#!/usr/bin/env -S v -n -w -cg -gc none -cc tcc -d use_openssl -enable-globals run
import freeflowuniverse.herolib.virt.heropods
// Initialize factory
// Initialize factory
mut factory := heropods.new(
reset: false
use_podman: true
) or { panic('Failed to init ContainerFactory: ${err}') }
container := factory.new(
println('=== HeroPods Refactored API Demo ===')
// Step 1: factory.new() now only creates a container definition/handle
// It does NOT create the actual container in the backend yet
mut container := factory.new(
name: 'myalpine'
image: .custom
custom_image_name: 'alpine_3_20'
docker_url: 'docker.io/library/alpine:3.20'
)!
println(' Container definition created: ${container.name}')
println(' (No actual container created in backend yet)')
// Step 2: container.start() handles creation and starting
// - Checks if container exists in backend
// - Creates it if it doesn't exist
// - Starts it if it exists but is stopped
println('\n--- First start() call ---')
container.start()!
println(' Container started successfully')
// Step 3: Multiple start() calls are now idempotent
println('\n--- Second start() call (should be idempotent) ---')
container.start()!
println(' Second start() call successful - no errors!')
// Step 4: Execute commands in the container and save results
println('\n--- Executing commands in container ---')
result1 := container.exec(cmd: 'ls -la /')!
println(' Command executed: ls -la /')
println('Result: ${result1}')
result2 := container.exec(cmd: 'echo "Hello from container!"')!
println(' Command executed: echo "Hello from container!"')
println('Result: ${result2}')
result3 := container.exec(cmd: 'uname -a')!
println(' Command executed: uname -a')
println('Result: ${result3}')
// Step 5: container.delete() works naturally on the instance
println('\n--- Deleting container ---')
container.delete()!
println(' Container deleted successfully')
println('\n=== Demo completed! ===')
println('The refactored API now works as expected:')
println('- factory.new() creates definition only')
println('- container.start() is idempotent')
println('- container.exec() works and returns results')
println('- container.delete() works on instances')

View File

@@ -22,7 +22,7 @@ pub fn (mut executor ExecutorCrun) init() ! {
}
// Parse state to ensure container is running
if !result.output.contains('"status":"running"') {
if !result.output.contains('"status": "running"') {
return error('Container ${executor.container_id} is not running')
}
}

View File

@@ -14,6 +14,8 @@ pub fn (mut node Node) exec(args ExecArgs) !string {
return node.executor.exec(cmd: args.cmd, stdout: args.stdout)
} else if mut node.executor is ExecutorSSH {
return node.executor.exec(cmd: args.cmd, stdout: args.stdout)
} else if mut node.executor is ExecutorCrun {
return node.executor.exec(cmd: args.cmd, stdout: args.stdout)
}
panic('did not find right executor')
}
@@ -80,6 +82,8 @@ pub fn (mut node Node) exec_silent(cmd string) !string {
return node.executor.exec(cmd: cmd, stdout: false)
} else if mut node.executor is ExecutorSSH {
return node.executor.exec(cmd: cmd, stdout: false)
} else if mut node.executor is ExecutorCrun {
return node.executor.exec(cmd: cmd, stdout: false)
}
panic('did not find right executor')
}
@@ -89,8 +93,11 @@ pub fn (mut node Node) exec_interactive(cmd_ string) ! {
node.executor.exec_interactive(cmd: cmd_)!
} else if mut node.executor is ExecutorSSH {
node.executor.exec_interactive(cmd: cmd_)!
}
} else if mut node.executor is ExecutorCrun {
node.executor.exec_interactive(cmd: cmd_)!
} else {
panic('did not find right executor')
}
}
pub fn (mut node Node) file_write(path string, text string) ! {
@@ -98,6 +105,8 @@ pub fn (mut node Node) file_write(path string, text string) ! {
return node.executor.file_write(path, text)
} else if mut node.executor is ExecutorSSH {
return node.executor.file_write(path, text)
} else if mut node.executor is ExecutorCrun {
return node.executor.file_write(path, text)
}
panic('did not find right executor')
}
@@ -107,6 +116,8 @@ pub fn (mut node Node) file_read(path string) !string {
return node.executor.file_read(path)
} else if mut node.executor is ExecutorSSH {
return node.executor.file_read(path)
} else if mut node.executor is ExecutorCrun {
return node.executor.file_read(path)
}
panic('did not find right executor')
}
@@ -116,6 +127,8 @@ pub fn (mut node Node) file_exists(path string) bool {
return node.executor.file_exists(path)
} else if mut node.executor is ExecutorSSH {
return node.executor.file_exists(path)
} else if mut node.executor is ExecutorCrun {
return node.executor.file_exists(path)
}
panic('did not find right executor')
}
@@ -137,6 +150,8 @@ pub fn (mut node Node) delete(path string) ! {
return node.executor.delete(path)
} else if mut node.executor is ExecutorSSH {
return node.executor.delete(path)
} else if mut node.executor is ExecutorCrun {
return node.executor.delete(path)
}
panic('did not find right executor')
}
@@ -179,6 +194,8 @@ pub fn (mut node Node) download(args_ SyncArgs) ! {
return node.executor.download(args)
} else if mut node.executor is ExecutorSSH {
return node.executor.download(args)
} else if mut node.executor is ExecutorCrun {
return node.executor.download(args)
}
panic('did not find right executor')
}
@@ -208,6 +225,8 @@ pub fn (mut node Node) upload(args_ SyncArgs) ! {
return node.executor.upload(args)
} else if mut node.executor is ExecutorSSH {
return node.executor.upload(args)
} else if mut node.executor is ExecutorCrun {
return node.executor.upload(args)
}
panic('did not find right executor')
}
@@ -224,6 +243,8 @@ pub fn (mut node Node) environ_get(args EnvGetParams) !map[string]string {
return node.executor.environ_get()
} else if mut node.executor is ExecutorSSH {
return node.executor.environ_get()
} else if mut node.executor is ExecutorCrun {
return node.executor.environ_get()
}
panic('did not find right executor')
}
@@ -235,6 +256,8 @@ pub fn (mut node Node) info() map[string]string {
return node.executor.info()
} else if mut node.executor is ExecutorSSH {
return node.executor.info()
} else if mut node.executor is ExecutorCrun {
return node.executor.info()
}
panic('did not find right executor')
}
@@ -244,6 +267,8 @@ pub fn (mut node Node) shell(cmd string) ! {
return node.executor.shell(cmd)
} else if mut node.executor is ExecutorSSH {
return node.executor.shell(cmd)
} else if mut node.executor is ExecutorCrun {
return node.executor.shell(cmd)
}
panic('did not find right executor')
}
@@ -257,6 +282,8 @@ pub fn (mut node Node) list(path string) ![]string {
return node.executor.list(path)
} else if mut node.executor is ExecutorSSH {
return node.executor.list(path)
} else if mut node.executor is ExecutorCrun {
return node.executor.list(path)
}
panic('did not find right executor')
}
@@ -266,6 +293,8 @@ pub fn (mut node Node) dir_exists(path string) bool {
return node.executor.dir_exists(path)
} else if mut node.executor is ExecutorSSH {
return node.executor.dir_exists(path)
} else if mut node.executor is ExecutorCrun {
return node.executor.dir_exists(path)
}
panic('did not find right executor')
}
@@ -275,8 +304,11 @@ pub fn (mut node Node) debug_off() {
node.executor.debug_off()
} else if mut node.executor is ExecutorSSH {
node.executor.debug_off()
}
} else if mut node.executor is ExecutorCrun {
node.executor.debug_off()
} else {
panic('did not find right executor')
}
}
pub fn (mut node Node) debug_on() {
@@ -284,6 +316,9 @@ pub fn (mut node Node) debug_on() {
node.executor.debug_on()
} else if mut node.executor is ExecutorSSH {
node.executor.debug_on()
}
} else if mut node.executor is ExecutorCrun {
node.executor.debug_on()
} else {
panic('did not find right executor')
}
}

View File

@@ -7,7 +7,9 @@
"gid": 0
},
"args": [
"/bin/sh"
"/bin/sh",
"-c",
"while true; do sleep 30; done"
],
"env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",

View File

@@ -15,13 +15,48 @@ pub mut:
factory &ContainerFactory
}
// Struct to parse JSON output of `crun state`
struct CrunState {
id string
status string
pid int
bundle string
created string
}
pub fn (mut self Container) start() ! {
// Check if container exists in crun
container_exists := self.container_exists_in_crun()!
if !container_exists {
// Container doesn't exist, create it first
console.print_debug('Container ${self.name} does not exist, creating it...')
osal.exec(
cmd: 'crun create --bundle ${self.factory.base_dir}/configs/${self.name} ${self.name}'
stdout: true
)!
console.print_debug('Container ${self.name} created')
}
status := self.status()!
if status == .running {
console.print_debug('Container ${self.name} is already running')
return
}
// If container exists but is stopped, we need to delete and recreate it
// because crun doesn't allow restarting a stopped container
if container_exists && status != .running {
console.print_debug('Container ${self.name} exists but is stopped, recreating...')
osal.exec(cmd: 'crun delete ${self.name}', stdout: false) or {}
osal.exec(
cmd: 'crun create --bundle ${self.factory.base_dir}/configs/${self.name} ${self.name}'
stdout: true
)!
console.print_debug('Container ${self.name} recreated')
}
// start the container (crun start doesn't have --detach flag)
osal.exec(cmd: 'crun start ${self.name}', stdout: true)!
console.print_green('Container ${self.name} started')
}
@@ -44,8 +79,20 @@ pub fn (mut self Container) stop() ! {
}
pub fn (mut self Container) delete() ! {
// Check if container exists before trying to delete
if !self.container_exists_in_crun()! {
console.print_debug('Container ${self.name} does not exist, nothing to delete')
return
}
self.stop()!
osal.exec(cmd: 'crun delete ${self.name}', stdout: false) or {}
// Remove from factory's container cache
if self.name in self.factory.containers {
self.factory.containers.delete(self.name)
}
console.print_green('Container ${self.name} deleted')
}
@@ -65,11 +112,9 @@ pub fn (self Container) status() !ContainerStatus {
result := osal.exec(cmd: 'crun state ${self.name}', stdout: false) or { return .unknown }
// Parse JSON output from crun state
state := json.decode(map[string]json.Any, result.output) or { return .unknown }
state := json.decode(CrunState, result.output) or { return .unknown }
status_str := state['status'].str()
return match status_str {
return match state.status {
'running' { .running }
'stopped' { .stopped }
'paused' { .paused }
@@ -77,6 +122,15 @@ pub fn (self Container) status() !ContainerStatus {
}
}
// Check if container exists in crun (regardless of its state)
fn (self Container) container_exists_in_crun() !bool {
// Try to get container state - if it fails, container doesn't exist
result := osal.exec(cmd: 'crun state ${self.name}', stdout: false) or { return false }
// If we get here, the container exists (even if stopped/paused)
return result.exit_code == 0
}
pub enum ContainerStatus {
running
stopped
@@ -92,8 +146,6 @@ pub fn (self Container) cpu_usage() !f64 {
stdout: false
) or { return 0.0 }
// Parse cpu.stat file and calculate usage percentage
// This is a simplified implementation
for line in result.output.split_into_lines() {
if line.starts_with('usage_usec') {
usage := line.split(' ')[1].f64()
@@ -166,25 +218,22 @@ pub fn (mut self Container) node() !&builder.Node {
return self.node
}
// Create builder factory (so node has proper lifecycle)
mut b := builder.new()!
// Create a new executor for this container
mut exec := builder.ExecutorCrun{
container_id: self.name
debug: false
}
// Initialize executor (checks container is running)
exec.init() or {
return error('Failed to init ExecutorCrun for container ${self.name}: ${err}')
}
// Create a node with this executor
mut node := b.node_new(name: 'container_${self.name}')!
// Create node using the factory method, then override the executor
mut node := b.node_new(name: 'container_${self.name}', ipaddr: 'localhost')!
node.executor = exec
node.platform = .alpine // TODO: detect from ContainerImageType
node.cputype = .intel // TODO: detect dynamically if needed
node.platform = .alpine
node.cputype = .intel
node.done = map[string]string{}
node.environment = map[string]string{}
node.hostname = self.name

View File

@@ -3,9 +3,9 @@ module heropods
import freeflowuniverse.herolib.ui.console
import freeflowuniverse.herolib.osal.core as osal
import freeflowuniverse.herolib.core.pathlib
import freeflowuniverse.herolib.core.texttools
import freeflowuniverse.herolib.installers.virt.herorunner as herorunner_installer
import os
import x.json2
// Updated enum to be more flexible
pub enum ContainerImageType {
@@ -54,14 +54,10 @@ pub fn (mut self ContainerFactory) new(args ContainerNewArgs) !&Container {
image_name = args.custom_image_name
rootfs_path = '${self.base_dir}/images/${image_name}/rootfs'
// Check if image exists, if not and docker_url provided, create it
// If image not yet extracted, pull and unpack it
if !os.is_dir(rootfs_path) && args.docker_url != '' {
console.print_debug('Creating new image ${image_name} from ${args.docker_url}')
_ = self.image_new(
image_name: image_name
docker_url: args.docker_url
reset: args.reset
)!
console.print_debug('Pulling image ${args.docker_url} with podman...')
self.podman_pull_and_export(args.docker_url, image_name, rootfs_path)!
}
}
}
@@ -71,21 +67,17 @@ pub fn (mut self ContainerFactory) new(args ContainerNewArgs) !&Container {
return error('Image rootfs not found: ${rootfs_path}. Please ensure the image is available.')
}
// Create container config
// Create container config (with terminal disabled) but don't create the container yet
self.create_container_config(args.name, rootfs_path)!
// Install crun if not installed
// Ensure crun is installed on host
if !osal.cmd_exists('crun') {
mut herorunner := herorunner_installer.new()!
herorunner.install()!
}
// Create container using crun
osal.exec(
cmd: 'crun create --bundle ${self.base_dir}/configs/${args.name} ${args.name}'
stdout: true
)!
// Create container struct but don't create the actual container in crun yet
// The actual container creation will happen in container.start()
mut container := &Container{
name: args.name
factory: &self
@@ -95,14 +87,63 @@ pub fn (mut self ContainerFactory) new(args ContainerNewArgs) !&Container {
return container
}
// Create OCI config.json from template
fn (self ContainerFactory) create_container_config(container_name string, rootfs_path string) ! {
config_dir := '${self.base_dir}/configs/${container_name}'
osal.exec(cmd: 'mkdir -p ${config_dir}', stdout: false)!
// Generate OCI config.json using template
config_content := $tmpl('config_template.json')
config_path := '${config_dir}/config.json'
// Load template
mut config_content := $tmpl('config_template.json')
// Parse JSON with json2
mut root := json2.raw_decode(config_content)!
mut config := root.as_map()
// Get or create process map
mut process := if 'process' in config {
config['process'].as_map()
} else {
map[string]json2.Any{}
}
// Force disable terminal
process['terminal'] = json2.Any(false)
config['process'] = json2.Any(process)
// Write back to config.json
config_path := '${config_dir}/config.json'
mut p := pathlib.get_file(path: config_path, create: true)!
p.write(config_content)!
p.write(json2.encode_pretty(json2.Any(config)))!
}
// Use podman to pull image and extract rootfs
fn (self ContainerFactory) podman_pull_and_export(docker_url string, image_name string, rootfs_path string) ! {
// Pull image
osal.exec(
cmd: 'podman pull ${docker_url}'
stdout: true
)!
// Create temp container
temp_name := 'tmp_${image_name}_${os.getpid()}'
osal.exec(
cmd: 'podman create --name ${temp_name} ${docker_url}'
stdout: true
)!
// Export container filesystem
osal.exec(
cmd: 'mkdir -p ${rootfs_path}'
stdout: false
)!
osal.exec(
cmd: 'podman export ${temp_name} | tar -C ${rootfs_path} -xf -'
stdout: true
)!
// Cleanup temp container
osal.exec(
cmd: 'podman rm ${temp_name}'
stdout: false
)!
}