Compare commits

...

5 commits

Author SHA1 Message Date
e59ea9ff44 feat: implement and refactor container engine module
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
This commit introduces a comprehensive implementation and refactoring of
the container engine module. Key enhancements include the addition of
abstract classes and the implementation of container engines.

New features:
- Developed `DockerEngine` and `PodmanEngine` classes, deriving from
  `AbstractContainerEngine` to provide modular functionality.
- Added a CLI `--container-runtime` option, allowing users to select
  their preferred runtime, defaulting to Docker if unspecified.
- Introduced a `container_runtime` property in
  `DocMachine::Build::Config` for improved runtime management.

Refactoring:
- Refactored code to replace hardcoded Docker commands with method calls
  to `AbstractContainerEngine`, promoting code reuse and abstraction.

Signed-off-by: Glenn Y. Rolland <glenux@glenux.net>
2025-03-27 15:37:04 +01:00
60ab198a69 chore: Update .gitignore to include .aider* and .env files 2025-03-27 15:37:04 +01:00
c0ae494c57 feat: Add support for specifying custom Docker image tag in CLI options 2025-03-27 15:37:04 +01:00
f55c06c05e docs: add missing information to README 2025-03-27 15:37:04 +01:00
ab31659e2b Update .drone.yml 2025-03-27 15:37:04 +01:00
9 changed files with 396 additions and 93 deletions

View file

@ -30,7 +30,7 @@ steps:
- name: publish:tag
image: curlimages/curl
environment:
PACKAGE_UPLOAD_URL: https://code.apps.glenux.net/api/packages/glenux/generic/docmachine-utils
PACKAGE_UPLOAD_URL: https://code.apps.glenux.net/api/packages/glenux/generic/docmachine-cli
PACKAGE_BASENAME: docmachine_linux_amd64
PACKAGE_UPLOAD_TOKEN:
from_secret: PACKAGE_UPLOAD_TOKEN

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
/bin
/lib
.aider*
.env

View file

@ -1,40 +1,53 @@
# DocMachine (Utils)
# DocMachine Cli
DocMachine is a CLI tool designed to simplify the process of creating technical documentation and presentations.
DocMachine Cli is a tool designed to simplify the process of creating technical
documentation and presentations.
## Motivation
This project aims to address the following challenges:
* **Automation:** Automate the generation of high-quality technical content, including documentation and presentation slides.
* **Consistency:** Ensure a consistent and polished look and feel across all content pieces.
* **Efficiency:** Reduce the time and effort required to produce content by leveraging AI tools.
* **Automation:** Automate the generation of high-quality technical content,
including documentation and presentation slides.
* **Consistency:** Ensure a consistent and polished look and feel across all
content pieces.
* **Efficiency:** Reduce the time and effort required to produce content by
leveraging AI tools.
## Features
DocMachine offers a range of features to streamline the content creation process:
* **Scaffolding:** Generate a well-structured project directory with all the necessary files.
* **Building:** Compile and publish your content as HTML and PDF documents using Dockerized build processes.
* **Scaffolding:** Generate a well-structured project directory with all the
necessary files.
* **Building:** Compile and publish your content as HTML and PDF documents
using Dockerized build processes.
We are actively developing the following features for future releases:
* **Planning:** Leverage LLMs (Large Language Models) to generate content outlines tailored to your specific needs and requirements.
* **Writing:** Utilize LLMs to draft content for each section and subsection, saving you valuable time and effort.
* **Planning:** Leverage LLMs (Large Language Models) to generate content
outlines tailored to your specific needs and requirements.
* **Writing:** Utilize LLMs to draft content for each section and subsection,
saving you valuable time and effort.
## Prerequisites
FIXME: list prerequisites for crystal lang & dependencies
You'll need a recent version of Crystal (>= 1.11.0) to use this project.
You'll also need to install a few dependencies:
* libreadline-dev
* libncurses-dev
## Getting Started
Follow these steps to start using DocMachine:
Follow these steps to start using DocMachine Cli:
### Installation
```bash
git clone https://code.apps.glenux.net/glenux/docmachine-utils.git docmachine-utils
cd docmachine-utils
git clone https://code.apps.glenux.net/glenux/docmachine-cli.git docmachine-cli
cd docmachine-cli
make build
make install
```
@ -42,13 +55,14 @@ make install
### Create a New Project
```bash
docmachine scaffold my-doc-project
docmachine scaffold my-documentation-project
```
This command will create a new directory named `my-doc-project` with the following structure:
This command will create a new directory named `my-documentation-project` with
the following structure:
```
my-doc-project
my-documentation-project
├── _build
├── docs
│ └── images # link to ../images
@ -60,7 +74,8 @@ my-doc-project
### Start Writing Content
* **Documentation:** Place your Markdown files inside the `docs` directory.
* **Presentations:** Place your Markdown files (using Marp syntax) inside the `slides` directory.
* **Presentations:** Place your Markdown files (using Marp syntax) inside the
`slides` directory.
* **Images:** Store your images in the respective `images` directories.
### Live-reload during writing
@ -70,6 +85,7 @@ docmachine build -a watch
```
This command will start a Docker container and build your documentation and presentations:
* **Documentation:** Built using MkDocs and served on `http://localhost:5100`.
* **Presentations:** Built using Marp and served on `http://localhost:5200`.
@ -100,6 +116,6 @@ We welcome contributions to DocMachine! To contribute:
## License
DocMachine is licensed under the GPL-3.0-or-later license. See the `LICENSE`
file for details.
DocMachine Cli is licensed under the GPL-3.0-or-later license. See the
`LICENSE` file for details.

