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| %> +
<%= message %>
+ <% end %> + <% if container_form.errors.any? %>
-

<%= pluralize(container.errors.count, "error") %> prohibited this container from being saved:

+

<%= pluralize(container_form.errors.count, "error") %> prohibited this container from being saved:

<% end %> +
+ <%= form.label :image, style: "display: block" %> + <%= form.text_field :image %> +
+
<%= form.label :name, style: "display: block" %> <%= form.text_field :name %>
- <%= form.label :status, style: "display: block" %> - <%= form.text_field :status %> + <%= form.label :container_port, style: "display: block" %> + <%= form.text_field :exposed_ports %> +
+ +
+ <%= form.label :external_port, style: "display: block" %> + <%= form.text_field :port_bindings %> +
+ +
+ <%= form.label :always_pull, "Always Pull Image" %>
+ <%= radio_button_tag 'container_form[always_pull]', 'true', false %> + <%= label_tag 'container_form_always_pull_true', 'On' %> + <%= radio_button_tag 'container_form[always_pull]', 'false', true %> + <%= label_tag 'container_form_always_pull_false', 'Off' %>
- <%= form.submit %> + <%= form.submit "Deploy Container" %>
<% end %> diff --git a/app/views/containers/new.html.erb b/app/views/containers/new.html.erb index 6db2783..6881dec 100644 --- a/app/views/containers/new.html.erb +++ b/app/views/containers/new.html.erb @@ -1,6 +1,6 @@

New container

-<%= render "form", container: @container %> +<%= render "form", container_form: @container_form %>
diff --git a/db/migrate/20240317175410_add_docker_options_to_containers.rb b/db/migrate/20240317175410_add_docker_options_to_containers.rb new file mode 100644 index 0000000..ddf8da7 --- /dev/null +++ b/db/migrate/20240317175410_add_docker_options_to_containers.rb @@ -0,0 +1,15 @@ +class AddDockerOptionsToContainers < ActiveRecord::Migration[7.1] + def change + remove_column :containers, :status + add_column :containers, :image, :string + add_column :containers, :command, :string + add_column :containers, :environment_variables, :text + add_column :containers, :ports, :text + add_column :containers, :volumes, :text + add_column :containers, :cpu_limit, :string + add_column :containers, :memory_limit, :string + add_column :containers, :restart_policy, :string + add_column :containers, :network_mode, :string + add_column :containers, :labels, :text + end +end diff --git a/db/migrate/20240317175811_make_image_not_null.rb b/db/migrate/20240317175811_make_image_not_null.rb new file mode 100644 index 0000000..c499b4f --- /dev/null +++ b/db/migrate/20240317175811_make_image_not_null.rb @@ -0,0 +1,5 @@ +class MakeImageNotNull < ActiveRecord::Migration[7.1] + def change + change_column_null :containers, :image, false + end +end diff --git a/db/migrate/20240317182009_change_port_names.rb b/db/migrate/20240317182009_change_port_names.rb new file mode 100644 index 0000000..0149340 --- /dev/null +++ b/db/migrate/20240317182009_change_port_names.rb @@ -0,0 +1,6 @@ +class ChangePortNames < ActiveRecord::Migration[7.1] + def change + rename_column :containers, :ports, :exposed_ports + add_column :containers, :port_bindings, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index b0bd706..678e221 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,12 +10,22 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_03_12_202905) do +ActiveRecord::Schema[7.1].define(version: 2024_03_17_182009) do create_table "containers", force: :cascade do |t| t.string "name" - t.string "status" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "image", null: false + t.string "command" + t.text "environment_variables" + t.text "exposed_ports" + t.text "volumes" + t.string "cpu_limit" + t.string "memory_limit" + t.string "restart_policy" + t.string "network_mode" + t.text "labels" + t.text "port_bindings" end end diff --git a/test/jobs/start_docker_container_job_test.rb b/test/jobs/start_docker_container_job_test.rb new file mode 100644 index 0000000..5b263a7 --- /dev/null +++ b/test/jobs/start_docker_container_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class StartDockerContainerJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end