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:
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)!
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user