View file

@ -36,10 +36,18 @@ module DocMachine::Build
config.enable_multiple = true
end
opts.on("-r", "--container-runtime RUNTIME", "Container runtime (default docker)") do |runtime|
config.container_runtime = runtime
end
opts.on("-t", "--tty", "Enable TTY mode (needed for shell)") do
config.enable_tty = true
end
opts.on("-i", "--image", "Use specific image:tag (default glenux/docmachine:latest)") do |image_tag|
config.image_tag = image_tag
end
commands << ->() : Nil do
app = DocMachine::Build::Run.new(config)
app.prepare

View file

@ -1,4 +1,3 @@
module DocMachine::Build
class Config
property data_dir : String = Dir.current
@ -7,6 +6,8 @@ module DocMachine::Build
property port : Int32 = 5100
property enable_multiple : Bool = false
property enable_cache : Bool = false
property image_tag : String = "glenux/docmachine:latest"
property container_runtime : String = "docker"
def initialize(@parent : DocMachine::Config)
end

View file

@ -1,4 +1,3 @@
require "path"
require "file_utils"
require "socket"
@ -6,26 +5,46 @@ require "socket"
require "./module"
require "./config"
require "../common/network"
require "../container/abstract_container_engine"
require "../container/docker_engine"
require "../container/podman_engine"
module DocMachine::Build
class Run
Log = DocMachine::Build::Log.for("run")
# Instance variable for the container engine
property container_engine : DocMachine::Container::AbstractContainerEngine
@basehash : String
@docker_name : String
@docker_opts : Array(String)
@process : Process?
def initialize(@config : DocMachine::Build::Config)
data = "#{@config.data_dir}:#{@config.port}"
@basehash = Digest::SHA256.hexdigest(data)[0..6]
@docker_name = "docmachine-#{@basehash}"
@docker_image = "glenux/docmachine:latest"
@docker_opts = [] of String
@process = nil
# Initialize the container engine based on configuration
@container_engine = (
if @config.container_runtime == "podman"
DocMachine::Container::PodmanEngine.new
else
DocMachine::Container::DockerEngine.new
end
)
end
# cleanup environment
# create directories
# setup permissions
# Cleanup environment
# Create directories
# Setup permissions
def prepare()
Log.info { "basedir = #{@config.data_dir}" }
Log.info { "docker_image = #{@docker_image}" }
Log.info { "container_image = #{@config.image_tag}" }
Log.info { "container_runtime = #{@config.container_runtime}" }
Log.info { "action = #{@config.action}" }
self._pull_image()
@ -34,14 +53,14 @@ module DocMachine::Build
private def _avoid_duplicates
Log.info { "Multiple Instances: stopping duplicate containers (for #{@docker_name})" }
docker_cid = %x{docker ps -f "name=#{@docker_name}" -q}.strip
container_id = @container_engine.find_container_id(@docker_name)
Log.info { "Multiple Instances: docker_name: #{@docker_name}" }
Log.info { "Multiple Instances: docker_cid: #{docker_cid || "-"}" }
Log.info { "Multiple Instances: container_name: #{@docker_name}" }
Log.info { "Multiple Instances: container_id: #{container_id || "-"}" }
if !docker_cid.empty?
Process.run("docker", ["kill", @docker_name])
Process.run("docker", ["rm", @docker_name])
if !container_id.empty?
@container_engine.kill_container(@docker_name)
@container_engine.remove_container(@docker_name)
end
end
@ -49,70 +68,67 @@ module DocMachine::Build
# FIXME: add option to force update
data_cache_dir = if ENV["XDG_CACHE_HOME"]?
Path[ENV["XDG_CACHE_HOME"], "docmachine"]
else Path[ENV["HOME"], ".cache", "docmachine"]
else
Path[ENV["HOME"], ".cache", "docmachine"]
end
## Build cache if it doesnt exist
data_cache_file = data_cache_dir / "image.tar"
## Build cache if it doesn't exist
data_cache_file = data_cache_dir / "image.tar"
Log.info { "Checking cache #{data_cache_file}..." }
if ! File.exists? data_cache_file.to_s
Log.info { "Downloading #{@docker_image} image..." }
Process.run("docker", ["pull", @docker_image], output: STDOUT)
if !File.exists? data_cache_file.to_s
Log.info { "Downloading #{@config.image_tag} image..." }
@container_engine.pull_image(@config.image_tag)
Log.info { "Building cache for image (#{data_cache_dir})" }
FileUtils.mkdir_p(data_cache_dir)
status = Process.run(
"docker",
["image", "save", @docker_image, "-o", data_cache_file.to_s],
output: STDOUT
)
if status.success?
Log.info { "done" }
else
Log.error { "Unable to save cache image" }
exit 1
end
status = @container_engine.save_image(@config.image_tag, data_cache_file.to_s)
else
Log.info { "Cache already exist. Skipping." }
Log.info { "Cache already exists. Skipping." }
end
if @config.enable_cache
Log.info { "Loading #{@docker_image} image from cache..." }
docker_image_loaded = false
status = Process.run(
"docker",
["image", "load", "-i", data_cache_file.to_s],
output: STDOUT
)
if status.success?
Log.info { "done" }
else
Log.error { "Unable to load cache image" }
exit 1
end
Log.info { "Loading #{@config.image_tag} image from cache..." }
status = @container_engine.load_image(data_cache_file.to_s)
else
Log.info { "Loading #{@docker_image} image from local registry..." }
Log.info { "Loading #{@config.image_tag} image from local registry..." }
# FIXME: check that local image exists
end
end
def start()
uid = %x{id -u}.strip
gid = %x{id -g}.strip
Log.info { "uid: #{uid}" }
Log.info { "cid: #{gid}" }
# Start with default uid/gid
ext_uid = %x{id -u}.strip.to_i
ext_gid = %x{id -g}.strip.to_i
# ...but use subuid/subgid if available
File.each_line("/etc/subuid") do |line|
split = line.split(":")
next if split[0] != %x{id -u -n}.strip
subuid = split[1].to_i
ext_uid += subuid - 1
end
File.each_line("/etc/subgid") do |line|
split = line.split(":")
next if split[0] != %x{id -g -n}.strip
subgid = split[1].to_i
ext_gid += subgid - 1
end
Log.info { "ext uid: #{ext_uid}" }
Log.info { "ext gid: #{ext_gid}" }
docker_opts = [] of String
docker_opts << "run"
docker_opts << "-i"
# add tty support
# Add tty support
docker_opts << "-t" if @config.enable_tty
# add container name
# Add container name
docker_opts.concat ["--name", @docker_name]
docker_opts << "--rm"
docker_opts << "--shm-size=1gb"
docker_opts.concat ["-e", "EXT_UID=#{uid}"]
docker_opts.concat ["-e", "EXT_GID=#{gid}"]
# docker_opts << "--privileged"
docker_opts.concat ["-e", "EXT_UID=#{ext_uid}"]
docker_opts.concat ["-e", "EXT_GID=#{ext_gid}"]
docker_opts.concat ["-v", "#{@config.data_dir}/docs:/app/docs"]
docker_opts.concat ["-v", "#{@config.data_dir}/slides:/app/slides"]
docker_opts.concat ["-v", "#{@config.data_dir}/images:/app/images"]
@ -125,7 +141,7 @@ module DocMachine::Build
Log.info { "Theme: detected Marp files. Adding option to command line (#{docker_opt_marp_theme})" }
else
Log.info { "Theme: no theme detected. Using default files" }
end
end
## Detect Mkdocs configuration - old format (full)
if File.exists?("#{@config.data_dir}/mkdocs.yml")
@ -140,7 +156,7 @@ module DocMachine::Build
Log.info { "Docs: detected mkdocs-patch.yml file. Adding option to command line (#{docker_opt_mkdocs_config})" }
else
Log.info { "Docs: no mkdocs-patch.yml detected. Using default files" }
end
end
## Detect docs
if Dir.exists?("#{@config.data_dir}/docs")
@ -157,20 +173,30 @@ module DocMachine::Build
## Detect slides
if Dir.exists?("#{@config.data_dir}/slides")
Log.info { "Slides: detected slides directory." }
marp_port = Network.find_port(@config.port+100)
marp_port = Network.find_port(@config.port + 100)
docker_opt_marp_port = ["-p", "#{marp_port}:5200"]
docker_opts.concat docker_opt_marp_port
Log.info { "Slides: Adding option to command line (#{docker_opt_marp_port})" }
Log.notice { "Slides: Using port #{marp_port} for slides" }
else
Log.info { "Slides: no slides directory detected." }
end
end
docker_opts << @docker_image
docker_opts << @config.image_tag
docker_opts << @config.action
Log.info { docker_opts.inspect.colorize(:yellow) }
@process = Process.new("docker", docker_opts, output: STDOUT, error: STDERR)
Log.info {
docker_str = [@config.container_runtime].concat(docker_opts).join(" ").colorize(:yellow)
"#{@config.container_runtime.capitalize}: #{docker_str}"
}
@process = @container_engine.run_container(
@config.image_tag,
@config.action,
@docker_name,
docker_opts,
@config.enable_tty
)
end
def wait()
@ -179,16 +205,9 @@ module DocMachine::Build
Signal::INT.trap do
Log.warn { "Received CTRL-C" }
process.signal(Signal::KILL)
Process.run("docker", ["kill", @docker_name])
@container_engine.kill_container(@docker_name)
end
process.wait
end
def stop()
end
def docker_opts()
end
end
end

