diff --git a/.gitlab/ci/test-on-cng/main.gitlab-ci.yml b/.gitlab/ci/test-on-cng/main.gitlab-ci.yml index 07fc2f56c66939b0f6db85ec864480180032a2fd..44a5ac24768634f4dcd37f570cf588d925d71a44 100644 --- a/.gitlab/ci/test-on-cng/main.gitlab-ci.yml +++ b/.gitlab/ci/test-on-cng/main.gitlab-ci.yml @@ -69,10 +69,9 @@ workflow: retry_failed_e2e_rspec_examples fi after_script: - - source scripts/qa/cng_deploy/cng-kind.sh - - echo -e "\e[0Ksection_start:`date +%s`:log_deploy[collapsed=true]\r\e[0KDeployment info" - - save_install_logs - - echo -e "\e[0Ksection_end:`date +%s`:log_deploy\r\e[0K" + - cd qa + - bundle exec cng log events --save + - bundle exec cng log pods --save --containers all artifacts: expire_in: 1 day when: always @@ -81,7 +80,7 @@ workflow: dotenv: $QA_SUITE_STATUS_ENV_FILE paths: - qa/tmp - - ${CI_PROJECT_DIR}/*.log + - ${CI_PROJECT_DIR}/qa/*.log # ========================================== # Pre stage diff --git a/gems/gitlab-cng/.rubocop.yml b/gems/gitlab-cng/.rubocop.yml index c070ad0dc70a8617970ced601512c4f3e368444b..83c3b0a0fd98452225d27db627dd8e3748c4a20a 100644 --- a/gems/gitlab-cng/.rubocop.yml +++ b/gems/gitlab-cng/.rubocop.yml @@ -7,6 +7,9 @@ Gemfile/MissingFeatureCategory: Rails/Output: Enabled: false +Rails/Pluck: + Enabled: false + Rails/Exit: Enabled: false diff --git a/gems/gitlab-cng/lib/gitlab/cng/cli.rb b/gems/gitlab-cng/lib/gitlab/cng/cli.rb index 42034f3b722e84bc5da73157f77cd81baef49680..ff09becdb121cc8f9ac0722378bd929a271fb324 100644 --- a/gems/gitlab-cng/lib/gitlab/cng/cli.rb +++ b/gems/gitlab-cng/lib/gitlab/cng/cli.rb @@ -30,6 +30,9 @@ def self.exit_on_failure? desc "create [SUBCOMMAND]", "Manage deployment related object creation" subcommand "create", Commands::Create + + desc "log [SUBCOMMAND]", "Manage deployment related logs" + subcommand "log", Commands::Log end end end diff --git a/gems/gitlab-cng/lib/gitlab/cng/commands/log.rb b/gems/gitlab-cng/lib/gitlab/cng/commands/log.rb new file mode 100644 index 0000000000000000000000000000000000000000..971d870b45218328aaec585ac1c2078e07b065b1 --- /dev/null +++ b/gems/gitlab-cng/lib/gitlab/cng/commands/log.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Gitlab + module Cng + module Commands + # Logging related commands that retreive various deployment and cluster related information + # + class Log < Command + desc "pods [NAME]", "Log application pods" + long_desc <<~DESC + Log application pods, where NAME is full or part of the pod name. Several pods can be specified, separated by a comma. + If no NAME is specified, all pods are logged. + DESC + option :namespace, + desc: "Kubernetes namespace", + default: "gitlab", + type: :string, + aliases: "-n" + option :since, + desc: "Only return logs newer than a relative duration like 5s, 2m, or 3h. Defaults to 1h", + type: :string, + default: "1h" + option :containers, + desc: "Log all or only default containers", + default: "all", + type: :string, + enum: %w[all default] + option :save, + desc: "Save logs to a file instead of printing to stdout", + type: :boolean, + default: false + def pods(name = "") + logs = kubeclient.pod_logs(name.split(","), since: options[:since], containers: options[:containers]) + + log(" saving logs to separate files in the current directory", :info) if options[:save] + logs.each do |pod_name, pod_logs| + next if pod_logs.empty? + + if options[:save] + file_name = "#{pod_name}.log" + File.write(file_name, pod_logs) + next log(" created file '#{file_name}'", :success) + end + + log("Logs for pod '#{pod_name}'", :success) + puts pod_logs + end + end + + desc "events", "Log cluster events" + long_desc <<~DESC + Output events from the cluster for specific namespace. Useful when debugging deployment failures + DESC + option :namespace, + desc: "Kubernetes namespace", + default: "gitlab", + type: :string, + aliases: "-n" + option :save, + desc: "Save events to a file instead of printing to stdout", + type: :boolean, + default: false + def events + events = kubeclient.events + + if options[:save] + log(" saving events to separate file in the current directory", :info) + file_name = "deployment-events.log" + File.write(file_name, events) + return log(" created file '#{file_name}'", :success) + end + + puts events + end + + private + + # Kubectl client + # + # @return [Kubectl::Client] + def kubeclient + @kubeclient ||= Kubectl::Client.new(options[:namespace]) + end + end + end + end +end diff --git a/gems/gitlab-cng/lib/gitlab/cng/lib/kubectl/client.rb b/gems/gitlab-cng/lib/gitlab/cng/lib/kubectl/client.rb index 3a252b850e4a25217fdf33957fdd68600cbe9e22..eafbb357506a6e41f8aec521a6959b6eac639846 100644 --- a/gems/gitlab-cng/lib/gitlab/cng/lib/kubectl/client.rb +++ b/gems/gitlab-cng/lib/gitlab/cng/lib/kubectl/client.rb @@ -7,6 +7,7 @@ module Kubectl # class Client include Helpers::Shell + include Helpers::Output # Error raised by kubectl client class Error = Class.new(StandardError) @@ -36,28 +37,75 @@ def create_resource(resource) # @param [Array] command # @param [String] container # @return [String] - def execute(pod, command, container: nil) + def execute(pod_name, command, container: nil) args = ["--", *command] args.unshift("-c", container) if container - run_in_namespace("exec", get_pod_name(pod), args: args) + run_in_namespace("exec", get_pod_name(pod_name), args: args) + end + + # Get pod logs + # + # @param [Array<String>] pods + # @param [String] since + # @param [String] containers + # @return [Hash<String, String>] + def pod_logs(pods, since: "1h", containers: "default") + pod_data = JSON.parse(all_pods)["items"] + .select { |pod| pods.empty? || pods.any? { |p| pod.dig("metadata", "name").include?(p) } } + .each_with_object({}) { |pod, hash| hash[pod.dig("metadata", "name")] = pod.slice("metadata", "spec") } + + if pod_data.empty? + raise Error, "No pods matched: #{pods.join(', ')}" unless pods.empty? + + raise Error, "No pods found in namespace '#{namespace}'" + end + + log("Fetching logs for pods '#{pod_data.keys.join(', ')}'", :info) + pod_data.to_h do |pod_name, data| + default_container = data.dig("spec", "containers").first["name"] + [ + pod_name, + run_in_namespace("logs", "pod/#{pod_name}", args: [ + "--since=#{since}", + "--prefix=true", + containers == "default" ? "--container=#{default_container}" : "--all-containers=true" + ]) + ] + end + end + + # Get events + # + # @return [String] + def events + log("Fetching events", :info) + run_in_namespace("get", "events", args: ["--sort-by=lastTimestamp"]) end private attr_reader :namespace + # Get all pods in namespace + # + # @param [String] output --output type + # @return [String] + def all_pods(output: "json") + run_in_namespace("get", "pods", args: ["--output", output]) + end + # Get full pod name # # @param [String] name # @return [String] def get_pod_name(name) - pod = run_in_namespace("get", "pods", args: ["--output", "jsonpath={.items[*].metadata.name}"]) + pod_name = all_pods(output: "jsonpath={.items[*].metadata.name}") .split(" ") - .find { |pod| pod.include?(name) } - raise Error, "Pod '#{name}' not found" unless pod + .find { |pod_name| pod_name.include?(name) } + raise Error, "Pod '#{name}' not found" unless pod_name - pod + pod_name end # Run kubectl command in namespace diff --git a/gems/gitlab-cng/spec/integration/cng_spec.rb b/gems/gitlab-cng/spec/integration/cng_spec.rb index 8a33eb85bc260d26afc2b627fc9de88b26e3d32e..8854d6cb7e37d9c196d03fa49aa2530370a69fcb 100644 --- a/gems/gitlab-cng/spec/integration/cng_spec.rb +++ b/gems/gitlab-cng/spec/integration/cng_spec.rb @@ -7,6 +7,7 @@ cng create [SUBCOMMAND] # Manage deployment related object creation cng doctor # Validate presence of all required system dependencies cng help [COMMAND] # Describe available commands or one specific command + cng log [SUBCOMMAND] # Manage deployment related logs cng version # Print cng orchestrator version USAGE end diff --git a/gems/gitlab-cng/spec/integration/gitlab/cng/cli_spec.rb b/gems/gitlab-cng/spec/integration/gitlab/cng/cli_spec.rb index 6ceb84c600914710f744a61e765d95ccae59641a..1727d7f8d41fb99e60645c13e5fcab8fafc1ee8a 100644 --- a/gems/gitlab-cng/spec/integration/gitlab/cng/cli_spec.rb +++ b/gems/gitlab-cng/spec/integration/gitlab/cng/cli_spec.rb @@ -2,76 +2,60 @@ RSpec.describe Gitlab::Cng::CLI do shared_examples "command with help" do |args, help_output| - it "shows help" do + it "shows help for #{args.last} command" do expect { cli.start(args) }.to output(/#{help_output}/).to_stdout end end - subject(:cli) { described_class } + shared_examples "executable command" do |command_class, args| + let(:command_instance) { command_class.new } - describe "version command" do - it_behaves_like "command with help", %w[help version], /Print cng orchestrator version/ + before do + allow(command_class).to receive(:new).and_return(command_instance) + allow(command_instance).to receive(args.last.to_sym) + end + + it "correctly invokes #{args} command" do + cli.start(args) - it "executes version command" do - expect { cli.start(%w[version]) }.to output(/#{Gitlab::Cng::VERSION}/o).to_stdout + expect(command_instance).to have_received(args.last.to_sym) end end - describe "doctor command" do - let(:command_instance) { Gitlab::Cng::Commands::Doctor.new } + subject(:cli) { described_class } - before do - allow(Gitlab::Cng::Commands::Doctor).to receive(:new).and_return(command_instance) - allow(command_instance).to receive(:doctor) - end + describe "version command" do + it_behaves_like "command with help", %w[help version], /Print cng orchestrator version/ + it_behaves_like "executable command", Gitlab::Cng::Commands::Version, %w[version] + end + describe "doctor command" do it_behaves_like "command with help", %w[help doctor], /Validate presence of all required system dependencies/ + it_behaves_like "executable command", Gitlab::Cng::Commands::Doctor, %w[doctor] + end - it "invokes doctor command" do - cli.start(%w[doctor]) + describe "log command" do + it_behaves_like "command with help", %w[log help events], + /Output events from the cluster for specific namespace/ + it_behaves_like "command with help", %w[log help pods], + /Log application pods, where NAME is full or part of the pod name/ - expect(command_instance).to have_received(:doctor) - end + it_behaves_like "executable command", Gitlab::Cng::Commands::Log, %w[log pods] + it_behaves_like "executable command", Gitlab::Cng::Commands::Log, %w[log events] end describe "create command" do context "with cluster subcommand" do - let(:cluster_instance) { instance_double(Gitlab::Cng::Kind::Cluster, create: nil) } - - before do - allow(Gitlab::Cng::Kind::Cluster).to receive(:new).and_return(cluster_instance) - end - it_behaves_like "command with help", %w[create help cluster], /Create kind cluster for local deployments/ - - it "invokes cluster create command" do - cli.start(%w[create cluster]) - - expect(Gitlab::Cng::Kind::Cluster).to have_received(:new) - .with(ci: false, name: "gitlab", host_http_port: 80, host_ssh_port: 22) - expect(cluster_instance).to have_received(:create) - end + it_behaves_like "executable command", Gitlab::Cng::Commands::Create, %w[create cluster] end context "with deployment subcommand" do - let(:installation_instance) { instance_double(Gitlab::Cng::Deployment::Installation, create: nil) } - context "with kind deployment" do - let(:configuration_instance) { instance_double(Gitlab::Cng::Deployment::Configurations::Kind) } - - before do - allow(Gitlab::Cng::Deployment::Installation).to receive(:new).and_return(installation_instance) - allow(Gitlab::Cng::Deployment::Configurations::Kind).to receive(:new) - end - it_behaves_like "command with help", %w[create deployment help kind], /Create CNG deployment against local kind k8s cluster/ - it "invokes kind deployment" do - cli.start(%w[create deployment kind --gitlab-domain 127.0.0.1.nip.io --skip-create-cluster]) - - expect(installation_instance).to have_received(:create) - end + it_behaves_like "executable command", Gitlab::Cng::Commands::Subcommands::Deployment, %w[create deployment kind] end end end diff --git a/gems/gitlab-cng/spec/unit/gitlab/cng/commands/log_spec.rb b/gems/gitlab-cng/spec/unit/gitlab/cng/commands/log_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..819242091f3ebe53c39f5ed6984cce7d2803697a --- /dev/null +++ b/gems/gitlab-cng/spec/unit/gitlab/cng/commands/log_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +RSpec.describe Gitlab::Cng::Commands::Log do + include_context "with command testing helper" + + let(:kubeclient) { instance_double(Gitlab::Cng::Kubectl::Client) } + + before do + allow(Gitlab::Cng::Kubectl::Client).to receive(:new).with("gitlab").and_return(kubeclient) + end + + describe "pods command" do + let(:command_name) { "pods" } + + let(:pod_logs) do + { + "pod-1" => "log-1", + "pod-2" => "log-2" + } + end + + before do + allow(kubeclient).to receive(:pod_logs).and_return(pod_logs) + pod_logs.each { |name, log| allow(File).to receive(:write).with("#{name}.log", log) } + end + + it "defines pods command" do + expect_command_to_include_attributes(command_name, { + description: "Log application pods", + name: command_name, + usage: "#{command_name} [NAME]" + }) + end + + it "prints all pod logs" do + expect { invoke_command(command_name) }.to output( + match(/Logs for pod 'pod-1'/).and(match(/log-1/).and(match(/Logs for pod 'pod-2'/).and(match(/log-2/)))) + ).to_stdout + end + + it "fetches log for single pod only" do + expect { invoke_command(command_name, ["pod-1"], {}) }.to output.to_stdout + expect(kubeclient).to have_received(:pod_logs).with(["pod-1"], containers: "all", since: "1h") + end + + it "saves logs to files", :aggregate_failures do + expect { invoke_command(command_name, [], { save: true }) }.to output( + match(/saving logs to separate files in the current directory/).and(match(/created file 'pod-1.log'/)) + ).to_stdout + + expect(File).to have_received(:write).with("pod-1.log", "log-1") + expect(File).to have_received(:write).with("pod-2.log", "log-2") + end + end + + describe "events command" do + let(:command_name) { "events" } + let(:events) { "events" } + + before do + allow(kubeclient).to receive(:events).and_return(events) + allow(File).to receive(:write).with("deployment-events.log", events) + end + + it "defines events command" do + expect_command_to_include_attributes(command_name, { + description: "Log cluster events", + name: command_name, + usage: command_name + }) + end + + it "prints events" do + expect { invoke_command(command_name) }.to output("#{events}\n").to_stdout + end + + it "saves events to file" do + expect { invoke_command(command_name, [], { save: true }) }.to output( + match(/saving events to separate file in the current directory/).and( + match(/created file 'deployment-events.log'/) + ) + ).to_stdout + end + end +end diff --git a/gems/gitlab-cng/spec/unit/gitlab/cng/kubectl/client_spec.rb b/gems/gitlab-cng/spec/unit/gitlab/cng/kubectl/client_spec.rb index a3ca54b1e5a6c63dc4ff1f1b2df901a5592261cd..030b76db0d5da37401a7cd8080457e5caf46db1d 100644 --- a/gems/gitlab-cng/spec/unit/gitlab/cng/kubectl/client_spec.rb +++ b/gems/gitlab-cng/spec/unit/gitlab/cng/kubectl/client_spec.rb @@ -3,31 +3,147 @@ RSpec.describe Gitlab::Cng::Kubectl::Client do subject(:client) { described_class.new("gitlab") } - let(:command_status) { instance_double(Process::Status, success?: true) } let(:resource) { Gitlab::Cng::Kubectl::Resources::Configmap.new("config", "some", "value") } before do - allow(Open3).to receive(:popen2e).and_return(["cmd-output", command_status]) + allow(client).to receive(:execute_shell).and_return("cmd-output") end it "creates namespace" do expect(client.create_namespace).to eq("cmd-output") - expect(Open3).to have_received(:popen2e).with({}, *%w[kubectl create namespace gitlab]) + expect(client).to have_received(:execute_shell).with(%w[kubectl create namespace gitlab]) end it "creates custom resource" do expect(client.create_resource(resource)).to eq("cmd-output") - expect(Open3).to have_received(:popen2e).with({}, *%w[kubectl apply -n gitlab -f -]) + expect(client).to have_received(:execute_shell).with( + %w[kubectl apply -n gitlab -f -], + stdin_data: resource.json + ) end - it "executes custom command in pod" do - allow(Open3).to receive(:popen2e).with({}, *%w[ - kubectl get pods -n gitlab --output jsonpath={.items[*].metadata.name} - ]).and_return(["some-pod-123 test-pod-123", command_status]) + describe "#execute" do + before do + allow(client).to receive(:execute_shell).with( + %w[kubectl get pods -n gitlab --output jsonpath={.items[*].metadata.name}], stdin_data: nil + ).and_return("some-pod-123 test-pod-123") + end - expect(client.execute("test-pod", ["ls"], container: "toolbox")).to eq("cmd-output") - expect(Open3).to have_received(:popen2e).with({}, *%w[ - kubectl exec test-pod-123 -n gitlab -c toolbox -- ls - ]) + it "executes command in a pod" do + expect(client.execute("test-pod", ["ls"], container: "toolbox")).to eq("cmd-output") + expect(client).to have_received(:execute_shell).with( + %w[kubectl exec test-pod-123 -n gitlab -c toolbox -- ls], + stdin_data: nil + ) + end + end + + describe "#pod_logs" do + let(:all_pods_json) do + { + items: [ + { + metadata: { + name: "some-pod-123" + }, + spec: { + containers: [ + { + name: "toolbox" + } + ] + } + }, + { + metadata: { + name: "test-pod-123" + }, + spec: { + containers: [ + { + name: "gitaly" + } + ] + } + } + ] + }.to_json + end + + before do + allow(client).to receive(:execute_shell).with( + %w[kubectl get pods -n gitlab --output json], + stdin_data: nil + ).and_return(all_pods_json) + end + + def mock_pod_logs(name, containers_arg) + allow(client).to receive(:execute_shell).with( + %W[kubectl logs pod/#{name} -n gitlab --since=1h --prefix=true #{containers_arg}], + stdin_data: nil + ).and_return("#{name} logs") + end + + context "with logs for specific pod and default container" do + let(:pod_name) { "some-pod-123" } + + before do + mock_pod_logs(pod_name, "--container=toolbox") + end + + it "returns logs for pod" do + logs = nil + + expect { logs = client.pod_logs([pod_name]) }.to output(/Fetching logs for pods '#{pod_name}'/).to_stdout + expect(logs).to eq({ pod_name => "#{pod_name} logs" }) + end + end + + context "with logs for all pods and containers" do + let(:pods) { %w[some-pod-123 test-pod-123] } + + before do + pods.each { |name| mock_pod_logs(name, "--all-containers=true") } + end + + it "returns logs for pod" do + logs = nil + + expect { logs = client.pod_logs([], containers: "all") }.to output( + /Fetching logs for pods '#{pods.join(', ')}'/ + ).to_stdout + expect(logs).to eq(pods.to_h { |name| [name, "#{name} logs"] }) + end + end + + context "with no pods matching specific pod" do + it "raises an error" do + expect { client.pod_logs(%w[missing-pod]) }.to raise_error("No pods matched: missing-pod") + end + end + + context "with no pods returned from cluster" do + let(:all_pods_json) { { items: [] }.to_json } + + it "raises an error" do + expect { client.pod_logs([]) }.to raise_error("No pods found in namespace 'gitlab'") + end + end + end + + describe "#events" do + before do + allow(client).to receive(:execute_shell).with( + %w[kubectl get events -n gitlab --sort-by=lastTimestamp], + stdin_data: nil + ).and_return("some events") + end + + it "return events" do + events = nil + + expect { events = client.events }.to output(/Fetching events/).to_stdout + expect(events).to eq("some events") + end end end diff --git a/scripts/qa/cng_deploy/cng-kind.sh b/scripts/qa/cng_deploy/cng-kind.sh deleted file mode 100644 index 5de49b2e6de2a5e0a65ea6af2a137eb2c5ca5b5c..0000000000000000000000000000000000000000 --- a/scripts/qa/cng_deploy/cng-kind.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash - -# -# General utils -# - -function log() { - echo -e "\033[1;32m$1\033[0m" -} - -function warn() { - echo -e "\033[1;33m$1\033[0m" -} - -function log_info() { - echo -e "\033[1;35m$1\033[0m" -} - -function log_with_header() { - length=$(echo "$1" | awk '{print length}') - delimiter=$(printf -- "${2:-=}%.0s" $(seq $length)) - - log_info "$delimiter" - log_info "$1" - log_info "$delimiter" -} - -function save_install_logs() { - log_with_header "Events of namespace ${NAMESPACE}" - kubectl get events --output wide --namespace ${NAMESPACE} - - for pod in $(kubectl get pods --no-headers --namespace ${NAMESPACE} --output 'jsonpath={.items[*].metadata.name}'); do - log_with_header "Description of pod ${pod}" - kubectl describe pod ${pod} --namespace ${NAMESPACE} - - for container in $(kubectl get pods ${pod} --no-headers --namespace ${NAMESPACE} --output 'jsonpath={.spec.initContainers[*].name}'); do - kubectl logs ${pod} --namespace ${NAMESPACE} --container ${container} >"${container}.log" - done - - for container in $(kubectl get pods ${pod} --no-headers --namespace ${NAMESPACE} --output 'jsonpath={.spec.containers[*].name}'); do - kubectl logs ${pod} --namespace ${NAMESPACE} --container ${container} >"${container}.log" - done - done -}