diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33db5b9f5..663d4b316 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,26 @@ jobs: suite: ${{ matrix.suite }} os: ${{ matrix.os }} + integration-swarm: + needs: lint-unit + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Install Chef + uses: actionshub/chef-install@3.0.1 + - name: Install Docker + uses: docker/setup-docker-action@v4 + - name: Test Kitchen + uses: actionshub/test-kitchen@3.0.0 + env: + CHEF_VERSION: latest + CHEF_LICENSE: accept-no-persist + KITCHEN_LOCAL_YAML: kitchen.exec.yml + with: + suite: swarm + os: ubuntu-latest + integration-smoke: needs: lint-unit runs-on: ubuntu-latest diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 000000000..58f0386e1 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby system diff --git a/kitchen.exec.yml b/kitchen.exec.yml index ba7b2a962..6f7a3d53a 100644 --- a/kitchen.exec.yml +++ b/kitchen.exec.yml @@ -3,5 +3,27 @@ driver: { name: exec } transport: { name: exec } platforms: - - name: macos-latest - - name: windows-latest + - name: ubuntu-latest + +suites: + - name: swarm + provisioner: + enforce_idempotency: false + multiple_converge: 1 + attributes: + docker: + version: '20.10.11' + swarm: + init: + advertise_addr: '127.0.0.1' + listen_addr: '0.0.0.0:2377' + rotate_token: true + service: + name: 'web' + image: 'nginx:latest' + publish: ['80:80'] + replicas: 2 + run_list: + - recipe[docker_test::swarm_default] + - recipe[docker_test::swarm_init] + - recipe[docker_test::swarm_service] diff --git a/kitchen.yml b/kitchen.yml index 99ae75796..8657ca411 100644 --- a/kitchen.yml +++ b/kitchen.yml @@ -123,6 +123,55 @@ suites: - recipe[docker_test::default] - recipe[docker_test::registry] + #################### + # swarm testing + #################### + + - name: swarm + driver: + network: + - ["private_network", {ip: "192.168.56.10"}] + provisioner: + enforce_idempotency: false + multiple_converge: 1 + attributes: + docker: + version: '20.10.11' + swarm: + init: + advertise_addr: '192.168.56.10' + listen_addr: '0.0.0.0:2377' + rotate_token: true + service: + name: 'web' + image: 'nginx:latest' + publish: ['80:80'] + replicas: 2 + run_list: + - recipe[docker_test::swarm_default] + - recipe[docker_test::swarm_init] + - recipe[docker_test::swarm_service] + + - name: swarm_worker + driver: + network: + - ["private_network", {ip: "192.168.56.11"}] + provisioner: + enforce_idempotency: false + multiple_converge: 1 + attributes: + docker: + version: '20.10.11' + swarm: + join: + manager_ip: '192.168.56.10:2377' + advertise_addr: '192.168.56.11' + listen_addr: '0.0.0.0:2377' + # Token will be obtained from the manager node + run_list: + - recipe[docker_test::swarm_default] + - recipe[docker_test::swarm_join] + ############################# # quick service smoke testing ############################# diff --git a/libraries/helpers_swarm.rb b/libraries/helpers_swarm.rb new file mode 100644 index 000000000..1c33aa623 --- /dev/null +++ b/libraries/helpers_swarm.rb @@ -0,0 +1,58 @@ +module DockerCookbook + module DockerHelpers + module Swarm + def swarm_init_cmd(resource = nil) + cmd = %w(docker swarm init) + cmd << "--advertise-addr #{resource.advertise_addr}" if resource && resource.advertise_addr + cmd << "--listen-addr #{resource.listen_addr}" if resource && resource.listen_addr + cmd << '--force-new-cluster' if resource && resource.force_new_cluster + cmd + end + + def swarm_join_cmd(resource = nil) + cmd = %w(docker swarm join) + cmd << "--token #{resource.token}" if resource + cmd << "--advertise-addr #{resource.advertise_addr}" if resource && resource.advertise_addr + cmd << "--listen-addr #{resource.listen_addr}" if resource && resource.listen_addr + cmd << resource.manager_ip if resource + cmd + end + + def swarm_leave_cmd(resource = nil) + cmd = %w(docker swarm leave) + cmd << '--force' if resource && resource.force + cmd + end + + def swarm_token_cmd(token_type) + raise 'Token type must be worker or manager' unless %w(worker manager).include?(token_type) + %w(docker swarm join-token -q) << token_type + end + + def swarm_member? + cmd = Mixlib::ShellOut.new('docker info --format "{{ .Swarm.LocalNodeState }}"') + cmd.run_command + return false if cmd.error? + cmd.stdout.strip == 'active' + end + + def swarm_manager? + return false unless swarm_member? + cmd = Mixlib::ShellOut.new('docker info --format "{{ .Swarm.ControlAvailable }}"') + cmd.run_command + return false if cmd.error? + cmd.stdout.strip == 'true' + end + + def swarm_worker? + swarm_member? && !swarm_manager? + end + + def service_exists?(name) + cmd = Mixlib::ShellOut.new("docker service inspect #{name}") + cmd.run_command + !cmd.error? + end + end + end +end diff --git a/resources/swarm_init.rb b/resources/swarm_init.rb new file mode 100644 index 000000000..85d1def42 --- /dev/null +++ b/resources/swarm_init.rb @@ -0,0 +1,35 @@ +unified_mode true + +include DockerCookbook::DockerHelpers::Swarm + +resource_name :docker_swarm_init +provides :docker_swarm_init + +property :advertise_addr, String +property :listen_addr, String +property :force_new_cluster, [true, false], default: false +property :autolock, [true, false], default: false + +action :init do + return if swarm_member? + + converge_by 'initializing docker swarm' do + cmd = Mixlib::ShellOut.new(swarm_init_cmd(new_resource).join(' ')) + cmd.run_command + if cmd.error? + raise "Failed to initialize swarm: #{cmd.stderr}" + end + end +end + +action :leave do + return unless swarm_member? + + converge_by 'leaving docker swarm' do + cmd = Mixlib::ShellOut.new('docker swarm leave --force') + cmd.run_command + if cmd.error? + raise "Failed to leave swarm: #{cmd.stderr}" + end + end +end diff --git a/resources/swarm_join.rb b/resources/swarm_join.rb new file mode 100644 index 000000000..cb4bdeaee --- /dev/null +++ b/resources/swarm_join.rb @@ -0,0 +1,36 @@ +unified_mode true + +include DockerCookbook::DockerHelpers::Swarm + +resource_name :docker_swarm_join +provides :docker_swarm_join + +property :token, String, required: true +property :manager_ip, String, required: true +property :advertise_addr, String +property :listen_addr, String +property :data_path_addr, String + +action :join do + return if swarm_member? + + converge_by 'joining docker swarm' do + cmd = Mixlib::ShellOut.new(swarm_join_cmd.join(' ')) + cmd.run_command + if cmd.error? + raise "Failed to join swarm: #{cmd.stderr}" + end + end +end + +action :leave do + return unless swarm_member? + + converge_by 'leaving docker swarm' do + cmd = Mixlib::ShellOut.new('docker swarm leave --force') + cmd.run_command + if cmd.error? + raise "Failed to leave swarm: #{cmd.stderr}" + end + end +end diff --git a/resources/swarm_service.rb b/resources/swarm_service.rb new file mode 100644 index 000000000..9c8935cd3 --- /dev/null +++ b/resources/swarm_service.rb @@ -0,0 +1,121 @@ +unified_mode true + +include DockerCookbook::DockerHelpers::Swarm + +resource_name :docker_swarm_service +provides :docker_swarm_service + +property :service_name, String, name_property: true +property :image, String, required: true +property :command, [String, Array] +property :replicas, Integer, default: 1 +property :env, [Array], default: [] +property :labels, [Hash], default: {} +property :mounts, [Array], default: [] +property :networks, [Array], default: [] +property :ports, [Array], default: [] +property :constraints, [Array], default: [] +property :secrets, [Array], default: [] +property :configs, [Array], default: [] +property :restart_policy, Hash, default: { condition: 'any' } + +# Health check +property :healthcheck_cmd, String +property :healthcheck_interval, String +property :healthcheck_timeout, String +property :healthcheck_retries, Integer + +load_current_value do |new_resource| + cmd = Mixlib::ShellOut.new("docker service inspect #{new_resource.service_name}") + cmd.run_command + if cmd.error? + current_value_does_not_exist! + else + service_info = JSON.parse(cmd.stdout).first + image service_info['Spec']['TaskTemplate']['ContainerSpec']['Image'] + command service_info['Spec']['TaskTemplate']['ContainerSpec']['Command'] + env service_info['Spec']['TaskTemplate']['ContainerSpec']['Env'] + replicas service_info['Spec']['Mode']['Replicated']['Replicas'] + end +end + +action :create do + return unless swarm_manager? + + converge_if_changed do + cmd = create_service_cmd(new_resource) + + converge_by "creating service #{new_resource.service_name}" do + shell_out!(cmd.join(' ')) + end + end +end + +action :update do + return unless swarm_manager? + return unless service_exists?(new_resource) + + converge_if_changed do + cmd = update_service_cmd(new_resource) + + converge_by "updating service #{new_resource.service_name}" do + shell_out!(cmd.join(' ')) + end + end +end + +action :delete do + return unless swarm_manager? + return unless service_exists?(new_resource) + + converge_by "deleting service #{new_resource.service_name}" do + shell_out!("docker service rm #{new_resource.service_name}") + end +end + +action_class do + def service_exists?(new_resource) + cmd = Mixlib::ShellOut.new("docker service inspect #{new_resource.service_name}") + cmd.run_command + !cmd.error? + end + + def create_service_cmd(new_resource) + cmd = %w(docker service create) + cmd << "--name #{new_resource.service_name}" + cmd << "--replicas #{new_resource.replicas}" + + new_resource.env.each { |e| cmd << "--env #{e}" } + new_resource.labels.each { |k, v| cmd << "--label #{k}=#{v}" } + new_resource.mounts.each { |m| cmd << "--mount #{m}" } + new_resource.networks.each { |n| cmd << "--network #{n}" } + new_resource.ports.each { |p| cmd << "--publish #{p}" } + new_resource.constraints.each { |c| cmd << "--constraint #{c}" } + + if new_resource.restart_policy + cmd << "--restart-condition #{new_resource.restart_policy[:condition]}" + cmd << "--restart-delay #{new_resource.restart_policy[:delay]}" if new_resource.restart_policy[:delay] + cmd << "--restart-max-attempts #{new_resource.restart_policy[:max_attempts]}" if new_resource.restart_policy[:max_attempts] + cmd << "--restart-window #{new_resource.restart_policy[:window]}" if new_resource.restart_policy[:window] + end + + if new_resource.healthcheck_cmd + cmd << "--health-cmd #{new_resource.healthcheck_cmd}" + cmd << "--health-interval #{new_resource.healthcheck_interval}" if new_resource.healthcheck_interval + cmd << "--health-timeout #{new_resource.healthcheck_timeout}" if new_resource.healthcheck_timeout + cmd << "--health-retries #{new_resource.healthcheck_retries}" if new_resource.healthcheck_retries + end + + cmd << new_resource.image + cmd << new_resource.command if new_resource.command + cmd + end + + def update_service_cmd(new_resource) + cmd = %w(docker service update) + cmd << "--image #{new_resource.image}" + cmd << "--replicas #{new_resource.replicas}" + cmd << new_resource.service_name + cmd + end +end diff --git a/resources/swarm_token.rb b/resources/swarm_token.rb new file mode 100644 index 000000000..c41b6995f --- /dev/null +++ b/resources/swarm_token.rb @@ -0,0 +1,43 @@ +unified_mode true + +include DockerCookbook::DockerHelpers::Swarm + +resource_name :docker_swarm_token +provides :docker_swarm_token + +property :token_type, String, name_property: true, equal_to: %w(worker manager) +property :rotate, [true, false], default: false + +load_current_value do + if swarm_manager? + cmd = Mixlib::ShellOut.new("docker swarm join-token -q #{token_type}") + cmd.run_command + current_value_does_not_exist! if cmd.error? + else + current_value_does_not_exist! + end +end + +action :read do + if swarm_manager? + cmd = Mixlib::ShellOut.new(swarm_token_cmd(token_type).join(' ')) + cmd.run_command + raise "Error getting #{token_type} token: #{cmd.stderr}" if cmd.error? + + node.run_state['docker_swarm'] ||= {} + node.run_state['docker_swarm']["#{token_type}_token"] = cmd.stdout.strip + end +end + +action :rotate do + return unless swarm_manager? + + converge_by "rotating #{token_type} token" do + cmd = Mixlib::ShellOut.new("docker swarm join-token --rotate -q #{token_type}") + cmd.run_command + raise "Error rotating #{token_type} token: #{cmd.stderr}" if cmd.error? + + node.run_state['docker_swarm'] ||= {} + node.run_state['docker_swarm']["#{token_type}_token"] = cmd.stdout.strip + end +end diff --git a/spec/unit/resources/swarm_init_spec.rb b/spec/unit/resources/swarm_init_spec.rb new file mode 100644 index 000000000..825c11aeb --- /dev/null +++ b/spec/unit/resources/swarm_init_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe 'docker_swarm_init' do + step_into :docker_swarm_init + platform 'ubuntu' + + context 'when initializing a new swarm' do + recipe do + docker_swarm_init 'initialize' do + advertise_addr '192.168.1.2' + listen_addr '0.0.0.0:2377' + end + end + + before do + # Mock the shell_out calls directly + shellout = double('shellout') + allow(Mixlib::ShellOut).to receive(:new).and_return(shellout) + allow(shellout).to receive(:run_command) + allow(shellout).to receive(:error?).and_return(false) + allow(shellout).to receive(:stdout).and_return('') + allow(shellout).to receive(:stderr).and_return('') + end + + it 'converges successfully' do + expect { chef_run }.to_not raise_error + end + + it 'runs the swarm init command' do + expect(Mixlib::ShellOut).to receive(:new).with(/docker swarm init/) + chef_run + end + end + + context 'when swarm is already initialized' do + recipe do + docker_swarm_init 'initialize' + end + + before do + # Mock the shell_out calls directly + shellout = double('shellout') + allow(Mixlib::ShellOut).to receive(:new).and_return(shellout) + allow(shellout).to receive(:run_command) + allow(shellout).to receive(:error?).and_return(false) + allow(shellout).to receive(:stdout).and_return('active') + allow(shellout).to receive(:stderr).and_return('') + end + + it 'does not run init command if already in swarm' do + expect(Mixlib::ShellOut).not_to receive(:new).with(/docker swarm init/) + chef_run + end + end +end diff --git a/spec/unit/resources/swarm_join_spec.rb b/spec/unit/resources/swarm_join_spec.rb new file mode 100644 index 000000000..c867a4c44 --- /dev/null +++ b/spec/unit/resources/swarm_join_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe 'docker_swarm_join' do + step_into :docker_swarm_join + platform 'ubuntu' + + context 'when joining a swarm' do + recipe do + docker_swarm_join 'join' do + token 'SWMTKN-1-random-token' + manager_ip '192.168.1.1:2377' + advertise_addr '192.168.1.2' + end + end + + before do + # Mock the shell_out calls directly + shellout = double('shellout') + allow(Mixlib::ShellOut).to receive(:new).and_return(shellout) + allow(shellout).to receive(:run_command) + allow(shellout).to receive(:error?).and_return(false) + allow(shellout).to receive(:stdout).and_return('') + allow(shellout).to receive(:stderr).and_return('') + end + + it 'converges successfully' do + expect { chef_run }.to_not raise_error + end + + it 'runs the swarm join command' do + expect(Mixlib::ShellOut).to receive(:new).with(/docker swarm join/) + chef_run + end + end + + context 'when already in a swarm' do + recipe do + docker_swarm_join 'join' do + token 'SWMTKN-1-random-token' + manager_ip '192.168.1.1:2377' + end + end + + before do + # Mock the shell_out calls directly + shellout = double('shellout') + allow(Mixlib::ShellOut).to receive(:new).and_return(shellout) + allow(shellout).to receive(:run_command) + allow(shellout).to receive(:error?).and_return(false) + allow(shellout).to receive(:stdout).and_return('active') + allow(shellout).to receive(:stderr).and_return('') + end + + it 'does not run join command if already in swarm' do + expect(Mixlib::ShellOut).not_to receive(:new).with(/docker swarm join/) + chef_run + end + end +end diff --git a/spec/unit/resources/swarm_service_spec.rb b/spec/unit/resources/swarm_service_spec.rb new file mode 100644 index 000000000..f3a762cc3 --- /dev/null +++ b/spec/unit/resources/swarm_service_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' + +describe 'docker_swarm_service' do + step_into :docker_swarm_service + platform 'ubuntu' + + context 'when creating a service' do + recipe do + docker_swarm_service 'nginx' do + image 'nginx:latest' + replicas 2 + ports %w(80:80) + end + end + + before do + # Mock swarm status + allow_any_instance_of(Chef::Resource).to receive(:shell_out).with('docker info --format "{{ .Swarm.LocalNodeState }}"').and_return( + double(error?: false, stdout: "active\n") + ) + allow_any_instance_of(Chef::Resource).to receive(:shell_out).with('docker info --format "{{ .Swarm.ControlAvailable }}"').and_return( + double(error?: false, stdout: "true\n") + ) + + # Mock service inspection + allow_any_instance_of(Chef::Resource).to receive(:shell_out).with('docker service inspect nginx').and_return( + double(error?: true, stdout: '', stderr: 'Error: no such service: nginx') + ) + + # Mock service creation + allow_any_instance_of(Chef::Resource).to receive(:shell_out).with(/docker service create/).and_return( + double(error?: false, stdout: '') + ) + end + + it 'converges successfully' do + expect { chef_run }.to_not raise_error + end + end + + context 'when not a swarm manager' do + recipe do + docker_swarm_service 'nginx' do + image 'nginx:latest' + replicas 2 + ports %w(80:80) + end + end + + before do + # Mock swarm status - member but not manager + allow_any_instance_of(Chef::Resource).to receive(:shell_out).with('docker info --format "{{ .Swarm.LocalNodeState }}"').and_return( + double(error?: false, stdout: "active\n") + ) + allow_any_instance_of(Chef::Resource).to receive(:shell_out).with('docker info --format "{{ .Swarm.ControlAvailable }}"').and_return( + double(error?: false, stdout: "false\n") + ) + + # Mock service inspection + allow_any_instance_of(Chef::Resource).to receive(:shell_out).with('docker service inspect nginx').and_return( + double(error?: true, stdout: '', stderr: 'Error: no such service: nginx') + ) + end + + it 'does not create the service' do + expect(chef_run).to_not run_execute('create service nginx') + end + end +end diff --git a/test/cookbooks/docker_test/recipes/swarm_default.rb b/test/cookbooks/docker_test/recipes/swarm_default.rb new file mode 100644 index 000000000..67195ad0a --- /dev/null +++ b/test/cookbooks/docker_test/recipes/swarm_default.rb @@ -0,0 +1,11 @@ +# This is a minimal default recipe for swarm testing +# It only installs Docker without the additional dependencies + +docker_installation_script 'default' do + repo node['docker']['repo'] + action :create +end + +docker_service 'default' do + action [:create, :start] +end diff --git a/test/cookbooks/docker_test/recipes/swarm_init.rb b/test/cookbooks/docker_test/recipes/swarm_init.rb new file mode 100644 index 000000000..9873d4489 --- /dev/null +++ b/test/cookbooks/docker_test/recipes/swarm_init.rb @@ -0,0 +1,26 @@ +docker_installation_package 'default' do + version node['docker']['version'] if node['docker']['version'] + action :create +end + +docker_swarm_init 'initialize swarm' do + advertise_addr node['docker']['swarm']['init']['advertise_addr'] + listen_addr node['docker']['swarm']['init']['listen_addr'] + action :init +end + +# Read or rotate the worker token +docker_swarm_token 'worker' do + rotate node['docker']['swarm']['rotate_token'] if node['docker']['swarm']['rotate_token'] + action node['docker']['swarm']['rotate_token'] ? :rotate : :read + notifies :create, 'ruby_block[save_token]', :immediately +end + +# Save the token to a node attribute for use by workers +ruby_block 'save_token' do + block do + node.override['docker']['swarm']['tokens'] ||= {} + node.override['docker']['swarm']['tokens']['worker'] = node.run_state['docker_swarm']['worker_token'] + end + action :nothing +end diff --git a/test/cookbooks/docker_test/recipes/swarm_join.rb b/test/cookbooks/docker_test/recipes/swarm_join.rb new file mode 100644 index 000000000..b46a32900 --- /dev/null +++ b/test/cookbooks/docker_test/recipes/swarm_join.rb @@ -0,0 +1,17 @@ +# We need to get the token from the manager node +# In a real environment, you would use a more secure way to distribute the token +ruby_block 'wait for manager' do + block do + # Simple wait to ensure manager is up + sleep 10 + end + action :run +end + +docker_swarm_join 'join swarm' do + advertise_addr node['docker']['swarm']['join']['advertise_addr'] + listen_addr node['docker']['swarm']['join']['listen_addr'] + manager_ip node['docker']['swarm']['join']['manager_ip'] + token node['docker']['swarm']['join']['token'] + action :join +end diff --git a/test/cookbooks/docker_test/recipes/swarm_service.rb b/test/cookbooks/docker_test/recipes/swarm_service.rb new file mode 100644 index 000000000..c8ea0c65a --- /dev/null +++ b/test/cookbooks/docker_test/recipes/swarm_service.rb @@ -0,0 +1,27 @@ +# Wait a bit to ensure the swarm is ready +ruby_block 'wait for swarm initialization' do + block do + sleep 10 + end + action :run +end + +docker_swarm_service node['docker']['swarm']['service']['name'] do + image node['docker']['swarm']['service']['image'] + ports node['docker']['swarm']['service']['publish'] + replicas node['docker']['swarm']['service']['replicas'] + action :create +end + +# Add a test to verify the service is running +ruby_block 'verify service' do + block do + 20.times do # try for about 1 minute + cmd = Mixlib::ShellOut.new('docker service ls') + cmd.run_command + break if cmd.stdout =~ /#{node['docker']['swarm']['service']['name']}/ + sleep 3 + end + end + action :run +end diff --git a/test/integration/swarm/controls/swarm_test.rb b/test/integration/swarm/controls/swarm_test.rb new file mode 100644 index 000000000..a971a7157 --- /dev/null +++ b/test/integration/swarm/controls/swarm_test.rb @@ -0,0 +1,58 @@ +control 'docker-swarm-1' do + impact 1.0 + title 'Docker Swarm Installation' + desc 'Verify Docker is installed and Swarm mode is active' + + describe command('docker --version') do + its('exit_status') { should eq 0 } + its('stdout') { should match(/Docker version/) } + end + + describe command('docker info --format "{{ .Swarm.LocalNodeState }}"') do + its('exit_status') { should eq 0 } + its('stdout') { should match(/active/) } + end + + describe command('docker info --format "{{ .Swarm.ControlAvailable }}"') do + its('exit_status') { should eq 0 } + its('stdout') { should match(/true/) } + end +end + +control 'docker-swarm-2' do + impact 1.0 + title 'Docker Swarm Service' + desc 'Verify the test service is running correctly' + + describe command('docker service ls --format "{{.Name}}"') do + its('exit_status') { should eq 0 } + its('stdout') { should match(/web/) } + end + + describe command('docker service inspect web') do + its('exit_status') { should eq 0 } + its('stdout') { should match(/"Image":\s*"nginx:latest"/) } + its('stdout') { should match(/"Replicas":\s*2/) } + end + + describe command('docker service ps web --format "{{.CurrentState}}"') do + its('exit_status') { should eq 0 } + its('stdout') { should match(/Running/) } + end +end + +control 'docker-swarm-3' do + impact 1.0 + title 'Docker Swarm Network' + desc 'Verify swarm networking is configured correctly' + + describe command('docker network ls --filter driver=overlay --format "{{.Name}}"') do + its('exit_status') { should eq 0 } + its('stdout') { should match(/ingress/) } + end + + describe port(2377) do + it { should be_listening } + its('protocols') { should include 'tcp' } + end +end