View file

@ -0,0 +1,41 @@
module DocMachine::Container
class ContainerError < Exception ; end
class SaveError < ContainerError ; end
class KillError < ContainerError ; end
class RemoveError < ContainerError ; end
class LoadError < ContainerError ; end
class RunError < ContainerError ; end
class PullError < ContainerError ; end
abstract class AbstractContainerEngine
# Pulls the specified Docker/Podman image
abstract def pull_image(image_tag : String) : Nil
# Loads the Docker/Podman image from a local cache
abstract def load_image(cache_path : String) : Nil
# Saves the Docker/Podman image to a local cache
abstract def save_image(image_tag : String, cache_path : String) : Nil
# Runs a container with the given options
abstract def run_container(
image_tag : String,
action : String,
docker_name : String,
docker_opts : Array(String),
enable_tty : Bool
) : Process
# Kills a running container by name
abstract def kill_container(container_name : String) : Nil
# Removes a container by name
abstract def remove_container(container_name : String) : Nil
# Finds the container ID based on its name
abstract def find_container_id(name : String) : String
end
end

View file

@ -0,0 +1,109 @@
module DocMachine::Container
class DockerEngine < AbstractContainerEngine
# Pulls the specified Docker image
def pull_image(image_tag : String) : Nil
Log.info { "Pulling Docker image: #{image_tag}" }
status = Process.run("docker", ["pull", image_tag], output: STDOUT, error: STDERR)
if status.success?
Log.info { "Successfully pulled Docker image: #{image_tag}" }
true
else
Log.error { "Failed to pull Docker image: #{image_tag}" }
false
end
end
# Loads the Docker image from a local cache
def load_image(cache_path : String) : Nil
Log.info { "Loading Docker image from cache: #{cache_path}" }
status = Process.run("docker", ["image", "load", "-i", cache_path], output: STDOUT, error: STDERR)
if status.success?
Log.info { "Successfully loaded Docker image from cache." }
true
else
Log.error { "Failed to load Docker image from cache." }
false
end
end
# Saves the Docker image to a local cache
def save_image(image_tag : String, cache_path : String) : Nil
Log.info { "Saving Docker image #{image_tag} to cache at #{cache_path}" }
status = Process.run("docker", ["image", "save", image_tag, "-o", cache_path], output: STDOUT, error: STDERR)
if status.success?
Log.info { "Successfully saved Docker image to cache." }
true
else
Log.error { "Failed to save Docker image to cache." }
false
end
end
# Runs a Docker container with the given options
def run_container(
image_tag : String,
action : String,
docker_name : String,
docker_opts : Array(String),
enable_tty : Bool
) : Process
Log.info { "Running Docker container: #{docker_name}" }
# Construct the full Docker run command
cmd = ["docker", "run"] + docker_opts + [image_tag, action]
# Log the command for debugging
Log.debug { "Docker run command: #{cmd.join(" ")}" }
# Start the Docker container process
process = Process.new("docker", ["run"] + docker_opts + [image_tag, action], input: STDIN, output: STDOUT, error: STDERR)
Log.info { "Docker container #{docker_name} started." }
process
end
# Kills a running Docker container by name
def kill_container(container_name : String) : Nil
Log.info { "Killing Docker container: #{container_name}" }
status = Process.run("docker", ["kill", container_name], output: STDOUT, error: STDERR)
if status.success?
Log.info { "Successfully killed Docker container: #{container_name}" }
true
else
Log.error { "Failed to kill Docker container: #{container_name}" }
false
end
end
# Removes a Docker container by name
def remove_container(container_name : String) : Nil
Log.info { "Removing Docker container: #{container_name}" }
status = Process.run("docker", ["rm", container_name], output: STDOUT, error: STDERR)
if status.success?
Log.info { "Successfully removed Docker container: #{container_name}" }
true
else
Log.error { "Failed to remove Docker container: #{container_name}" }
false
end
end
# Finds the container ID based on its name
def find_container_id(name : String) : String
Log.info { "Finding Docker container ID for name: #{name}" }
output = IO::Memory.new
status = Process.run("docker", ["ps", "-f", "name=#{name}", "-q"], output: output, error: STDERR)
if status.success?
container_id = output.to_s.strip
Log.info { "Found Docker container ID: #{container_id}" }
container_id
else
Log.error { "Failed to find Docker container ID for name: #{name}" }
""
end
end
end
end

