diff --git a/README.md b/README.md index c1d4811..83fb7e1 100644 --- a/README.md +++ b/README.md @@ -7,21 +7,34 @@ A portainer like application for managing docker containers with a simple UI. ## Roadmap ### Security -- [X] Remove root access for group access method -- [X] Add authentication +- [] Remove root access for group access method +- [] Add authentication ### Containers - [X] Can see all containers - [X] Can see specific container -- [] Can start container TODO: Started coding start container. Need to add to index form. -- [] Can stop container +- [X] Can start container +- [X] Can stop container - [] Can restart container +- [] Can pause container +- [] Can unpause container - [] Can remove container -- [] Can see logs of container -- [] Can see stats of container +- [X] Can see logs of container +- [X] Can see stats of container (CPU, Memory, Network, Disk, IO) - Currently only a snapshot. See (Container Stats Monitoring) - [] Can see exec into container - [] Can see inspect of container -- [] Can create a container (with options) +- [X] Can create a container TODO: Need to test latest container with a fresh pull from dockerhub with noticeable change. +- [] Add UDP and TCP port mapping options. +- [] TODO: When running docker run -p 3000 it automatically creates an external port in the 5xxxx range. Should mimic the same behaviour. + + +### Container Stats Monitoring +- [] Set up the following as a stream + - [] Can see CPU usage + - [] Can see Memory usage + - [] Can see Network usage + - [] Can see Disk usage + - [] Can see IO usage ### Images - [] Can see all images @@ -48,5 +61,15 @@ Long term goal Long term goal ## Starting + +### Locally +`bundle install` to install dependencies `rails s` to start server `./bin/dev` to start server in build mode for tailwind + +### Locally via Docker +`docker build -t azeroth_containers .` to build the docker image +`docker run -p 3000:3000 azeroth_containers` to run the docker image + +### Latest build from Dockerhub (Dev) +`docker run -p 3000:3000 evilgenius13/azerothcontainers:dev` diff --git a/app/controllers/containers_controller.rb b/app/controllers/containers_controller.rb index 1f56790..9cd85bb 100644 --- a/app/controllers/containers_controller.rb +++ b/app/controllers/containers_controller.rb @@ -1,6 +1,7 @@ require 'docker' class ContainersController < ApplicationController + include ContainersHelper before_action :set_docker_url before_action :set_container, only: %i[ show edit update destroy logs start stop stats ] @@ -52,7 +53,8 @@ def stats # GET /containers/new def new - @container = Container.new + @container_form = ContainerForm.new + Rails.logger.debug "@container_form: #{@container_form.inspect}" end # GET /containers/1/edit @@ -61,18 +63,36 @@ def edit # POST /containers or /containers.json def create - @container = Container.new(container_params) + @container_form = ContainerForm.new(container_params) + + if @container_form.valid? + docker_params = create_container_params(params) + + always_pull = ActiveModel::Type::Boolean.new.cast(@container_form.always_pull) + if always_pull || !image_exists_locally?(docker_params['Image']) + unless pull_image(docker_params['Image']) + flash.now[:alert] = "Failed to pull the specified image. Please check the image name and try again." + render :new, status: :unprocessable_entity and return + end + end - respond_to do |format| - if @container.save - format.html { redirect_to container_url(@container), notice: "Container was successfully created." } - format.json { render :show, status: :created, location: @container } + @docker_container = Docker::Container.create(docker_params) + + if @docker_container.id.present? + StartDockerContainerJob.perform_later(@docker_container.id) + redirect_to containers_url, notice: "Container was successfully created." else - format.html { render :new, status: :unprocessable_entity } - format.json { render json: @container.errors, status: :unprocessable_entity } + # Handle the case where the Docker container does not provide an ID + flash[:alert] = "Container creation failed for an unknown reason." + render :new, status: :unprocessable_entity end + else + # This block is reached if the form validation fails + render :new, status: :unprocessable_entity end end + + # PATCH/PUT /containers/1 or /containers/1.json def update @@ -113,6 +133,6 @@ def set_container # Only allow a list of trusted parameters through. def container_params - params.require(:container).permit(:name, :status) + params.require(:container_form).permit(:name, :image, :exposed_ports, :port_bindings, :always_pull) end end diff --git a/app/helpers/containers_helper.rb b/app/helpers/containers_helper.rb index 8e86c0f..70c7c3b 100644 --- a/app/helpers/containers_helper.rb +++ b/app/helpers/containers_helper.rb @@ -4,5 +4,65 @@ def humanize_bytes(bytes) return '0 Bytes' if bytes.to_i <= 0 i = (Math.log(bytes) / Math.log(1024)).floor sprintf("%.2f %s", bytes.to_f / (1024 ** i), units[i]) - end + end + + def create_container_params(params) + container_params = params[:container_form] || {} + puts "Container Parameters: #{container_params}" + + filtered_params = container_params.reject { |key, value| value.blank? } + puts "Filtered Parameters: #{filtered_params}" + + allowed_keys = ['Image', 'name', 'ExposedPorts', 'PortBindings'] + docker_options = {} + + filtered_params.each do |key, value| + docker_key = key == 'name' ? 'name' : key.camelize + + if allowed_keys.include?(docker_key) + case docker_key + when 'ExposedPorts' + # Only handling TCP + port_with_protocol = "#{value}/tcp" + docker_options['ExposedPorts'] = { port_with_protocol => {} } + when 'PortBindings' + # Only handling TCP & using filtered params port which is why it's not camelized. + internal_port_with_protocol = "#{filtered_params['exposed_ports']}/tcp" + external_port = "#{value}" + docker_options['HostConfig'] ||= {} + docker_options['HostConfig']['PortBindings'] = { internal_port_with_protocol => [{ 'HostPort' => external_port }] } + else + docker_options[docker_key] = value + end + end + end + + puts "Docker Options: #{docker_options}" + docker_options + end + + + def image_exists_locally?(image_name) + Docker::Image.exist?(image_name) + rescue Docker::Error::DockerError => e + puts "Error checking if image exists locally: #{e.message}" + false + end + + def pull_image(image_name) + Docker::Image.create('fromImage' => image_name) + puts "Image pulled successfully: #{image_name}" + true + rescue Docker::Error::DockerError => e + puts "Error pulling image: #{e.message}" + false + end + + def handle_image_pull(image_name, always_pull) + if always_pull || !image_exists_locally?(image_name) + pull_image(image_name) + else + puts "Using local image: #{image_name}" + end + end end diff --git a/app/jobs/start_docker_container_job.rb b/app/jobs/start_docker_container_job.rb new file mode 100644 index 0000000..d9f3754 --- /dev/null +++ b/app/jobs/start_docker_container_job.rb @@ -0,0 +1,10 @@ +class StartDockerContainerJob < ApplicationJob + queue_as :default + + def perform(docker_container_id) + docker_container = Docker::Container.get(docker_container_id) + docker_container.start + rescue Docker::Error::DockerError => e + puts "Failed to start container: #{e.message}" + end +end diff --git a/app/models/container_form.rb b/app/models/container_form.rb new file mode 100644 index 0000000..019210e --- /dev/null +++ b/app/models/container_form.rb @@ -0,0 +1,17 @@ +# app/forms/container_form.rb +class ContainerForm + include ActiveModel::Model + + attr_accessor :image, :name, :exposed_ports, :port_bindings, :always_pull + + # Validations + validates :image, presence: true + validate :name_must_be_hyphenated + + private + def name_must_be_hyphenated + unless name.match?(/\A[a-z0-9]+(-[a-z0-9]+)*\z/i) + errors.add(:name, 'must contain only letters, numbers, and hyphens with no consecutive hyphens') + end + end +end diff --git a/app/views/containers/_form.html.erb b/app/views/containers/_form.html.erb index 7677d4f..08f9e28 100644 --- a/app/views/containers/_form.html.erb +++ b/app/views/containers/_form.html.erb @@ -1,27 +1,48 @@ -<%= form_with(model: container) do |form| %> - <% if container.errors.any? %> +<%= form_with(model: container_form, url: containers_path, local: true) do |form| %> + <% flash.each do |type, message| %> +