View file

@ -0,0 +1,107 @@
module DocMachine::Container
class PodmanEngine < AbstractContainerEngine
# Pulls the specified Podman image
def pull_image(image_tag : String) : Nil
Log.info { "Pulling Podman image: #{image_tag}" }
status = Process.run("podman", ["pull", image_tag], output: STDOUT, error: STDERR)
if status.success?
Log.info { "Successfully pulled Podman image: #{image_tag}" }
true
else
Log.error { "Failed to pull Podman image: #{image_tag}" }
false
end
end
# Loads the Podman image from a local cache
def load_image(cache_path : String) : Nil
Log.info { "Loading Podman image from cache: #{cache_path}" }
status = Process.run("podman", ["image", "load", "-i", cache_path], output: STDOUT, error: STDERR)
if status.success?
Log.info { "Successfully loaded Podman image from cache." }
else
Log.error { "Failed to load Podman image from cache." }
raise LoadError.new("Failed to load Podman image from cache.")
end
end
# Saves the Podman image to a local cache
def save_image(image_tag : String, cache_path : String) : Nil
Log.info { "Saving Podman image #{image_tag} to cache at #{cache_path}" }
status = Process.run("podman", ["image", "save", image_tag, "-o", cache_path], output: STDOUT, error: STDERR)
if status.success?
Log.info { "Successfully saved Podman image to cache." }
else
Log.error { "Failed to save Podman image to cache." }
raise SaveError.new("Failed to save Podman image to cache.")
end
end
# Runs a Podman container with the given options
def run_container(
image_tag : String,
action : String,
docker_name : String,
docker_opts : Array(String),
enable_tty : Bool
) : Process
Log.info { "Running Podman container: #{docker_name}" }
# Construct the full Podman run command
cmd = ["podman", "run"] + docker_opts + [image_tag, action]
# Log the command for debugging
Log.debug { "Podman run command: #{cmd.join(" ")}" }
# Start the Podman container process
process = Process.new("podman", ["run"] + docker_opts + [image_tag, action], input: STDIN, output: STDOUT, error: STDERR)
Log.info { "Podman container #{docker_name} started." }
process
end
# Kills a running Podman container by name
def kill_container(container_name : String) : Nil
Log.info { "Killing Podman container: #{container_name}" }
status = Process.run("podman", ["kill", container_name], output: STDOUT, error: STDERR)
if status.success?
Log.info { "Successfully killed Podman container: #{container_name}" }
true
else
Log.error { "Failed to kill Podman container: #{container_name}" }
false
end
end
# Removes a Podman container by name
def remove_container(container_name : String) : Nil
Log.info { "Removing Podman container: #{container_name}" }
status = Process.run("podman", ["rm", container_name], output: STDOUT, error: STDERR)
if status.success?
Log.info { "Successfully removed Podman container: #{container_name}" }
true
else
Log.error { "Failed to remove Podman container: #{container_name}" }
false
end
end
# Finds the container ID based on its name
def find_container_id(name : String) : String
Log.info { "Finding Podman container ID for name: #{name}" }
output = IO::Memory.new
status = Process.run("podman", ["ps", "-f", "name=#{name}", "-q"], output: output, error: STDERR)
if status.success?
container_id = output.to_s.strip
Log.info { "Found Podman container ID: #{container_id}" }
container_id
else
Log.error { "Failed to find Podman container ID for name: #{name}" }
""
end
end
end
end